weixin_39627408
weixin_39627408
2020-11-21 02:42

Vue源码详细解析(一)--数据的响应化

源码整体概览

Vue源码构造实例的过程就一行

this._init(options)
,用你的参数对象去执行init初始化函数。init函数中先进行了大量的参数初始化操作
this.xxx = blabla
,然后剩下这么几行代码(后文所有的英文注释是尤雨溪所写,中文是我添加的,英文注释极其精确、简洁,请勿忽略
javascript
this._data = {}

// call init hook
this._callHook('init')

// initialize data observation and scope inheritance.
this._initState()

// setup event system and option events.
this._initEvents()

// call created hook
this._callHook('created')

// if `el` option is passed, start compilation.
if (options.el) {
    this.$mount(options.el)
}

基本就是触发init钩子,初始化一些状态,初始化event,然后触发created钩子,最后挂载到具体的元素上面去。_initState()方法中包含了数据的初始化操作,也就是让数据变成响应式的,让Vue能够监听到数据的变动。而this.$mount()方法则承载了绝大部分的代码量,负责模板的嵌入、编译、link、指令和watcher的生成、批处理的执行等等。

从数据的响应化说起

嗯,是的,虽然这个observe数据的部分已经被很多文章说烂了,但是我并不只是讲getter/setter,这里应该会有你没看过的部分,比如Vue是如何解决"getter/setter无法监听属性的添加和删除"的。

熟悉Vue的同学都了解Vue的响应式特性,对于data对象的几乎任何更改我们都能够监听到。这是MVVM的基础,基本思路就是遍历每一个属性,然后使用Object.defineProperty将这个属性设置为响应式的(即我能监听到他的改动)。

先说遍历,很简单,如下10行左右代码就足够遍历一个对象了:

javascript
function touch (obj) {
    if (typeof obj === 'object')
      if (Array.isArray(obj)) {
        for (let i = 0,l = obj.length; i < l; i++) {
          touch(obj[i])
        }
      } else {
        let keys = Object.keys(obj)
        for (let key of keys) touch(obj[key])
      }
    console.log(obj)
  }

遇到普通数据属性,直接处理,遇到对象,遍历属性之后递归进去处理属性,遇到数组,递归进去处理数组元素(console.log)。

遍历完就到处理了,也就是Object.defineProperty部分了,对于一个对象,我们可以用这个来改写它属性的getter/setter,这样,当你改属性的值我就有办法监听到。但是对于数组就有问题了。

你也许想到可以遍历当前存在的下标,然后执行Object.defineProperty。这种处理方法先不说性能问题,很多时候我们操作数组是采用push、pop、splice、unshift等方法来操作的,光是push你就没办法监听,更不要说pop后你设置的getter/setter就直接没了。

所以,Vue的方法是,改写数组的push、pop等8个方法,让他们在执行之后通知我数组更新了(这种方法带来的后果就是你不能直接修改数组的长度或者通过下标去修改数组。参见官网)。这样改进之后我就不需要对数组元素进行响应式处理,只是遇到数组的时候把数组的方法变异即可。于是在用户使用数组的push、pop等方法会改变数组本身的方法时,可以监听到数组变动。

此外,当数组内部元素是对象时,设置getter/setter是可以监听对象的,所以对于数组元素还是要遍历一下的。如果不是对象,比如a[0]是字符串、数字?那就没办法了,但是vue为数组提供了$set和$remove,方便我们可以通过下标去响应式的改动数组元素,这里后文再说。

我们先说说怎么“变异”数组的push等方法,并且找出数组元素中的对象,让对象响应式。我们结合我的注释版源码来看一下。

javascript
Vue.prototype._initData = function () {
    // 初始化数据,其实一方面把data的内容代理到vm实例上,
    // 另一方面改造data,变成reactive的
    // 即get时触发依赖收集(将订阅者加入Dep实例的subs数组中),set时notify订阅者
    var dataFn = this.$options.data
    var data = this._data = dataFn ? dataFn() : {}

    var props = this._props
    // proxy data on instance
    var keys = Object.keys(data)
    var i, key
    i = keys.length
    while (i--) {
        key = keys[i]
        // 将data属性的内容代理到vm上面去,使得vm访问指定属性即可拿到_data内的同名属性
        // 实现vm.prop === vm._data.prop,
        // 这样当前vm的后代实例就能直接通过原型链查找到父代的属性
        // 比如v-for指令会为数组的每一个元素创建一个scope,这个scope就继承自vm或上级数组元素的scope,
        // 这样就可以在v-for的作用域中访问父级的数据
        this._proxy(key)
    }
    // observe data
    //重点来了
    observe(data, this)
  }

(注释里的依赖收集、Dep什么的大家看不懂没关系,请跳过,后面会细说)

代码中间做了_proxy操作,注释里我已经写明原因。_proxy操作也很简单想了解的话大家自己查看源码即可。

代理完了之后就开始observe这个data:

javascript
export function observe (value, vm) {
  if (!value || typeof value !== 'object') {
    // 保证只有对象会进入到这个函数
    return
  }
  var ob
  if (
    //如果这个数据身上已经有ob实例了,那observe过了,就直接返回那个ob实例
    hasOwn(value, '__ob__') &&
    value.__ob__ instanceof Observer
  ) {
    ob = value.__ob__
  } else if (
    shouldConvert &&
    (isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    // 是对象(包括数组)的话就深入进去遍历属性,observe每个属性
    ob = new Observer(value)
  }
  if (ob && vm) {
    // 把vm加入到ob的vms数组当中,因为有的时候我们会对数据手动执行$set/$delete操作,
    // 那么就要提示vm实例这个行为的发生(让vm代理这个新$set的数据,和更新界面)
    ob.addVm(vm)
  }
  return ob
}

代码的执行过程一般都是进入到那个else if里,执行new Observer(value),至于shouldConvert和后续的几个判断则是为了防止value不是单纯的对象而是Regexp或者函数之类的,或者是vm实例再或者是不可扩展的,shouldConvert则是某些特殊情况下为false,它的解释参见源码里尤雨溪的注释。

那好,现在就进入到拿当前的data对象去new Observer(value),现在你可能会疑惑,递归遍历的过程不是应该是纯命令式的、面向过程的吗?怎么代码跑着跑着跑出来一句new一个对象了,嗯先不用管,我们先理清代码执行过程,先带着这个疑问。同时,我们注意到代码最后return了ob,结合代码,我们可以理解为如果return的是undifned,那么说明传进来的value不是对象,反之return除了一个ob,则说明这个value是对象或数组,他可以添加或删除属性,这一点我们先记着,这个东西后面有用。

我们先看看Observer构造函数:

javascript
/**
 * Observer class that are attached to each observed
 * object. Once attached, the observer converts target
 * object's property keys into getter/setters that
 * collect dependencies and dispatches updates.
 *
 *  {Array|Object} value
 * 
 */

function Observer (value) {
  this.value = value
  this.dep = new Dep()
  def(value, '__ob__', this) //value的__ob__属性指向这个Ob实例
  if (isArray(value)) {
    var augment = hasProto
      ? protoAugment
      : copyAugment
    augment(value, arrayMethods, arrayKeys)
    this.observeArray(value)
  } else {
    // 如果是对象则使用walk遍历每个属性
    this.walk(value)
  }
}

observe一个数组

上述代码中,如果遇到数组data中的数组实例增加了一些“变异”的push、pop等方法,这些方法会在数组原本的push、pop方法执行后发出消息,表明发生了改动。听起来这好像可以用继承的方式实现: 继承数组然后在这个子类的原型上附加上变异的方法。

但是你需要知道的是在es5及更低版本的js里,无法完美继承数组,主要原因是Array.call(this)时,Array根本不是像一般的构造函数那样对你传进去this进行改造,而是直接返回一个新的数组。所以一般的继承方式就没法实现了。参见这篇文章所以出现了新建一个iframe,然后直接拿那个iframe里的数组的原型进行修改,添加自定义方法,诸如此类的hack方法,在此按下不表。

但是如果当前浏览器里存在__proto__这个非标准属性的话(大部分都有),那又可以有方法继承,就是创建一个继承自Array.prototype的Object: Object.create(Array.prototype),在这个继承了数组原生方法的对象上添加方法或者覆盖原有方法,然后创建一个数组,把这个数组的__proto__指向这个对象,这样这个数组的响应式的length属性又得以保留,又获得了新的方法,而且无侵入,不会改变本来的数组原型。

Vue就是基于这个思想,先判断__proto__能不能用(hasProto),如果能用,则把那个一个继承自Array.prototype的并且添加了变异方法的Object (arrayMethods),设置为当前数组的__proto__,完成改造,如果__proto__不能用,那么就只能遍历arrayMethods就一个个的把变异方法def到数组实例上面去,这种方法效率不高,所以优先使用改造__proto__的那个方法。

源码里后面那句this.observeArray非常简单,for遍历传进去的value,然后对每个元素执行observe,处理之前说的数组的元素为对象或者数组的情况。好了,对于数组的讨论先打住,至于数组的变异方法怎么通知我他进行了更改之类的我们不说了,我们先说清楚对象的情况,对象说清楚了,再去看源码就一目了然了。

observe 对象

对于对象,上面的代码执行this.walk(value),他“游走”对象的每个属性,对属性和属性值执行defineReactive函数。

javascript
function Dep () {
  this.id = uid++
  this.subs = []
}

Dep.prototype.depend = function () {
  Dep.target.addDep(this)
}

Dep.prototype.notify = function () {
  // stablize the subscriber list first
  var subs = toArray(this.subs)
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}

function defineReactive (obj, key, val) {
  // 生成一个新的Dep实例,这个实例会被闭包到getter和setter中
  var dep = new Dep()

  var property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  var getter = property && property.get
  var setter = property && property.set
  // 对属性的值继续执行observe,如果属性的值是一个对象,那么则又递归进去对他的属性执行defineReactive
  // 保证遍历到所有层次的属性
  var childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      var value = getter ? getter.call(obj) : val
      // 只有在有Dep.target时才说明是Vue内部依赖收集过程触发的getter
      // 那么这个时候就需要执行dep.depend(),将watcher(Dep.target的实际值)添加到dep的subs数组中
      // 对于其他时候,比如dom事件回调函数中访问这个变量导致触发的getter并不需要执行依赖收集,直接返回value即可
      if (Dep.target) {
        dep.depend()
        if (childOb) {
         //如果value是对象,那就让生成的Observer实例当中的dep也收集依赖
          childOb.dep.depend()
        }
        if (isArray(value)) {
          for (var e, i = 0, l = value.length; i < l; i++) {
            e = value[i]
            //如果数组元素也是对象,那么他们observe过程也生成了ob实例,那么就让ob的dep也收集依赖
            e && e.__ob__ && e.__ob__.dep.depend()
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val
      if (newVal === value) {
        return
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // observe这个新set的值
      childOb = observe(newVal)
      // 通知订阅我这个dep的watcher们:我更新了
      dep.notify()
    }
  })
}

我们来说说这个Dep,Dep类的定义极其简单,一个id,一个数组,他就是一个很基本的发布者-观察者模式的实现,作为一个发布者,他的subs属性用来存放了订阅他的观察者,也就是后面我们会说到的watcher。

defineProperty是用来将对象的属性转化为响应式的getter/setter的,defineProperty函数执行过程中新建了一个Dep,闭包在了属性的getter和setter中,因此每个属性都有一个唯一的Dep与其对应,我们暂且可以把属性和他对应的Dep理解为一体的。

Dep其实是dependence依赖的缩写,我之前一直没能理解依赖、依赖收集是什么,其实对于我们的一个模板{{a+b}},我们会说他的依赖有a和b,其实就是依赖了data的a和b属性,更精确的说是依赖了a属性中闭包的dep实例和b属性中闭包的那个dep实例。

详细来说:我们的这个{{a+b}}在dom里最终会被"a+b"表达式的真实值所取代,所以存在一个求出这个“a+b”的表达式的过程,求值的过程就会自然的分别触发a和b的getter,而在getter中,我们看到执行了dep.depend(),这个函数实际上回做dep.addSub(Dep.target),即在dep的订阅者数组中存放了Dep.target,让Dep.target订阅dep

那Dep.target是什么?他就是我们后面介绍的Watcher实例,为什么要放在Dep.target里呢?是因为getter函数并不能传参,dep可以通过闭包的形式放进去,那watcher可就不行了,watcher内部存放了a+b这个表达式,也是由watcher计算a+b的值,在计算前他会把自己放在一个公开的地方(Dep.target),然后计算a+b,从而触发表达式中所有遇到的依赖的getter,这些getter执行过程中会把Dep.target加到自己的订阅列表中。等整个表达式计算成功,Dep.target又恢复为null.这样就成功的让watcher分发到了对应的依赖的订阅者列表中,订阅到了自己的所有依赖。

我们可以看到这是极其精妙的一笔!在一个表达式的求值过程中隐式的完成依赖订阅。

上面完成的是订阅的过程,而上面setter代码里的dep.notify就负责完成数据变动时通知订阅者的功能。而且数据变化时,后文会说明只有依赖他的那些dom会精确更新,不会出现一些介绍mvvm的文章里虽然实现了订阅更新但是重新计算整个视图的情况。

于是一整个对象订阅、notify的过程就结束了。

Observer类?

现在我们明白了Dep的作用和收集订阅依赖的过程,但是对于watcher是什么肯定还是云里雾里的,先别急。我们先解决之前的疑问:为什么命令式的监听过程中出现了个new Observer()?而且构造函数第一行就创建了一个dep(这个dep不是defineReactive里的那个闭包dep,注意区分),在defineReactive函数的getter中还执行了childOb.dep.depend(),去完成了这个dep的watcher添加?

我们考虑一下这样的情况,比如我的data:{a:{b:true}},这个时候,如果页面有dom上有个指令:class="a",而我想响应式的删除data.a的b属性,此时我就没有办法了,因为defineReactive中的getter/setter都不会执行(他们甚至还会在delete a.b时被清空),闭包里的那个dep就无法通知对应的watcher。

这就是getter和setter存在的缺陷:只能监听到属性的更改,不能监听到属性的删除与添加。

Vue的解决办法是提供了响应式的api: vm.$set/vm.$delete/ Vue.set/ Vue.delete /数组的$set/数组的$remove。

具体方法是为所有的对象和数组(只有这俩哥们才可能delete和新建属性),也创建一个dep,也完成收集依赖的过程。我们回到源码defineReactive再看一遍,在执行defineReactive(data,'a',{b:true})时,他首先创造了那个闭包在getter/setter中的dep,然后var childOb = observe(val),val是{b:true},那就会为这个对象new Observer(val),并放在val.__ob__上,而这个ob实例上存放了一个Dep实例。现在我们看到,有两个Dep实例,一个是闭包里的dep,一个是为{b:true}创建的ob上的这个dep。而:class="a"生成的watcher的求值过程中会触发到a的getter,那就会执行:

javascript
dep.depend()
if (childOb) {
    //如果value是对象,那就让生成的Observer实例当中的dep也收集依赖
    childOb.dep.depend()
}

这一步,:class="a"的watcher既会订阅闭包dep,也会订阅ob的dep。

当我们执行Vue.delete(this.a,'b'),内部会执行del函数,他会找到要删除属性的那个对象,也是{b:true},它的__ob__属性存放了ob,现在先删除属性,然后执行ob.dep.notify,通知所有依赖这个对象的watcher重新计算,这个时候属性已经删除了,重新计算的值(为空)就会刷新到页面上,完成dom响应式更新。参见此处源码

不仅对于属性的删除这样,属性的的添加也是类似的,都是为了弥补getter和setter存在的缺陷,都会找到这个dep执行notify。不过data的顶级属性略有不同,涉及到digest,此处不表。

同时我们再回到之前遍历数组的代码,我们数组的响应化代码甚至都里没有getter/setter,他连那个闭包的dep都没有,代码只是变异了一下push/pop方法。他有的只是那个childOb上的dep,所以数组的响应式过程都是notify的这个dep,不管是数组的变异方法,还是数组的$set/$remove里我们都会看到是在这个dep上触发notify,通知订阅了整个数组的watcher进行更新。所以你知道这个dep的重要性了把。当然这也就有问题了,我一个watcher订阅整个数组,当数组的元素有改动我就会收到消息,但我不知道变动的是哪个,难道我要用整个数组重新构造一下dom?所以这就是数组diff算法的使用场景了。

至于Observer,这个额外的实例上存放了一个dep,这个dep配合Observer的addVm、removeVm、vms等属性来一起搞定data的顶级属性的新增或者删除,至于为什么不直接在数据上存放dep,而是搞个Observer,并把dep定义在上面,我觉得是Observer的那些方法和vms等属性,并不是所有的dep都应该具有的,作为dep的实例属性是不应该的,所以就抽象了个Observer这么个东东吧,顺便把walk、convert之类的函数变成方法挂在Observer上了,抽象出个专门用来observe的类而已,这部分纯属个人臆测。

该提问来源于开源项目:Ma63d/vue-analysis

  • 点赞
  • 写回答
  • 关注问题
  • 收藏
  • 复制链接分享
  • 邀请回答

17条回答

  • weixin_39727706 weixin_39727706 4月前

    “每个属性都有一个唯一的Dep与其对应,我们暂且可以把属性和他对应的Dep理解为一体的” 赞啊,之前一直没串懂。。。

    点赞 评论 复制链接分享
  • weixin_39627408 weixin_39627408 4月前

    你好,可以看一下我在这篇文章下面的评论 https://github.com/youngwind/blog/issues/85

    点赞 评论 复制链接分享
  • weixin_39938331 weixin_39938331 4月前

    "但是你需要知道的是在es5及更低版本的js里,无法完美继承数组,主要原因是Array.call(this)时,Array根本不是像一般的构造函数那样对你传进去this进行改造,而是直接返回一个新的数组。所以一般的继承方式就没法实现了。参见这篇文章" 你好,我看这篇文章理解的意思主要应该不是因为直接返回一个新的数组吧,subArray在调用构造函数的时候是会返回一个this(普通的object),文章里面只是说可以手动强制返回一个新的数组对象,但是这样就无法实现继承了

    点赞 评论 复制链接分享
  • weixin_39627408 weixin_39627408 4月前

    哦哦, 这个操作其实很简单: 可以看这几行:https://github.com/Ma63d/vue-analysis/blob/master/vue%E6%BA%90%E7%A0%81%E6%B3%A8%E9%87%8A%E7%89%88/watcher.js#L125-L129

    一般的数据绑定只监听指定下标数据的改动,如果是像你这样的监听指定下标对象里数据的任何改动,那么这就是 deep watcher,比如 v-style 和 v-class 指令的 deep 字段都为 true。 对于 deep watcher ,在依赖收集阶段的时候 traverse 一下这个需要 deep 监听的对象就好了,于是就会深度优先遍历去访问到所有的深层属性,而一旦触发了深层属性的 getter,这个深层属性就会作为依赖被收集到这个 watcher 对应的依赖列表中,深度优先的递归遍历结束后,所有的依赖就收集完成了,所以简单又无脑对吧。

    点赞 评论 复制链接分享
  • weixin_39800957 weixin_39800957 4月前

    我有一个疑惑点,收集依赖时是如何做到向下收集的?

    举个例子:

    javascript
    let data = {a: {b: {c: {d: {e: 1}}}}}
    

    模板中引用了 {{a.b.c}}。此时无论是修改 a / a.b 还是 a.b.c.d.e 都应该能触发更新。

    依赖向上收集很简单,访问 a.b.c 时直接写进了 a、b、c 的 get/set 闭包 dep 里。但是如何做到修改 e 时也触发通知,也就是依赖的向下收集呢?我测试过,修改 a.b.c.d.e 不会触发 a.b.c 的 setter(对于复杂类型 setter 应该只检查引用变化),而 e 的 setter 中应该只触发属于 e 的 get/set 闭包 dep,{{a.b.c}} 的 watcher 应该是放不进 e 的 get/set 闭包 dep 的。

    想了好几天了,希望和 lz 探讨下

    点赞 评论 复制链接分享
  • weixin_39846378 weixin_39846378 4月前

    太佩服你们了

    点赞 评论 复制链接分享
  • weixin_39834745 weixin_39834745 4月前

    我懂了

    点赞 评论 复制链接分享
  • weixin_39627408 weixin_39627408 4月前

    我看了 这行代码不写不行的,是为了求出值并把值刷新到dom上。 在Vue里也有这个操作的,一个mvvm构造阶段完成后你得保证页面里呈现你想绑定的值,比如一个input v-model绑定到a属性,你得在Vue构造完毕时保证页面这个input里的value正确呈现为a的实际值。而watcher只是在数据变动时才会更新dom,所以对于这种初始阶段不是数据变动的情况,必须得手动执行更新dom的这个操作。

    在Vue里也一样,指令的初始化阶段(_bind)函数中,生成watcher,绑定依赖后会this.update(watcher.value)。不过顺序不太一样,你给的代码是先update,Vue是先watcher绑定完依赖之后再update,没啥大区别。

    点赞 评论 复制链接分享
  • weixin_39834745 weixin_39834745 4月前

    我加着你qq呢,我在qq上说吧

    点赞 评论 复制链接分享
  • weixin_39627408 weixin_39627408 4月前

    额。。我待会回寝室以后帮你看看吧

    点赞 评论 复制链接分享
  • weixin_39834745 weixin_39834745 4月前

    我看的是一个别人的mvvm,他这里是什么意思?https://github.com/DMQ/mvvm/blob/master/js/compile.js#L126-L131 这里我感觉不写第126行也可以啊?还是没太想明白

    点赞 评论 复制链接分享
  • weixin_39627408 weixin_39627408 4月前

    在Vue初始化阶段。 在compile里会检测出你写的v-model/v-if/:style等等Vue指令,在link阶段会将这些检测到的指令创建为真正的Directive实例,之后对这些指令按照优先级排好序,然后按优先级从高到低,依次执行他们的_bind方法。而每个Directive的_bind方法中会生成一个自己的watcher。watcher也会在此时执行依赖收集的过程。

    点赞 评论 复制链接分享
  • weixin_39834745 weixin_39834745 4月前

    我再问一下,这个watcher实例是什么时候被添加的,初次编译的时候吗?

    点赞 评论 复制链接分享
  • weixin_39627408 weixin_39627408 4月前

    比如最后一个收集依赖的watcher是{{a}}对应的watcher,它完成依赖订阅之后,它订阅到a属性,这没问题。可是如果不把Dep.target恢复为null,他就会一直在Dep.target上。

    接下来,假如说你在页面其他地方写了个v-on,现在v-on被触发了,他的回调函数里你访问了this.b,那么就会去执行b的getter,而任何getter执行过程中都会不管三七二十一,只要Dep.target不为null,就让Dep.target订阅我自己的dep。 现在出现了{{a}}的watcher订阅了b属性。这就出错了。

    所以任何一个watcher的依赖收集过程完毕之后,都会在afterGet里清空Dep.target。

    其实在Vue里,Dep.target有值的情况只会是watcher在进行依赖订阅的情况,所以if Dep.target为null,getter里就直接return val就完事了。

    点赞 评论 复制链接分享
  • weixin_39834745 weixin_39834745 4月前

    observer对象那里,还是不明白为什么在添加完依赖后,dep.target为什么要设置为null?

    点赞 评论 复制链接分享
  • weixin_39627408 weixin_39627408 4月前

    -yuan-jun 我也是断断续续看完的,整体看完的话确实比较费神。

    点赞 评论 复制链接分享
  • weixin_39914752 weixin_39914752 4月前

    佩服,我看完了缓存那块,就放了很久没看了。没想到你已经整理完了。

    点赞 评论 复制链接分享