Vue响应式原理中篇:结合源码来理解响应式原理!

278 阅读7分钟

上一章我们通过从零构建了一个极简响应式系统后,对响应式系统中的Dep类、Watcher类和defineReactive方法都有了一定的了解。这一章我们将会结合源码来看看Vue到底是如何实现响应式系统的,以及还有哪些细节需要我们注意和优化。

这一章主要分为以下几个模块:

  • observe方法的作用与实现
  • Observer类的实现
  • defineReactive方法做了哪些额外处理?
  • 拦截数组变异方法 - Vue是如果处理数组的监听的?
  • $set/$del的实现
  • Dep类的实现
  • Watcher类的实现

observe方法

上一章我们已经提到了defineReactive方法是可以将对象的某一个key转换成响应式。如果我们想直接将一个对象或者数组里的值全部转换成响应式,就不得不每次都去循序遍历处理。

因此,为了解决这个问题,Vue封装了一个方法,专门用于监测对象或数据:如果是对象,就循环遍历处理所有的key;如果是数组,就遍历每一个元素,对每一个元素进行响应式处理。下面我们看一下这个observe方法的具体实现。响应式的核心代码是在src/core/observer目录下,我们先打开该目录下的index.js文件,找到observe方法:

 export function observe (value: any, asRootData: ?boolean): Observer | void {
   // 1. 如果不是对象或者是VNode则不检测
   if (!isObject(value) || value instanceof VNode) {
     return
   }
 ​
   let ob: Observer | void
   if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
     ob = value.__ob__
   } else if (
     shouldObserve &&
     !isServerRendering() &&
     (Array.isArray(value) || isPlainObject(value)) &&
     Object.isExtensible(value) &&
     !value._isVue
   ) {
     ob = new Observer(value)
   }
 ​
   if (asRootData && ob) {
     ob.vmCount++
   }
   return ob
 }

第一步,先判断要监测的value是否是对象,如果不是则不需要监测。如果valueVNode,那么也不需要监测,因为虽然VNode中会使用数据,但是监测这种依赖关系没有意义。

 // 1. 如果不是对象或者是VNode则不检测
 if (!isObject(value) || value instanceof VNode) {
    return
  }

第二步,判断value是否具有__ob__属性,如果存在且为Observer类的实例,说明该value已经被监测了,返回相应的监测对象即可。否则,说明value尚未被监测,继续进行判断,这里一共有5个判断条件

 // shouldObserve 用于控制是否数据需要监测,因为有的时候是不需要监测的
 shouldObserve &&
 // 服务端渲染也不需要监测
 !isServerRendering() &&
 // 只有数组或对象才会被监测
 (Array.isArray(value) || isPlainObject(value)) &&
 // 被 Object.seal 或 Object.freeze等处理过的对象不可被监测
 Object.isExtensible(value) &&
 // 如果是 Vue 实例则不会被监测
 !value._isVue

如果所有条件满足,那么就会实例化一个Observer类。可以看出,observe函数主要是做了一些判断,实际的响应式处理都在Observer类当中,后续我们会继续分析Observer类。


最后,如果是根数据的话,那么ob.vmCount会加1,这个有什么用呢?实际上从这里我们可以得出,如果是根数据被监测的话,那么ob.vmCount是大于0的,而Vue.set方法里进行监测时,会判断ob.vmCount为true时不会进行监测。因此Vue.set方法是无法对根数据里的key处理成响应式。

 if (asRootData && ob) {
    ob.vmCount++
 }

Observer

Observer类的定义在observe方法的上方,部分代码如下:

 export class Observer {
   value: any;
   dep: Dep;
   vmCount: number; // number of vms that have this object as root $data
 ​
   constructor (value: any) {
     this.value = value
     this.dep = new Dep()
     // 当为根数据时,vmCount > 0,$set 方法不进行处理
     this.vmCount = 0
     // 在 value 上添加属性 __ob__ 
     def(value, '__ob__', this)
     // 1. 如果是数组,由于 Object.defineProperty 无法检验数据的变化,因此需要额外处理
     if (Array.isArray(value)) {
       if (hasProto) {
         protoAugment(value, arrayMethods)
       } else {
         copyAugment(value, arrayMethods, arrayKeys)
       }
       this.observeArray(value)
     } else {
     // 2. 如果是对象,遍历对每个 key 进行响应式处理
       this.walk(value)
     }
   }
 }

Observer实例化时主要做了这几项工作: 首先是记录下要监测的值,同时生成一个dep实例。注意,这里的dep不同于上一章我们提到的闭包里的dep,闭包里的dep是属于某一个字段的,而这里的dep是针对value这个对象的。是在这个对象发生改变时,触发这个dep相关的Watcher更新。

接下来,value上被添加了__ob__属性,用于保存当前实例,前面observe就是通过__ob__访问已经实例化好的Observer对象,达到不重复监测的目的。

最后,如果value是数组,则会调用observeArray方法,我们看一下该方法是怎样的:

 observeArray (items: Array<any>) {
   for (let i = 0, l = items.length; i < l; i++) {
     observe(items[i])
   }
 }

这里不难发现,observeArray就是遍历value里的每一个元素,然后进行响应式处理。

当代码走到else分支时,代表value是一个对象,此时会执行walk方法:

   walk (obj: Object) {
     const keys = Object.keys(obj)
     for (let i = 0; i < keys.length; i++) {
       defineReactive(obj, keys[i])
     }
   }

walk方法比较简单,就是通过defineReactive方法对对象里的每个key做响应式处理。下面我们看一下defineReactive的实现。

defineReactive方法

defineReactive方法代码如下所示:

 export function defineReactive (
   obj: Object,
   key: string,
   val: any,
   customSetter?: ?Function,
   shallow?: boolean
 ) {
   const dep = new Dep()
 ​
   const property = Object.getOwnPropertyDescriptor(obj, key)
   if (property && property.configurable === false) {
     return
   }
 ​
   const getter = property && property.get
   const setter = property && property.set
   if ((!getter || setter) && arguments.length === 2) {
     val = obj[key]
   }
   let childOb = !shallow && observe(val)
   
   Object.defineProperty(obj, key, {
     enumerable: true,
     configurable: true,
     get: function reactiveGetter () {
       const value = getter ? getter.call(obj) : val
       if (Dep.target) {
         dep.depend()
         if (childOb) {
           childOb.dep.depend()
           if (Array.isArray(value)) {
             dependArray(value)
           }
         }
       }
       return value
     },
     set: function reactiveSetter (newVal) {
       const value = getter ? getter.call(obj) : val
       /* eslint-disable no-self-compare */
       if (newVal === value || (newVal !== newVal && value !== value)) {
         return
       }
       /* eslint-enable no-self-compare */
       if (process.env.NODE_ENV !== 'production' && customSetter) {
         customSetter()
       }
       // #7981: for accessor properties without setter
       if (getter && !setter) return
       if (setter) {
         setter.call(obj, newVal)
       } else {
         val = newVal
       }
       childOb = !shallow && observe(newVal)
       dep.notify()
     }
   })
 }

可以看出,defineReactive方法和我们上一章实现的思路大致是一致的:在get的时候,使用dep.depend()来建立起DepWatcher之间的依赖关系,在set的时候通过dep.notify()来通知相应Watcher进行更新。

所以这里我们主要讨论一下不一样的地方。

首先,defineReactive在响应式处理前做了一层判断:

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

如果property.configurablefalse的话,那么是defineProperty方法是无效的,因此不做任何处理。

接着,通过property获取了这个字段原有的getset,并且缓存起来:

 const getter = property && property.get
 const setter = property && property.set
 if ((!getter || setter) && arguments.length === 2) {
   val = obj[key]
 }
 let childOb = !shallow && observe(val)

!shallow && observe(val)这段代码则表示当val值是对象时,需要进行递归监测,这样才能保证对象里任何的属性都是响应式的。

但是上面的这行代码(!getter || setter) && arguments.length === 2就比较难以理解了,这牵涉到Vue中的两个issue:#7302#7828。我也纠结了比较长的时间,下面讲一下我的理解。

Vue原本处理对象时的walk方法是这样定义的:

  walk (obj: Object) {
     const keys = Object.keys(obj)
     for (let i = 0; i < keys.length; i++) {
       defineReactive(obj, keys[i], obj[keys[i]])
     }
  }

defineReactive是传入三个参数的,也就是将对应的值也传进入了。这样做会有什么问题呢?我们知道,当获取obj[keys[i]]这个值的时候,实际上会调用keys[i]这个字段的get方法,如果用户自己定义了这个get方法,那么获取值时是无法预知用户会如何定义的。这里参考《Vue技术内幕》中的一句话。

之所以在深度观测之前不取值是因为属性原本的 getter 由用户定义,用户可能在 getter 中做任何意想不到的事情,这么做是出于避免引发不可预见行为的考虑。

所以,我们在深度监测之前,如果用户自己设置了get,那么我们就跳过监测,可以参考#7302。所以代码改成只传两个参数:

// walk 方法
walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i], obj[keys[i]])
    }
  }

// defineReactive 方法
const getter = property && property.get
const setter = property && property.set
if ((!getter && arguments.length === 2) {
  val = obj[key]
}
let childOb = !shallow && observe(val)

这样,如果传入的只有两个参数,也就是没有传value值时,如果用户设置了get,那么就不会执行val = obj[key]这行代码,因此此时val的值为undefined,那么observe(val)就不会进行监测了。

但是这样又会存在怎样的问题呢?如果对于某个字段没有get,我们会对这个字段的值(这里代称叫做value)进行深度监测。监测完后,我们使用了Object.defineProperty对这个字段添加了getset方法,此时get又是存在的。如果改变value里的值,由于该字段的get是存在的,所以在递归监测时会跳过对value的深度监测。这就导致了前后不一致的情况,详情参考#7828

为了解决这个问题,我们将判断方式改为,增加了setter判断:

 if ((!getter || setter) && arguments.length === 2) {
   val = obj[key]
 }

这样,当gettersetter同时存在时,值一样会被深度监测。至此,这几行代码就解析完了,如果这里的解析存在偏差,恳请各位不吝赐教~

好了,我们看下后面的代码,在get方法里:

 if (childOb) {
   childOb.dep.depend()
   if (Array.isArray(value)) {
     dependArray(value)
   }
 }

如果childOb存在,说明value是对象或数组,且已经被监测,这个时候需要记录这个value与当前Watcher之间的关系。另外,如果值是数组的话,会执行dependArray方法:

 function dependArray (value: Array<any>) {
   for (let e, i = 0, l = value.length; i < l; i++) {
     e = value[i]
     e && e.__ob__ && e.__ob__.dep.depend()
     if (Array.isArray(e)) {
       dependArray(e)
     }
   }
 }

这里主要分为value数组和对象两种情况:

  • 如果value是数组,那么会遍历数组,遍历的结果如果是对象或数组,那么e.__ob__存在,进行依赖搜集。如果是数组,重复该步骤。
  • 如果value是数组,遍历键值对,如果遍历的值为数组,重复上述步骤,如果是对象,重复本步骤

所以,本质上这段代码是递归处理了对象和数组的依赖搜集过程,这里需要慢慢理解一下递归的过程。

拦截数组变异方法

如果监测的value为数组时,这里会出现一种问题:如果数组新增了某个元素,那么实际上defineProperty是不会检测到这种数组的增删变化的,因此新增的元素也不能自动进行响应式处理了。

那么Vue又是如何处理这种情况的呢?让我们回到Observer类中,当value是数组时,做了以下处理:

 if (Array.isArray(value)) {
   if (hasProto) {
     protoAugment(value, arrayMethods)
   } else {
     copyAugment(value, arrayMethods, arrayKeys)
   }
   this.observeArray(value)
 }
 ​
 ​
 function protoAugment (target, src: Object) {
   target.__proto__ = src
 }

这里主要看一下protoAugment方法,它将value__proto__指向了arrayMethods,我们看看arrayMethods是什么,打开./array.js文件:

 const arrayProto = Array.prototype
 // 继承于Array
 export const arrayMethods = Object.create(arrayProto)
 ​
 const methodsToPatch = [
   'push',
   'pop',
   'shift',
   'unshift',
   'splice',
   'sort',
   'reverse'
 ]
 ​
 /**
  * Intercept mutating methods and emit events
  */
 methodsToPatch.forEach(function (method) {
   // cache original method
   // 缓存数组的原始方法
   const original = arrayProto[method]
   def(arrayMethods, method, function mutator (...args) {
     // 调用数组的原始方法
     const result = original.apply(this, args)
     const ob = this.__ob__
     let inserted
     switch (method) {
       case 'push':
       case 'unshift':
         inserted = args
         break
       case 'splice':
         inserted = args.slice(2)
         break
     }
     // 如果是增加了新的元素,那么需要将新增元素也进行响应式处理
     if (inserted) ob.observeArray(inserted)
     // notify change
     // 数组改变了,通知相应 watcher 更新
     ob.dep.notify()
     return result
   })
 })

首先,arrayMethods继承于Array,因此拥有数组相关的属性方法。然后,遍历数组中所有与增删操作相关的方法名,进行响应式处理,这样每次数组的增删方法时,都会触发这里的mutator函数。mutator函数主要做了以下三点工作:

  • 执行数组原有的方法,保持原有输出不变。

  • 如果执行的是增加相关的方法,如push,unshift,splice,记录下增加了哪些元素,然后将这些元素也进行响应式处理。

  • 因为此时能够”感受“到数组变化了,所以会通知数组相关的watcher进行更新。

$set/$del的实现

现在还存在一个问题:如果我们为对象添加了一个属性,我们却不知道这个对象发生了变化,从而也就不能对这个属性自动进行响应式处理了。同样,如果我们对数组用索引来增加新值的时候,而不触发数组变异方法,也就无法感知变化了。

那么Vue是如何处理这一问题的呢?这里就要提到$set方法的实现了,它的目的就是为了解决设置新的属性不具备响应式的问题。

 export function set (target: Array<any> | Object, key: any, val: any): any {
   if (process.env.NODE_ENV !== 'production' &&
     (isUndef(target) || isPrimitive(target))
   ) {
     warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
   }
   // 1. 如果是数组,key 是 index,且是有效索引,那么将对应的 value 进行替换即可
   if (Array.isArray(target) && isValidArrayIndex(key)) {
     target.length = Math.max(target.length, key)
     target.splice(key, 1, val)
     return val
   }
   // 2. 如果对象自身就存在这个键,直接赋值即可
   if (key in target && !(key in Object.prototype)) {
     target[key] = val
     return val
   }
   const ob = (target: any).__ob__
   // 3. 如果是 Vue的实例,或者是 根数据,那么不应该被设置。
   if (target._isVue || (ob && ob.vmCount)) {
     process.env.NODE_ENV !== 'production' && warn(
       'Avoid adding reactive properties to a Vue instance or its root $data ' +
       'at runtime - declare it upfront in the data option.'
     )
     return val
   }
   // 4. 如果没有 ob,说明对象本来就不是响应式的,这里也没必要做响应式处理
   if (!ob) {
     target[key] = val
     return val
   }
   // 5. 如果对象本来就是响应式的,那么添加的 key 也要做响应式处理
   defineReactive(ob.value, key, val)
   
   // 6. 对象变化了,通知相关 watcher 更新
   ob.dep.notify()
   return val
 }

如代码中注释所示,前4个判断都只将键值进行普通设置处理。到了第5步,也就是数组索引超出边界或者对象设置了新的属性时,才会对新增属性进行响应式处理。这里看一下第6步,为什么需要通知Watcher更新呢?这里举一个例子:

 // dom
 <div> {{ user.name }}</div>
 ​
 ​
 // index.vue
 export default {
   data() {
     return {
       user: {}
     }
   },
   mounted() {
     this.$set(this.user, 'name', 'qgh')
   }
 }

我们分析一下,因为data里的user没有name属性,所以name属性是没有做过响应式处理的,那么改变this.user.name的时候也不会触发渲染watcher更新,即视图不会发生任何变化。这里需要注意的是,获取user.name的前提是需要获取user这个对象,而上面我们已经提到过的childObj在这里就起作用了,它会在获取user的时候,执行childObj.dep.depend(),建立与渲染watcher 的关系。最后user形式如下:

 user: {
   // __ob__是Observer,里面的dep记录着相应的watcher
   __ob__: Observer
 }

当我们使用$set的时候,触发的Watcher更新,实际上就是这里的渲染Watcher更新,所以user.name也会随之更新。又因为$setname设置为响应式了,所以后续更改user.name视图也会更新。这就是$set妙处所在了。另外$del的实现也相差不多,最终也会通知watcher更新,这里不做过多介绍了,有兴趣的可以自己看源码了解一下。

Dep

Dep类是在./dep.js文件下单独定义的:

 export default class Dep {
   static target: ?Watcher;
   id: number;
   subs: Array<Watcher>;
 ​
   constructor () {
     this.id = uid++
     this.subs = []
   }
 ​
   addSub (sub: Watcher) {
     this.subs.push(sub)
   }
 ​
   removeSub (sub: Watcher) {
     remove(this.subs, sub)
   }
 ​
   depend () {
     if (Dep.target) {
       Dep.target.addDep(this)
     }
   }
 ​
   notify () {
     const subs = this.subs.slice()
     for (let i = 0, l = subs.length; i < l; i++) {
       subs[i].update()
     }
   }
 }

看过《响应式原理上篇》的同学应该知道,上篇的实现与这里几乎相差无几,所以还有不了解Dep实现细节的同学,可以阅读一下本系列文章的《响应式原理上篇》。这里主要介绍一下另外一个细节:

 Dep.target = null
 const targetStack = []
 ​
 export function pushTarget (target: ?Watcher) {
   targetStack.push(target)
   Dep.target = target
 }
 ​
 export function popTarget () {
   targetStack.pop()
   Dep.target = targetStack[targetStack.length - 1]
 }

我们知道Dep.target代表的是当前的Watcher,那么这里为什么要用的形式来处理target呢?

试想一个场景:当前Dep.target存在时,我们想执行某段代码,但并不想要进行依赖搜集,此时用栈的优点就体现出来了。我们可以往栈里推入一个null,即targetStack[watcher, null]那么在执行后续代码时,取得的当前target也就是最后一个targetnull,那么就不会进行依赖搜集了。当这段代码执行完成后,我们把null弹出去,即targetStack[watcher],这时target就又会恢复原来的watcher,后续代码就可以正常进行依赖搜集了。

Watcher

Watcher类定义在./watcher.js文件,相较于Dep类,Watcher类则显得复杂许多。我们先看看Watcherconstructor:

 // 渲染 watcher
 if (isRenderWatcher) {
   vm._watcher = this
 }
 vm._watchers.push(this)

首先如果是渲染Watcher,则会单独记录到vm实例上,因此可以通过调用vm._watcher强制重新渲染界面,这也是$forceUpdate的核心实现原理。而所有的Watcher都被记录到vm._watchers上,方便后续移除相应的Watcher

接下来是options的一些处理:

     // watcher 触发后的回调函数
     this.cb = cb
     this.id = ++uid // uid for batching
     // watcher 是否还能使用
     this.active = true
     // 是否是惰性计算
     this.dirty = this.lazy // for lazy watchers
     // 上一次计算搜集的依赖
     this.deps = []
     // 当前计算搜集的依赖
     this.newDeps = []
     // 上一次计算搜集的依赖id
     this.depIds = new Set()
     // 当前计算搜集的依赖id
     this.newDepIds = new Set()
     this.expression = process.env.NODE_ENV !== 'production'
       ? expOrFn.toString()
       : ''
     // parse expression for getter
     if (typeof expOrFn === 'function') {
       // 如果是函数,直接赋值即可
       this.getter = expOrFn
     } else {
       // 解析 'a.b.c' 的形式,取得对应函数
       this.getter = parsePath(expOrFn)
       if (!this.getter) {
         this.getter = noop
         process.env.NODE_ENV !== 'production' && warn(
           `Failed watching path: "${expOrFn}" ` +
           'Watcher only accepts simple dot-delimited paths. ' +
           'For full control, use a function instead.',
           vm
         )
       }
     }

这里的dirtycomputed计算属性实现的关键,我们将会在下一章进行讲解,这里先放一放。

另外,depsnewDeps分别记录了上一次计算和当前计算搜集的依赖.这是因为每次计算的时候,搜集的依赖可能不一样,所以每次计算的时候,都会将新的依赖重新记录一遍:

 addDep (dep: Dep) {
     const id = dep.id
     // 如果新的 dep 中不包含该 dep,则添加该 dep
     if (!this.newDepIds.has(id)) {
       this.newDepIds.add(id)
       this.newDeps.push(dep)
       // 如果旧的 dep 中不包含该 dep,则在dep 里添加该 watcher
       if (!this.depIds.has(id)) {
         dep.addSub(this)
       }
     }
   }

而在每次计算完毕后,又会将新的依赖赋值给旧的依赖,将新的依赖置空:

   // 进行依赖搜集
   get () {
     // 标明当前正在执行的 watcher
     pushTarget(this)
     let value
     const vm = this.vm
     try {
       // 进行依赖搜集
       value = this.getter.call(vm, vm)
     } catch (e) { 
       if (this.user) {
         handleError(e, vm, `getter for watcher "${this.expression}"`)
       } else {
         throw e
       }
     } finally {
       // 如果 deep 为 true 的话,会循环遍历获取对象里的每一个值,
       // 从而触发每一个相关的 watcher 进行 update
       if (this.deep) {
         traverse(value)
       }
       // 当前正在执行的 watcher 结束,不需要标明了
       popTarget()
       // 搜集完依赖后,清除依赖
       this.cleanupDeps()
     }
     return value
   }
 ​
   // 计算完成后,将新 deps 赋值给旧 deps, 移除新 deps
   cleanupDeps () {
     let i = this.deps.length
     // 清除在新 deps 中不存在的旧 dep
     while (i--) {
       const dep = this.deps[i]
       if (!this.newDepIds.has(dep.id)) {
         dep.removeSub(this)
       }
     }
     // 将新 deps 赋值给旧 deps, 移除新 deps
     let tmp = this.depIds
     this.depIds = this.newDepIds
     this.newDepIds = tmp
     this.newDepIds.clear()
     tmp = this.deps
     this.deps = this.newDeps
     this.newDeps = tmp
     this.newDeps.length = 0
   }

注意,这里有个细节就是deep代表深度搜集依赖。例如 { user: { name: { first: 'a' } } }这个对象,如果我们获取user时,这时只会将user作为依赖项进行搜集。但是如果deeptrue时,会调用traverse方法,该方法会遍历对象,将对象内部所有的键值(如usernamenamefirst)获取一遍,这个获取的过程也就是搜集依赖的过程,所以最终会将对象内所有字段全做为依赖搜集。

Watcher类剩下没介绍的主要就是update方法了:

   update () {
     if (this.lazy) {
       this.dirty = true
     } else if (this.sync) {
       this.run()
     } else {
       queueWatcher(this)
     }
   }

可以看出,这里有三种情况,我们将会在下一章结合数据初始化过程分别讲讲这三种情况,并学习computedwatch两个方法的具体实现。

总结

这一章我们主要通过源码的角度,分别了解了observeObserver类defineReactive$set/$delDep类,Watcher类的实现,以及介绍了Vue做的一些特殊处理,比如变异数组的拦截等。建议最好是能够亲自动手调试一下源码,才能更好的理解这几者之间的关系。

下一章我们将会回到Vue的实例化过程,看看再实例化过程中,到底是如何处理数据响应式的,同时我们也会彻底地理解computedwatch两个方法的具体实现过程。

最后,如果你觉得这篇文章对你有所帮助,可以点赞/关注/收藏三连哦!码字不易,你的支持和喜欢是对我最大的鼓励~~

扶我起来我还能学.jpeg

如果你有任何疑问都可以在评论区留言,我都会一一查看。如果你也是前端的爱好者,可以私信我进群和其他人一起交流,一起在前端的路上学习提升自己!