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

109 阅读16分钟

概况

相比起 Vue3 更清晰的项目结构和实现,Vue2 中各个部分的实现存在较多的耦合,也导致其逻辑梳理起来较为复杂。其中「响应式」的部分是最为复杂也是最重要的一环,实际项目中大部分的 Issue 也与其相关,如 Vue2 官网中所述的那样:

“Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题”。

在系统地梳理「响应式」工作原理的过程中,也参考了不少现有的文章,大部分都是围绕“依赖收集”、“派发更新”或者“Watcher”,“Dep”这些响应式相关的概念逻辑展开讲述,当然这些概念和逻辑是必不可少的要展开讲述的内容,但是如果单纯围绕这些内容展开来编写一篇文章,对于理解「响应式」在整个 Vue 中的工作过程可能会感到困惑。因此,本文会换一个角度,从 Vue 使用的过程展开说明「响应式」的工作原理,即从「实例化」、「渲染」、「数据更新」三条线讲述「响应式」的工作过程,分别对应的是如何定义响应式数据、如何触发响应式逻辑执行,以及如何触发响应式数据更新。

在介绍了「响应式」的工作原理之后,也会基于工作原理解决一些常见的数据更新相关的问题。

从实例化到渲染

import Vue from 'vue'
import App from './App.vue'
 
Vue.config.productionTip = false
 
new Vue({
  render: h => h(App),
}).$mount('#app')

以上是一段大家应该都很熟悉的代码,即 Vue Cli 创建的示例项目实例化 Vue 的代码,虽然是实例化代码,但实际上这里做了两件事:

new Vue,即创建了一个 Vue 实例。

调用实例的 $mount 方法,即挂载 Vue 的渲染结果到 #app 这个节点上。

这里是 Vue 中两条重要的工作线,接下来看看在 Vue 内部这两个操作具体做了什么,当然会着重于「响应式」相关部分。

实例化过程
// 精简了非 production 的逻辑
function Vue (options) {
  this._init(options)
}
 
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
 
export default Vue

首先是定义了 Vue 的构造函数,构造函数内会调用 _init 方法,定义构造函数后会调用 initMixin,stateMixin 等方法,其中 initMixin 内会定义构造函数内的 _init 方法,因此先关注一下 initMixin。

// 精简了非 production 逻辑
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    vm._uid = uid++
    vm._isVue = true
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')
 
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

initMixin 方法内部会给 Vue 的原型扩展大量方法,其中初始的就是 _init 方法,包括生命周期、渲染函数(把模板构造成 render 函数,render 函数负责输出虚拟节点)、data/props、调用 created hook 等,对数据进行响应式封装的逻辑也是从这里开始的。

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

initState 是负责处理 data 的核心,props、methods、computed、watch 这些常用的 Vue 的 options,也是在这里进行处理,主要的处理内容包括做一些检查,例如有名字冲突,比如比较常见的 warning:"Method xxx has already been defined as a prop.",就是在这个阶段做的检查,另外最重要的就是对数据进行响应式封装,接下来会以最常用也是最直观的 data 作为例子。

// 精简了非 production 逻辑
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  const keys = Object.keys(data)
  const props = vm.$options.props
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (props && hasOwn(props, key)) {
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  observe(data, true /* asRootData */)
}

上图是 initData 的主体逻辑,主要的作用是对 data 的内容进行格式检查,比如必须是一个 isPlainObject(至于这是什么后面会详细说明),另外就是如上面提到的,进行名字校检防止冲突,例如如果有 data 的 key 跟 props 冲突了,就会报那个大家应该都很熟悉的 warning:"The data property xxx is already declared as a prop. Use prop default value instead.",最后就是真正的响应式逻辑 observe 方法。

到这里,实例化的主线已经梳理出来了,可以看到 new Vue 之后 Vue 的处理步骤,以及 data 这类 options 是如何走到数据响应式处理的。

调用 $mount,挂载实例

在 Vue 的示例中,实例化之后会调用 $mount 把渲染出来的 DOM 挂载到页面上,$mount 实际上是触发渲染的入口。

// 精简了非 production 逻辑
export function mountComponent (vm: Component, el: ?Element, hydrating?: boolean): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')
 
  let updateComponent
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false
 
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

$mount 首先会调用 mountComponent 方法,这是渲染的核心主线逻辑,按顺序分别做了以下的事情:

判断是否有传入 render 方法,render 方法是把 Vue 模板转换成 VNode 的方法,在 Vue 内部,如果 new Vue 时有 render 会优先使用,上面 new Vue 的示例就传入了 render 方法,也是大家比较熟悉的把 App.vue 传入的逻辑。如果没有传入 render 则会把 render 赋值成创建一个空 VNode 节点的方法。

调用 beforeMount 的钩子。

定义好 updateComponent 方法,该方法负责执行实例的渲染和更新,内部会调用 Vue 实例的 _update,而 _update 则传入了 render 的调用结果,即计算好的 VNode。_update 方法的内最重要的就是调用了 patch,即把 VNode 转换成真实 DOM 的方法,转换过程跟「响应式」关联不大,因此这里不针对 patch 展开太多。

创建一个 Watcher 实例,传入当前 Vue 的实例 vm,updateComponent,还有一些 options,例如 before 参数。

调用 mounted 钩子。

在梳理了 $mount 的过程后,可以梳理出一个清晰的 Vue 实例渲染主线,调用 new Vue 实例化 Vue,然后把 data、props 等 options 进行校检和「响应式」封装,接着调用 $mount 开始进行渲染,首先创建一个 Watcher 对象跟 Vue 实例关联起来,并通过传入 updateComponent 方法维护实例的渲染和更新,render 作为 updateComponent,负责把模板转换成虚拟节点 VNode,后面的 patch 方法则把 VNode 转换成真实 DOM,最后挂载到页面上。而在这个过程中,实例化时定义好响应式数据,渲染时调用响应式数据的更新逻辑,最终实现整个更新逻辑。

订阅者模式

在上面的整个更新逻辑中,核心的「响应式」逻辑,应用了订阅者模式这种设计模式,在说明 Vue 具体是如何基于订阅者模式实现「响应式」之前,先来介绍一下订阅者模式。

什么是订阅者模式?

“一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知”。这是对订阅者的简单描述,在 JavaScript 中,订阅者模式是最常用的模式之一,例如经常用到的 DOM 事件监听也是一种订阅者模式,比如:

document.body.addEventListener('click', () => {
    console.log('clicked1');
});
document.body.addEventListener('click', () => {
    console.log('clicked2');
});

body 作为观察目标,订阅了 click 事件,当 body 被点击时就会向订阅者发出通知,订阅者依次输出 clicked1 和 clicked2,完成了一个订阅 - 通知 - 响应的过程。

订阅者模式的基础实现

根据上面的例子可以总结出订阅者模式的基础特征:

一个观察目标对象通常会有观察者管理类,包括了添加、删除、通知观察者更新三个主要操作。

一个或多个观察者,接收观察目标的通知并作出处理。

图片

也就是说,观察目标类,观察者管理类,观察者是订阅者模式中的三个基本要素。基于以上特征,这里实现了一个简单的订阅者模式示例,其中观察者集合类 ObserverList 作为一个工具类用于管理观察者,观察者目标类 Subject 调用 ObserverList 进行实际的观察者(Observer)管理,以及在需要时发送更新通知给观察者,示例中的更新通知是更新随机数,观察者接受通知把最新的随机数输出。可以尝试打开控制台,点击示例中的按钮留意输出。

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

Vue 响应式实现

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

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

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

实例化 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 方法,这是其中核心的部分,它的基本逻辑是这样的:

判断如果有 __ob__ 则直接使用。

没有 __ob__ 会走一系列的判断,然后把数据传入到 new Observer 创建响应式数据。

首先要分析的就是这一系列的判断,这些实际上都是对需要做响应式封装的数据进行检查的判断,shouldObserve 是默认为 true 的全局静态变量,isServerRendering 和 Array.isArray 顾名思义判断是否为服务端渲染和判断是否为数组,value._isVue 是判断是否为最根的 Vue 实例,根实例只是一个壳,是不需要处理响应式的,因此比较特别的是 isPlainObject 和 Object.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 劫持 get 和 set 操作,这样数据读取和赋值时就会调用响应式的逻辑。由于基于 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 已经赋值了,接下来会执行以下操作:

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

如果数据中有子值也是对象,则对子值进行依赖收集。

也就是 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 的操作:

获取当前最新的数据值。

判断新值如果等于旧值则直接跳过下面的操作。newVal !== newVal && value !== value 是为了避免一些特殊情况,例如 newVal 是 NaN,由于 NaN === NaN 为 false,所以需要这样一个特殊的判断。
然后跳过没有原生 setter 但有原生 getter 的情况。

接着调用 setter 赋新值。

最后是调用 dep.notify(),根据上面 Dep 类的源码可以知道,这实际上就是遍历之前收集的 Watcher,然后逐个调用它们的 update 方法,Watcher 会去执行更新逻辑。

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

  • Dep 是观察目标,Watcher 是观察者,每个数据对应一个 Dep 实例 dep,get 数据时会触发 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,接下来看看 Watcher 的 constructor。

// 精简了非 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 的逻辑里,大部分都是定义变量,需要重点关注的主要是:

真正要处理的逻辑在 get() 方法里。

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() 的逻辑主要是分成两块:

  • 把 newDepIds 里面不存在的 dep 实例找出来,然后把当前的 Watcher 从这个 dep 实例中移除,也就是后续 dep 对应的数据更新不用再通知当前 Watcher。
  • 清空当前的 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>

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

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

初始化时, a 和 b 都为 1,在初始化的时候经常 observe 的处理,形成了两个 Dep 实例,dep(id=1,绑定 a)和 dep(id=2,绑定 b)渲染时 new Watcher 绑定了 App 这个 Vue 实例,然后 Dep.target 赋值成当前 Watcher,经常 Watcher 的 getter -> updateComponent -> render() 这样一个过程,触发了 a 和 b 的 get,从而进行依赖收集,把当前 Watcher 同时放入两个 dep 中。

然后把 a 改为2,触发了 a 的 set 从而通知 Watcher 更新,重新触发 updateComponent 走到 render(),这个时候假如没有 cleanupDeps(),则这次 render() 触发依赖收集完成后,只是更新了 a 的值为2,而后续如果 b 修改值时,仍会通知 Watcher 更新,造成一次浪费的订阅更新。对于 Vue 这样的基础框架来说,如果每次依赖收集都重新进行,抛弃内存缓存记录,又会导致性能很差,无法适配各种常见,因此最终 Vue 的做法就是通过 Watcher 和 Dep 同时互相记录,来实现渲染优化,即订阅者也可以通知订阅目标抛弃掉一些无用的通知对象,减少浪费。

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

图片

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

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

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

上面 Watcher 的 constructor 的代码里展示过,lazy 和 sync 这两个变量默认都是 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 调用 flushSchedulerQueue,nextTick 大家都比较熟悉,作用是把方法按周期调用,因此组件的实际渲染更新都不是即时的,而是每隔一个周期中集中处理,接下来看看 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)
}

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

按 id 从小到大排序 Watcher,Watchwe 的 id 是创建时自增的,渲染是从外层递归的,也就是父元素会排在队列的前面。为什么要这样排呢?实际上也是为了性能优化,把父元素放在队列的前面,就会优先处理父元素,因此如果父元素销毁了,就可以直接跳过后面子元素的渲染更新。

遍历队列调用每个 Watcher 的 run() 方法。

queue.length 是动态的,Vue 没有把队列长度缓存起来,是因为 queue 在调用过程中可能会增删 Watcher,例如上面的例子中,a 的改变可以导致 HelloWorld1 的 Watcher 加入到队列中,而 HellowWorld2 的 Watch 则不再需要被渲染,因此 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「响应式」逻辑流程图供参考。

图片