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


上一章我们通过从零构建了一个极简响应式系统后,对响应式系统中的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) {
  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) {
  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 | 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
{{ user.name }}
// 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;

  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两个方法的具体实现过程。

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

响应式原理中篇.png

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

你可能感兴趣的:(Vue响应式原理中篇:结合源码来理解响应式原理!)