虽然这个东西已经被网上讲烂了,在新版中有些必要的细节还是没说清,不搞懂不爽。


简化的逻辑图:

vue

源码将按照 生命周期 的顺序解读。

生命周期

建议先看懂主线,再阅读细节。

先看下响应式(Reactive)相关函数调用顺序:

new Vue
-> mountComponent
-> updateComponent = () => render()

// beforeCreate
initProps / initDatas
-> proxy
-> defineReactive / observe // initProps or initDatas
-> new Dep
-> defineProperty: getter / setter
initComputed

// created
$mount
-> renderFunction = compileToFunctions(template)
-> mountComponent(renderFunction)
-> new Wather(/* callback: updateComponent */)

// beforeUpdate
-> this.get 
-> pushTarget(Wather) // Dep.target = Wather
-> updateComponent
-> init render
-> touch getter
-> dep.depend()
-> Dep.target.addDep(dep)
-> Wather.dep.addSub(Wather)
-> traverse
-> popTarget

// mounted

touch setter
-> dep.notify()
-> Wather.update
-> queueWatcher
-> queue.push(watcher)
-> nextTick(flushSchedulerQueue)
-> Watcher.run
-> getAndInvoke
-> updateComponent
-> rerender

init render 和 rerender 都会渲染成 vnode,然后调用 patch。区别是 rerender 时因为节点已经存在,会进行 diff 算法操作。

diff 算法简单来说就是对新旧 vnode 树的逐层比较。通过节点的类型和 key 判断是不是相同节点,即如果两个 vnode 的 key 不相等,则是不同的;否则继续判断对于同步组件,则判断 isComment、data、input 类型等是否相同。

  • 如果新旧节点相同,继续递归比较。
  • 如果新旧节点不同,则创建新节点删除旧节点。

这就把算法优化到了 O(n) 的时间复杂度。具体的算法不是本文范畴。

initProps / initDatas

这两个函数把 props 通过 defineReactive 把属性定义为响应式,然后通过 defineProperty,把 el._data.xxx 的存取代理到 el.xxx。

data 和 props 大致上相同,只是 data 通过 observe(data, true) 来把对象深度定义为响应式。

为什么呢?二者数据来源不同,其中 data 数据定义的组件自身,我们称其为本地数据,而 props 数据来自于外界。如果再对 props observe 就会有重复。具体的实现请往下看。

observe

export function observe (value, asRootData) {
  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 自身是否含有 __ob__ 属性,并且 __ob__ 属性应该是 Observer 的实例。如果为真则直接将数据对象自身的 __ob__ 属性的值作为 ob 的值:ob = value.__ob__。那么 __ob__ 是什么呢?其实当一个数据对象被观测之后将会在该对象上定义 __ob__ 属性,所以 if 分支的作用是用来避免重复观测一个数据对象。

然后是一些判断条件;

  • 第一个条件是 shouldObserve 必须为 true,也就是说某些情况下不需要响应式。比如刚才提到 props 是来自父组件的数据,这个数据如果是一个对象(包括纯对象和数组),那么它本身可能已经是响应式的了,所以不再需要重复定义。
  • 第二个条件是 !isServerRendering() 必须为 true,即服务端渲染时响应式被禁用。因为实际的渲染过程需要确定性,所以我们也将在服务器上「预取」数据 —— 这意味着在我们开始渲染时,我们的应用程序就已经解析完成其状态。也就是说,将数据进行响应式的过程在服务器上是多余的,所以默认情况下禁用。禁用响应式数据,还可以避免将「数据」转换为「响应式对象」的性能开销。
  • 第三个和第四个条件很好理解。
  • 第五个条件是 !value._isVue 必须为 true。Vue 实例对象拥有 _isVue 属性,所以这个条件用来避免 Vue 实例对象被观测。

至于 vmCount 有点绕就不说了。

当都满足,即创建一个 Observer 实例:

export class Observer {
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
  // 省略
}

初始化完成三个实例属性之后,使用 def 函数,为数据对象定义了一个不可枚举的 __ob__ 属性,这个属性的值就是当前 Observer 实例对象。

walk 用于把对象成员遍历并进行 defineReactive,而 observeArray 是对数组。这里先讲对象,数组的处理有所不同后面会说到。

defineReactive

export function defineReactive () {
  const dep = new Dep()

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

  // cater for pre-defined getter/setters
  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()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

dep 在访问器属性的 getter/setter 中被闭包引用,即每一个数据字段都通过闭包引用着属于自己的 dep 常量。

首先通过 Object.getOwnPropertyDescriptor 函数获取该字段可能已有的属性描述对象,并将该对象保存在 property 常量中。

我们知道 property 对象是属性的描述对象,一个对象的属性很可能已经是一个访问器属性了,所以该属性很可能已经存在 get 或 set 方法。由于接下来会使用 Object.defineProperty 函数重新定义属性的 setter/getter,这会导致属性原有的 set 和 get 方法被覆盖,所以要将属性原有的 setter/getter 缓存,并在重新定义的 set 和 get 方法中调用缓存的函数,从而做到不影响属性的原有读写操作。

至于 if((!getter || setter) && arguments.length === 2),比较难懂就不说了,不影响对框架的理解。

在 get 函数中收集依赖

首先判断是否存在 getter,刚才说了 getter 常量中保存的属性原型的 get 函数,如果 getter 存在那么直接调用该函数,并以该函数的返回值作为属性的值。如果 getter 不存在则使用 val 作为属性的值。

除了正确的返回属性值,还要收集依赖。

首先判断 Dep.target 是否存在,这是个唯一的全局变量,保存的值就是要被收集的依赖(Watch),后面会说到。

接着又判断了 childOb 是否存在,如果存在那么就执行 childOb.dep.depend()。childOb 是什么?

搞懂这个之前,先要明确一个数据对象经过了 observe 函数处理之后变成了这个样子:

const data = {
  // 属性 a 通过 setter/getter 通过闭包引用着 dep 和 childOb
  a: {
    // 属性 b 通过 setter/getter 通过闭包引用着 dep 和 childOb
    b: 1
    __ob__: {a, dep, vmCount}
  }
  __ob__: {data, dep, vmCount}
}

对于属性 a 来讲,访问器属性 a 的 setter/getter 通过闭包引用了一个 Dep 实例对象,即 a 用来收集依赖的容器。除此之外访问器属性 a 的 setter/getter 还闭包引用着 childOb,且 childOb === data.a.__ob__ 所以 childOb.dep === data.a.__ob__.dep。也就是说 childOb.dep.depend() 这句话的执行说明除了要将依赖收集到属性 a 自己的容器里之外,还要将同样的依赖收集到 data.a.__ob__.dep 的容器里,为什么要将同样的依赖分别收集到这两个不同的容器里呢?

这两个容器里收集的依赖的触发时机是不同的。

dep 里收集的依赖的触发时机是当属性值被修改时触发,即在 set 函数中触发:dep.notify()。而 childOb.dep 里收集的依赖的触发时机是在使用 $set 或 Vue.set 给数据对象添加新属性时触发。在没有 Proxy 之前 Vue 没办法拦截到给对象添加属性的操作。所以 Vue 才提供了 $set 和 Vue.set 等方法让我们有能力给对象添加新属性的同时触发依赖,就是通过数据对象的 __ob__ 属性做到的。因为 __ob__.dep 这个容器里收集了与 dep 这个容器同样的依赖。假设 Vue.set 函数代码如下:

Vue.set = function (obj, key, val) {
  defineReactive(obj, key, val)
  // 相当于 data.a.__ob__.dep.notify()
  obj.__ob__.dep.notify()
}

Vue.set(data.a, 'c', 1)

上面的代码之所以能够触发依赖,就是因为 Vue.set 函数中触发了收集在 data.a.__ob__.dep 中的依赖。所以 __ob__ 属性以及 __ob__.dep 的主要作用是为了添加、删除属性时有能力触发依赖,而这就是 Vue.set 或 Vue.delete 的原理。

然后对数组的每个元素(e)执行 e.__ob__.dep.depend() 收集依赖。

在 set 函数中触发依赖

getter 中取得属性原有的值,为什么要取得属性原来的值呢?很简单,因为我们需要拿到原有的值与新的值作比较,并且只有在原有值与新设置的值不相等的情况下才需要触发依赖和重新设置属性值。其中包含了 NaN === NaN 的处理。

customSetter 的作用是当你尝试修改 vm.$attrs 属性的值时,打印一段信息即:$attrs 属性是只读的。

set 最后两行:

childOb = !shallow && observe(newVal)
dep.notify()

由于属性被设置了新的值,那么假如我们为属性设置的新值是一个数组或者纯对象,那么该数组或纯对象是未被观测的,所以需要对新值进行观测,这就是第一句代码的作用,同时使用新的观测对象重写 childOb 的值。当然了,这些操作都是在 !shallow 为真的情况下,即需要深度观测的时候才会执行。最后是时候触发依赖了,我们知道 dep 是属性用来收集依赖的容器,现在我们需要把容器里的依赖都执行,这就是 dep.notify() 的作用。

数组的处理

处理数组的方式与纯对象不同,我们知道数组是一个特殊的数据结构,它有很多实例方法,并且有些方法会改变数组自身的值,我们称其为变异方法,这些方法有:push、pop、shift、unshift、splice、sort 以及 reverse 等。用户调用这些变异方法改变数组时需要触发依赖。

// 要拦截的数组变异方法
const mutationMethods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

// 实现 arrayMethods.__proto__ === Array.prototype
const arrayMethods = Object.create(Array.prototype) 
// 缓存 Array.prototype
const arrayProto = Array.prototype  

mutationMethods.forEach(method => {
  arrayMethods[method] = function (...args) {
    const result = arrayProto[method].apply(this, args)

    console.log(`执行了代理原型的 ${method} 函数`)
    // 依赖更新

    return result
  }
})

const arr = []
arr.__proto__ = arrayMethods

arr.push(1)

// 执行了代理原型的 push 函数

因为 __proto__ 属性是在 IE11+ 才开始支持,所以如果是低版本的 IE,直接在数组实例上定义与变异方法同名的函数。

可能你会问,为什么 Object.create(arrayMethods) 不行?因为 Vue 要用到数组里面的 __ob__ 来触发依赖更新。

$mount

$mount 会在 new Vue(传了 el 参数时)执行,或者延迟执行(new Vue 没传 el 参数),这可以用于在文档之外渲染然后挂载(比如用于 服务端渲染,不知道这一点没关系)。

第一个定义 $mount 地方是 platforms/web/runtime/index.js 文件,是运行时版 Vue 的入口文件。第二个定义 $mount 函数的地方是 platforms/web/entry-runtime-with-compiler.js 文件,这个文件是完整版 Vue 的入口文件,在该文件中重新定义了 $mount 函数,但是保留了运行时 $mount 的功能,并在此基础上为 $mount 函数添加了编译模板的能力。

  • 如果 template 选项不存在,那么使用 el 元素的 outerHTML 作为模板内容。
  • 如果 template 第一个字符是 #,那么会把该字符串作为 css 选择符去选中对应的元素。
  • 如果第一个字符不是 #,就用 template 自身的字符串值作为模板。

无论是完整版 Vue 的 $mount 函数还是运行时版 Vue 的 $mount 函数,他们最终都将通过 mountComponent 函数去真正的挂载组件。

mountComponent

之前我们介绍当对数据对象的访问会触发他们的 getter 方法,那么这些对象什么时候被访问呢?还记得之前我们介绍过 Vue 的 mount 过程是通过 mountComponent 函数,其中有一段比较重要的逻辑,大致如下:

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

当我们去实例化一个渲染 watcher 的时候,首先进入 watcher 的构造函数逻辑,然后会执行它的 this.get() 方法,进入 get 函数,首先会执行 pushTarget(this):

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

实际上就是把 Dep.target 赋值为当前的渲染 watcher 并压栈。

接着又执行了:

value = this.getter.call(vm, vm)

// this.getter 对应就是 updateComponent 函数
// 这实际上就是在执行:
vm._update(vm._render(), hydrating)

之前分析过这个方法会生成渲染 VNode,并且在这个过程中会对 vm 上的数据访问,这个时候就触发了数据对象的 getter。

那么每个对象值的 getter 都闭包持有一个 dep,在触发 getter 的时候会调用 dep.depend() 方法,也就会在 dep 里面执行 Dep.target.addDep(this)。

刚才我们提到这个时候 Dep.target 已经被赋值为渲染 watcher,那么就执行到 addDep 方法:

addDep (dep: Dep) {
  const id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}

这时候会做优化逻辑(先忽略),然后执行 dep.addSub(this),那么 dep 就会执行 this.subs.push(sub),也就是说把当前的 watcher 订阅到这个数据持有的 dep 的 subs 中。

然后如果是 deep watch,就递归遍历,触发它们的 getter 过程,收集到依赖。

if (this.deep) {
  traverse(value)
}

接下来执行:

Dep.target = targetStack.pop()

把 Dep.target 恢复成上一个状态,因为当前 vm 的数据依赖收集已经完成。

为什么 target 设计成了栈的形式?

Vue2 中视图被抽象为一个 render 函数,一个 render 函数只会生成一个 Watcher。

renderRoot () {
    ...
    renderMy ()
    ...
}

在视图渲染时映射为 render 函数的嵌套调用,有嵌套调用就会有调用栈。当 evaluate root 时,调用到 my 的 render 函数,此时就需要中断 root 而进行 my 的 evaluate,当 my 的 evaluate 结束后 root 将会继续进行,这就是 target stack 的意义。

然后还与计算属性有关,这个后面会讲到。

touch setter 派发更新

当我们在组件中对响应的数据做了修改,就会触发 setter 的逻辑,最后调用 dep.notify() 方法, 它是 Dep 的一个实例方法:

class Dep {
  // ...
  notify () {
  // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

遍历所有的 subs,也就是 Watcher 的实例数组,然后调用每一个 watcher 的 update 方法。在一般组件数据更新的场景,会走到最后一个 queueWatcher(this) 的逻辑,这里引入了一个队列的概念,这也是 Vue 在做派发更新的时候的一个优化的点,它并不会每次数据改变都触发 watcher 的回调,而是把这些 watcher 先添加到一个队列里,然后在 nextTick 后执行 flushSchedulerQueue,它的定义在 src/core/observer/scheduler.js 中。queueWatcher 用 has 对象保证同一个 Watcher 只添加一次。

computed 属性的实现

这个之所以放到后面,因为只要前面的懂了这个好懂了。

initComputed 里面为每个计算属性 new 了个 computed watcher(通过设置 computed 参数为 true)。

当初始化这个 computed watcher 实例的时候,构造函数部分逻辑稍有不同:

constructor (...) {
  // ...
  if (this.computed) {
    this.value = undefined
    this.dep = new Dep()
  } else {
    this.value = this.get()
  }
}  

区别于前文中提到的渲染 watcher,computed watcher 并不会立刻求值,同时持有一个 dep 实例。

当 render 函数执行的时候,触发了计算属性的 getter,它会拿到计算属性对应的 watcher,然后执行 watcher.depend(),来看一下它的定义:

// 这个方法为 computed property watchers 独有
depend () {
  if (this.dep && Dep.target) {
    this.dep.depend()
  }
}

注意,这时候的 Dep.target 是渲染 watcher,所以 this.dep.depend() 相当于渲染 watcher 订阅了这个 computed watcher 的变化。

然后再执行 watcher.evaluate() 去求值,来看一下它的定义:

evaluate () {
  if (this.dirty) {
    this.value = this.get()
    this.dirty = false
  }
  return this.value
}

evaluate 的逻辑非常简单,判断 this.dirty,如果为 true 则通过 this.get() 求值,然后把 this.dirty 设置为 false。在求值过程中,会执行 value = this.getter.call(vm, vm),这实际上就是执行了计算属性定义的 getter 函数,即运行计算属性。

这里需要特别注意的是,由于计算属性内部都是响应式对象,这里会触发它们的 getter,根据我们之前的分析,它们会把自身持有的 dep 添加到当前正在计算的 watcher 中,这个时候 Dep.target 就是这个 computed watcher。

我们知道了计算属性的求值过程,那么接下来看一下它依赖的数据变化后的逻辑。

一旦我们对计算属性依赖的数据做修改,则会触发 setter 过程,通知所有订阅它变化的 watcher 更新,执行 watcher.update() 方法:

if (this.computed) {
  if (this.dep.subs.length === 0) {
    this.dirty = true
  } else {
    this.getAndInvoke(() => {
      this.dep.notify()
    })
  }
} else if (this.sync) {
  this.run()
} else {
  queueWatcher(this)
}

那么对于计算属性这样的 computed watcher,如果 this.dep.subs.length === 0 成立,则说明没有人去订阅这个 computed watcher 的变化,仅仅把 this.dirty = true,只有当下次再访问这个计算属性的时候才会重新求值。在直接使用 computed 的场景下,渲染 watcher 订阅了这个 computed watcher 的变化,那么它会执行 getAndInvoke。

getAndInvoke 函数会重新计算,然后对比新旧值,如果变化了则执行回调函数,那么这里这个回调函数是 this.dep.notify(),在这个场景下就是触发了渲染 watcher 重新渲染。

计算属性本质上就是一个 computed watcher,之所以这么设计是因为 Vue 想确保不仅仅是计算属性依赖的值发生变化,而是当计算属性最终计算的值发生变化才会触发渲染 watcher 重新渲染,本质上是一种优化。

从上面的源码中还可以看到,一旦我们设置了 sync,就可以在当前 Tick 中同步执行 watcher 的回调函数。

nextTick

限于篇幅,nextTick 更多解释请看我写的这篇 Event Loop: Macro Task 和 Micro Task 及应用

刚才提到 nextTick 说白了就是在一个事件循环中发生的所有数据改变都会在下一个事件循环中来触发视图更新,这有利于避免重复的绘制。

Vue2.4 之后 nextTick 采取的策略是默认走 Micro Task,对于一些 DOM 的交互事件,如 v-on 绑定的事件回调处理函数的处理,会强制走 Macro Task。

对于 Macro Task 的执行,Vue 优先检测是否支持原生 setImmediate(高版本 IE 和 Edge 支持),不支持的话再去检测是否支持原生 MessageChannel,如果还不支持的话为 setTimeout。

flushSchedulerQueue

queue.sort((a, b) => a.id - b.id) 对队列做了从小到大的排序,这么做主要有以下要确保以下几点:

  1. 组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher 的创建也是先父后子,执行顺序也应该保持先父后子。
  2. 用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的。
  3. 如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行。

在对 queue 排序后,拿到对应的 watcher,执行 watcher.run(),实际上就是执行 this.getAndInvoke 方法。限于篇幅还有一些情况略过。

getAndInvoke

这个函数第一句代码就调用了 this.get 方法,对于渲染函数的观察者,文章前面有讲到重新求值等价于重新执行渲染函数。

对于非渲染函数类型的观察者,先通过 this.get() 得到它当前的值,然后做判断,如果满足新旧值不等、新值是对象类型、deep 模式任何一个条件,则执行 watcher 的回调。回调函数执行的时候会把第一个和第二个参数传入新值 value 和旧值 oldValue,这就是当我们添加自定义 watcher 的时候能在回调函数的参数中拿到新旧值的原因。

总结

在响应式方面,如果数据和模板绑定的粒度越细,每一个绑定都会需要一个 observer / watcher ,这样会带来相应的内存以及依赖追踪的开销。所以在 Vue2 里面选择的是一个比较中等粒度的方案,每一个组件是一个响应式的 watcher,当数据变动时候我们可以对组件进行更新,在每个组件内部则是用 Virtual Dom 进行比对。

Vue 还进行了一些巧妙的复用和优化,这有利于提升性能、缩小代码体积。

框架要考虑跨平台渲染和通用的应用场景,并在多个选型之中进行了合理的取舍,这需要很高的设计水平。

参考