Vue2的响应式原理

836 阅读5分钟

响应式

数据响应式的目标:当响应式数据对象本身或属性发生变化时,会运行一些函数,比如 render 函数。

Vue 实现响应式的实现上,具体是依赖几个部件:

  1. Observer
  2. Dep
  3. Watcher
  4. Scheduler

该几个模块的实现都在 Vue2 源码的 ./src/core/observer 中。

Observer

Observer 是一个类,它所做的事就是把一个普通的对象转换为响应式对象。

Observer 把对象的每个属性通过 Object.defineProperty 转换为带有 gettersetter 的属性,这样一来,当访问或设置属性时,vue 就有机会做一些别的事情。

Observer 的原型上定义了两个方法 observeArraywalk,分别用于将数组、一般的对象转为响应式。

Vue2 中数组的响应式和对象有一些区别,如果数组中的某一项是原始值类型,直接通过赋值修改是无法触发响应式的。而数组的一些原型方法像是具有响应式的,这是 Vue2 悄悄地修改了数组的原型方法。

具体做法是在 array 模块中,通过 Object.create(Array.prototype) 创建了一个对象 arrayMethods,并将数组的一些方法重写实现数据响应式。在实例化 Observer 时将我们数据中的数组的隐式原型修改为 arrayMethods

Vue2 是基于 ES5 实现的,ES5 未提供 Object.setPrototypeOf 方法,Vue2 只能针对实现了 __proto__ 和未实现 __proto__ 分别执行部不同的操作。对于实现了 __proto__ 的浏览器直接修改数组原型让数组继承自 arrayMethods,未实现 __proto__ 的浏览器则将 arrayMethods 的那些方法直接定义到数组身上。

/* @flow */
function copyAugment (target: Object, src: Object, keys: Array<string>) {
    for (let i = 0, l = keys.length; i < l; i++) {
        const key = keys[i]
        def(target, key, src[key])
    }
}

Observer 模块中最重要的函数 observe,该函数用于观察一个数据,原始值什么都不做,如果是未实现响应式的对象就为它实例化一个 Observer 实现响应式。

数组中原始值没有实现响应式,而对象实现了是因为 observeArray 遍历整个数组对每一项调用 observe

Observer 中依赖的一个重要的函数是 defineReactive,该函数用于将一个普通对象实现响应式,也就是将该对象的每个属性通过 defineProperty 添加 gettersetter,该函数在实现响应式的过程中,会通过 observe 函数来进行递归以完成深度的响应式实现。

每个响应式对象、数组都会有一个与之对应的 Observer 实例,该实例通过 __ob__ 可以访问。这是因为在 Observer 的构造函数中存在这么一句 def(value, '__ob__', this)

由于遍历时只能遍历到对象的当前属性,无法监测到将来动态增加或删除的属性。Observer 模块定义了两个函数 setdel 来解决这个问题:

function set (target: Array<any> | Object, key: any, val: any): any {
    if (Array.isArray(target)) {
        target.splice(key, 1, val)
        return val
    }
    const ob = (target: any).__ob__
    defineReactive(ob.value, key, val)
    ob.dep.notify()
    return val
}

function del (target: Array<any> | Object, key: any) {
    if (Array.isArray(target)) {
        target.splice(key, 1)
        return
    }
    const ob = (target: any).__ob__
    delete target[key]
    ob.dep.notify()
}

对于对象,删除 / 新增属性之后,通过 __ob__.notify 来实现派发更新。

对于数组,直接调用改写过的原型方法 splice 即可实现添加 / 修改 / 删除并派发更新,因为 arrayMethods 中也用同样的方式 ob.dp.notify 实现派发更新。

Dep

这里有两个问题没解决,就是读取属性时要做什么事,而属性变化时要做什么事,这个问题需要依靠 Dep 来解决。

Dep 的含义是 Dependency,表示依赖的意思。

Dep 同样是一个类,该类主要用于解决读取属性读取和属性变化时需要做的时。

Vue2 会为响应式对象中的每个属性、对象本身、数组本身创建一个 Dep 实例。

每个 Dep 实例都有能力做以下两件事:

  • 记录依赖:是谁在用我对应的数据
  • 派发更新:我对应的数据变了,要通知那些用到我的函数进行更新

当读取响应式数据时,它会进行依赖收集;当改变某个响应式数据时,它会派发更新。

这些事都是在 defineReactive 中实现:

  • defineReactive 函数第一行代码就是 const dep = new Dep()
  • Observer 构造函数中存在 this.dep = new Dep()
  • getter 中存在 dep.depend() 收集依赖
  • set 中存在 dep.notify() 派发更新

Watcher

这里又出现一个问题,就是 Dep 如何知道是谁在用我?

要解决这个问题,需要使用到另一个类 Watcher

当某个函数(computed、watch、render)执行的过程中,用到了响应式数据,响应式数据是无法知道是哪个函数在用自己的。

Vue2 通过一种巧妙的办法来解决这个问题,我们不要直接执行函数,而是把函数交给一个叫做 watcher 的东西去执行。

watcher 是一个对象,每个会用到响应式数据的函数执行时都应该创建一个 watcher,通过 watcher 去执行该函数。

执行之前会有一个全局变量记录当前负责执行的 watcher 是自己,然后开始执行函数,在函数的执行过程中,如果用到了响应式数据会执行 getter 间接的会执行 dep.dependdep.depend 函数的作用是记录依赖,所谓的依赖就是该函数对应的 Watcher 实例。

每一个响应式数据都对应一个 dep,每个响应式数据都可能会被一些函数所依赖,每个依赖响应式数据的函数都对应一个 watcherdep 会记录依赖,记录的方式是:每一个 dep 都具有一个属性 subs 记录该 dep 对应的响应式数据被依赖的函数所对应的 watcher。调用 dep.depend 就会将现在这个 watcher 加入到 subs 中。

现在正在运行的 watcher 是通过 Dep.target 这个全局变量来记录的。

每一个 Vue 组件实例,都至少对应一个 watcher,该 watcher 中记录了该组件的 render 函数。

当数据变化时,会运行响应式对象的 setter 方法,从而运行 dp.notify 派发更新,subs 中的每一个 watcher 都运行update 方法。会调用 run 方法,run 方法中会调用和该 watcher 对应的函数。

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

Scheduler

就是 Dep 通知 watcher 之后,如果 watcher 执行重运行对应的函数,就有可能导致函数频繁运行,从而导致效率低下。

这样显然是不合适的,因此,watcher 收到派发更新的通知后,实际上不是立即执行对应函数,而是把自己交给一个叫调度器的东西,通过 queueWatcher(this) 来实现。

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

调度器通过 scheduler 模块实现,该模块维护一个执行队列,该队列中同一个 watcher 仅会存在一次。

scheduler 模块具有一个 flushSchedulerQueue 函数,用于清空执行队列,该函数会被传递给 next-tick 模块中 nextTick

scheduler 模块中还存在一个 MAX_UPDATE_COUNT = 100 的常量。

next-tick 模块维护一个任务队列,nextTick 方法会将需要执行的任务放入为微队列中,一般使用 Promise 实现,其次会依次使用 MutationObserversetImmediatesetTimeout(flushCallbacks, 0)

所以说 Vue 的更新(render函数的执行)是异步的。

nextTick 通过 this.$nextTick 暴露给开发者,如果在数据更新操作前使用 nextTick 则拿到的数据是更新之前的,在数据更新之后使用 nextTick 则可以拿到变化之后的值。

总体流程