Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(下)

上一篇文章中,梳理了 Vue 实例化和渲染的基本逻辑,并且介绍了订阅者模式这种设计模式,Vue 的「响应式」实现本质上也是一个订阅者模式,但是由于 Vue 需要考虑更加复杂的情况,并且需要在其中作出大量优化操作,因此具体实现也会复杂很多。通过上面对订阅者模式的介绍,观察目标类观察者管理类观察者是订阅者模式中的三个基本要素,Vue 内部也会有对应的实现,下面通过更详细地说明 Vue「响应式」的实现,同时发掘在 Vue 中订阅者三要素分别是什么。

Vue 响应式实现

正正如上文开头所述,本文会从「实例化」、「渲染」、「数据更新」三条线讲述「响应式」的工作过程,首先可以总结出三条线的作用:

  1. 实例化 Vue —— 负责定义好响应式的相关逻辑。
  2. 渲染 —— 负责执行响应式的逻辑
  3. 数据更新 —— 负责响应式逻辑的二次执行

上面梳理了的是三条线的主线逻辑,下面开始聚焦到「响应式」的部分。

实例化 Vue —— 负责定义好响应式的逻辑

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)
}

回顾前面提到的 observer 方法,这是其中核心的部分,它的基本逻辑是这样的:

  1. 判断如果有 __ob__ 则直接使用。
  2. 没有 __ob__ 会走一系列的判断,然后把数据传入到 new Observer 创建响应式数据。

首先要分析的就是这一系列的判断,这些实际上都是对需要做响应式封装的数据进行检查的判断,shouldObserve 是默认为 true 的全局静态变量,isServerRenderingArray.isArray 顾名思义判断是否为服务端渲染和判断是否为数组,value._isVue 是判断是否为最根的 Vue 实例,根实例只是一个壳,是不需要处理响应式的,因此比较特别的是 isPlainObjectObject.isExtensible,这是两个含义不是很直观的判断。

isPlainObject

“Plain Object — 通过 {} 或者 new Object 创建的纯粹的对象”,这是对于 Plain Object 的定义。在 JavaScript 中,Function,Array 都继承于 Object,也拥有 Object 的特性,但为了避免产生额外的问题,框架在数据上通常都会使用 Plain Object。要区分 Plain Object 也很简单,很多框架里都有关于 Plain Object 的判断实践,而 Vue 则是使用原型判断,例如以下这段代码:

// Plain object
var plainObj1 = {};
var plainObj2 = { name : 'myName' };
var plainObj3 = new Object();
// Non Plain object
var Person = function(){};

console.log(plainObj1.__proto__); // {}
console.log(plainObj2.__proto__); // {}
console.log(plainObj3.__proto__); // {}
console.log(Person.__proto__); // [Function]

打印结果中,原型的值是不一样的,Vue 的 isPlainObject 具体实现如下:

var _toString = Object.prototype.toString;

function isPlainObject (obj) {
  return _toString.call(obj) === '[object Object]'
}

Object.isExtensible

“Object.isExtensible() 判断一个对象是否是可扩展的,即是否可以在它上面添加新的属性”,这是 Object.isExtensible() 的说明,看以下的例子:

// 新对象默认可扩展
var empty = {};
console.log(Object.isExtensible(empty)); // true

// 通过 Object.preventExtensions 使变得不可扩展
Object.preventExtensions(empty);
console.log(Object.isExtensible(empty)); // false

// 密封对象不可扩展
var sealed = Object.seal({});
console.log(Object.isExtensible(sealed)); // false

// 冻结对象也不可扩展
var frozen = Object.freeze({});
console.log(Object.isExtensible(frozen)); // false

// 尝试给不可扩展的对象添加属性
empty.a = 1;
console.log('modified empty: ', empty); // modified empty:  {}

一个直接创建的 Plain Object 默认是可扩展的,也可以通过一些原生方法把对象变为不可扩展,另外密封和冻结对象都是不可扩展的,不可扩展的元素添加属性不会报错,但是会添加无效。那为什么 Vue 要求响应式数据对象必须要可扩展呢?原因很简单,在上面介绍 observer 方法中,核心的步骤就是要给数据对象添加 __ob__ 属性,用于缓存响应式数据的封装结果。

定义响应式数据

回到实例化 Vue 的流程,在判断传入的数据对象如果没有 __ob__ 属性后,会调用 new Observer,这是响应式处理的真正入口类。

constructor (value: any) {
  this.value = value
  this.dep = new Dep()
  this.vmCount = 0
  def(value, '__ob__', this)
  if (Array.isArray(value)) {
    if (hasProto) {
      protoAugment(value, arrayMethods)
    } else {
      copyAugment(value, arrayMethods, arrayKeys)
    }
    this.observeArray(value)
  } else {
    this.walk(value)
  }
}

首先是把当前的 Observer 实例赋值给当前对象的 __ob__ 属性,然后判断如果是数组则遍历每个 item 调用 observer,由于之前调用 observer 时就进行了判断,传入的数据类型只能是数组或者对象,因此这里 else 就按对象处理,调用 walk 方法。

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

walk 主要的作用是为数据对象的每个 key 调用 defineReactive 方法,defineReactive 的主要逻辑是为传入数据的某个 key,基于 Object.defineProperty 劫持 getset 操作,这样数据读取和赋值时就会调用响应式的逻辑。由于基于 Object.defineProperty 实现了这个核心逻辑,因此 Vue 不支持 IE8 下运行。

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
}

首先看看 get 操作的劫持,首先是通过原生的 getter 获取数据的值,然后判断 Dep.target 是否存在,这里可能会有疑问,没有看到它的赋值时机,所以 Dep.target 究竟是什么呢?实际上现在不用关注它的赋值,因为正如前面强调的,当前这些实例化的操作,只是把「响应式」的数据先定义好,也就是还不用运行,到了渲染过程的时候,才会对 Dep.target 进行赋值。

// 精简了部分逻辑
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)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

假如 Dep.target 已经赋值了,接下来会执行以下操作:

  1. 调用 dep.depend() 进行依赖收集,在 Dep 类源码中可以发现,这个方法实际上是把当前 target,即当前渲染的 Watcher 加入到 dep 实例的一个数组中,保存下来。
  2. 如果数据中有子值也是对象,则对子值进行依赖收集。

也就是 get 调用后数据的 dep 会持有关联的 Watcher

// 精简非正式环境逻辑
set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }
  // #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()
}

然后看看 set 的操作:

  1. 获取当前最新的数据值。
  2. 判断新值如果等于旧值则直接跳过下面的操作。newVal !== newVal && value !== value 是为了避免一些特殊情况,例如 newValNaN,由于 NaN === NaNfalse,所以需要这样一个特殊的判断。
  3. 然后跳过没有原生 setter 但有原生 getter 的情况。
  4. 接着调用 setter 赋新值。
  5. 最后是调用 dep.notify(),根据上面 Dep 类的源码可以知道,这实际上就是遍历之前收集的 Watcher,然后逐个调用它们的 update 方法,Watcher 会去执行更新逻辑。

到这里,实例化中「响应式」相关逻辑已经完整分析清楚了,订阅者模式的相关要素也很清晰:

  • Dep观察目标Watcher观察者,每个数据对应一个 Dep 实例 depget 数据时会触发 dep 收集了数据相关的 Watcher,相当于观察目标收集了观察者。
  • Watcher 也记录了相关的 dep,方便后续更新时做优化。这是与普通订阅者模式最大的区别,后续会展开说明。
  • set 数据时会触发 dep 通知相关的 Watcher 更新,而具体的更新逻辑,等第三个小章节“数据更新”再详细说明。

如前面所说的,实例化中的响应式处理实际上是负责定义响应式的逻辑。接下来看看渲染的逻辑。

渲染 —— 负责执行响应式的逻辑

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

回顾调用 $mount,挂载实例的这块代码,重新聚焦几个点:

  • Watcher 绑定的是 Vue 的实例 vm,传入的第二个参数是 vm 的更新方法,里面会先调用 vm_render() 方法。
  • Watcher 的作用包括在需要时触发 _render(),即重新计算 vnode,然后 _update 调用 _patch,即重新渲染 DOM,从而实现整个 Vue 实例的更新。

因此对于这个流程,响应式相关的逻辑重点在 new Watcher,接下来看看 Watcherconstructor

// 精简了非 production 的逻辑
constructor (vm: Component, expOrFn: string | Function, cb: Function, options?: ?Object, isRenderWatcher?: boolean) {
  this.vm = vm
  if (isRenderWatcher) { vm._watcher = this }
  vm._watchers.push(this)
  if (options) {
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync
    this.before = options.before
  } else {
    this.deep = this.user = this.lazy = this.sync = false
  }
  this.cb = cb
  this.id = ++uid // uid for batching
  this.active = true
  this.dirty = this.lazy // for lazy watchers
  this.deps = []
  this.newDeps = []
  this.depIds = new Set()
  this.newDepIds = new Set()
  this.expression = process.env.NODE_ENV !== 'production' ? expOrFn.toString() : ''
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn
  } else {
    this.getter = parsePath(expOrFn)
    if (!this.getter) { this.getter = noop }
  }
  this.value = this.lazy ? undefined : this.get()
}

constructor 的逻辑里,大部分都是定义变量,需要重点关注的主要是:

  1. 真正要处理的逻辑在 get() 方法里。
  2. Watcher 实例的 getter 就是传入的 updateComponent 方法,getter 会被保存到 Watcher 实例变量上。

接下来分析一下 get() 方法的逻辑:

get () {
  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 {
    if (this.deep) { traverse(value) }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

get() 方法主要是做两件事,调用 getter 以及进行「收集依赖」,getter 本质上就是 updateComponent,即上面介绍过的渲染更新组件的逻辑,这里不再详述这点,重点关注「收集依赖」的过程。

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

上面是 pushTarget 的逻辑,它是 Dep 类下面的一个静态方法,本质上就是把当前的 Watcher 加入到一个栈中,并且赋值给 Dep.target,这里可以回应上面在劫持响应式数据 get 逻辑的一个疑问Dep.target 是在渲染过程中「收集依赖」时赋值的,因此真正执行响应式逻辑实际上是在渲染时才进行的。结合两个特性:

  • Vue 实例渲染是递归的,从子到父逐个完成,同时只有一个 Watcher 被渲染。
  • JS 是单线程的,Dep.target 在同一时刻只会被赋值成一个 Watcher

Vue 就是利用这两个特性,逐个执行 Watcher 的渲染逻辑,最终完成整个 Vue 应用的渲染,最后重点看看 this.cleanupDeps() 的逻辑。

为什么需要 cleanupDeps?

cleanupDeps () {
  let i = this.deps.length
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) { dep.removeSub(this) }
  }
  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
}

this.cleanupDeps() 的逻辑主要是分成两块:

  1. newDepIds 里面不存在的 dep 实例找出来,然后把当前的 Watcher 从这个 dep 实例中移除,也就是后续 dep 对应的数据更新不用再通知当前 Watcher
  2. 清空当前的 newDepIds,把 deps 赋值成 newDeps

这样看无法直观看出来为什么需要实现一个这样的逻辑,举一个具体的例子:

<template>
  <div id="app">
    <div>
      a:{{ a }}
      <button @click="chnageA">点击修改 a 的值</button>
      <HelloWorld1 v-if="a % 2 === 0" :data="a" />
      <HelloWorld2 v-else :data="b" />
    </div>
  </div>
</template>

在这个例子中,ab 两个 dataa 在模板中直接用到,而 b 仅在 HellowWorld2 中作为 props 传递,当 a 为奇数时 ab 改变都会触发 App 更新渲染。

可以试想一下这样一个过程:

  1. 初始化时, ab 都为 1,在初始化的时候经常 observe 的处理,形成了两个 Dep 实例,dep(id=1,绑定 a)和 dep(id=2,绑定 b
  2. 渲染时 new Watcher 绑定了 App 这个 Vue 实例,然后 Dep.target 赋值成当前 Watcher,经常 Watchergetter -> updateComponent -> render() 这样一个过程,触发了 abget,从而进行依赖收集,把当前 Watcher 同时放入两个 dep 中。
  3. 然后把 a 改为2,触发了 aset 从而通知 Watcher 更新,重新触发 updateComponent 走到 render(),这个时候假如没有 cleanupDeps(),则这次 render() 触发依赖收集完成后,只是更新了 a 的值为2,而后续如果 b 修改值时,仍会通知 Watcher 更新,造成一次浪费的订阅更新。对于 Vue 这样的基础框架来说,如果每次依赖收集都重新进行,抛弃内存缓存记录,又会导致性能很差,无法适配各种常见,因此最终 Vue 的做法就是通过 WatcherDep 同时互相记录,来实现渲染优化,即订阅者也可以通知订阅目标抛弃掉一些无用的通知对象,减少浪费。

为了更好地说明这个过程,这里特意做了一张流程图完整表述整个过程:

cleanupDeps 的作用

数据更新 —— 负责响应式逻辑的二次执行

相对来说,数据 set 后的更新逻辑比较好理解,上面大概提到了,但其中的内部逻辑却是三条主线里最复杂的。上面稍微提到过,当数据 set 后,会触发 dep.notify(),即遍历之前收集的 Watcher,然后逐个调用它们的 update 方法,因此首先来看看 Watcherupdate 方法:

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

上面 Watcherconstructor 的代码里展示过,lazysync 这两个变量默认都是 false,因此可以先不用理会,也就是说 update 的主逻辑是把当前的 Watcher 作为参数调用 queueWatcher,顾名思义是把 Watcher 放入到一个队列中,接下来看看 queueWatcher 的具体处理。

// 精简了非 production 的逻辑
let waiting = false
let flushing = false

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

根据这里的逻辑,默认的情况都是直接把传入的 Watcher 加入到一个队列中,然后使用 nextTick 调用 flushSchedulerQueuenextTick 大家都比较熟悉,作用是把方法按周期调用,因此组件的实际渲染更新都不是即时的,而是每隔一个周期中集中处理,接下来看看 flushSchedulerQueue 的逻辑。

// 已精简非 production 逻辑
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id
  queue.sort((a, b) => a.id - b.id)
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
  }

  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)
}

这里主要处理了三件事情:

  1. 按 id 从小到大排序 WatcherWatchweid 是创建时自增的,渲染是从外层递归的,也就是父元素会排在队列的前面。为什么要这样排呢?实际上也是为了性能优化,把父元素放在队列的前面,就会优先处理父元素,因此如果父元素销毁了,就可以直接跳过后面子元素的渲染更新。
  2. 遍历队列调用每个 Watcherrun() 方法。
  3. queue.length 是动态的,Vue 没有把队列长度缓存起来,是因为 queue 在调用过程中可能会增删 Watcher,例如上面的例子中,a 的改变可以导致 HelloWorld1Watcher 加入到队列中,而 HellowWorld2Watch 则不再需要被渲染,因此 queue 的长度无法缓存。
run () {
  if (this.active) {
    const value = this.get()
    if (
      value !== this.value ||
      isObject(value) ||
      this.deep
    ) {
      const oldValue = this.value
      this.value = value
      if (this.user) {
        const info = `callback for watcher "${this.expression}"`
        invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
      } else {
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

最后看看 run() 方法,首先会调用 get() 方法,也就是会重新调用 updateComponent 和进行依赖收集,这里再关注一下 get() 后半部分的逻辑,之前文章内提到的 Watcher 其实都是绑定 Vue 实例的渲染 Watcher,Vue 中还有用户 Watcher,也就是平常监听 data 或者 props 值变化用的 Watcher,对于这些 Watcher,会有有效的返回值 value,因此 run() 里面还会对比 value 是否有变化,如果有就重新赋值,并且会执行回调。

至此,Vue「响应式」的整个逻辑以及在各个环节中分别所做的处理已经讲述完成,作为 Vue 的核心部分,「响应式」的整个逻辑较为庞大,也涉及实例化、渲染、数据更新三个环节,同时内部还有很多的性能考虑,因此单纯去看「响应式」的核心代码也不大好理解,后面还会有一篇短文来解答一些数据更新的常见问题。最后制作了一张完整的 Vue「响应式」逻辑流程图供参考。

Vue 如何实现响应式

本文由 Kayo Lee 发表,本文链接:https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-two.html