源码阅读 - Vue的数据响应式原理

没有像 Vue 和 React 这些具有数据响应式特性的前端框架之前,我们从服务端提供过的接口获取到数据要渲染在 html 页面上时,抑或是需要获取表单的值进行计算回显到页面上时,都需要建立很多 DOM 事件监听器,并进行许多的 DOM 操作。举个最简单的例子:用户在 html 页面的表单中输入两个加数 a 和 b ,计算得到两个加数之和,并展示到页面上。这时我们需要监听表单中 a 和 b 两个输入框的变化,拿到变化值相加然后通过修改指定 id 或者 class 的选择器对应的 DOM 来修改两数之和。但是当输入表单不止两个时,就会令人捉急。

而 Vue 的数据响应式大大地优化了这一整套的流程。面对大量的 UI 交互和数据更新,数据响应式让我们做到从容不迫——我们需要去了解数据的改变是怎样被触发,更新触发后的数据改变又是怎样反馈到视图。接下来我们通过对部分 Vue 源码的简单分析和学习来深入了解 Vue 中的数据响应式是怎样实现的。

什么是数据响应式

Vue.js 一个核心思想是数据驱动。所谓数据驱动,是指视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据。——《Vue.js技术揭秘》

没有像 Vue 和 React 这些具有数据响应式特性的前端框架之前,我们从服务端提供过的接口获取到数据要渲染在 html 页面上时,抑或是需要获取表单的值进行计算回显到页面上时,都需要建立很多 DOM 事件监听器,并进行许多的 DOM 操作。举个最简单的例子:用户在 html 页面的表单中输入两个加数 a 和 b ,计算得到两个加数之和,并展示到页面上。这时我们需要监听表单中 a 和 b 两个输入框的变化,拿到变化值相加然后通过修改指定 id 或者 class 的选择器对应的 DOM 来修改两数之和。但是当输入表单不止两个时,就会令人捉急。

而 Vue 的数据响应式大大地优化了这一整套的流程。面对大量的 UI 交互和数据更新,数据响应式让我们做到从容不迫——我们需要去了解数据的改变是怎样被触发,更新触发后的数据改变又是怎样反馈到视图。接下来我们通过对部分 Vue 源码的简单分析和学习来深入了解 Vue 中的数据响应式是怎样实现的。

我们将这一部分的代码分析大致分为三部分:让数据变成响应式、依赖收集和派发更新。

数据响应式的中心思想

5de7af21d4c2de951720c006f84b98fc.png

该原理图源自 Vue 官方教程的深入响应式原理,这张图向我们展示了 Vue 的数据响应式的中心思想。

我们先来介绍图中的四个模块,黄色部分是 Vue 的渲染方法,视图初始化和视图更新时都会调用 vm._render 方法进行重新渲染。渲染时不可避免地会 touch 到每个需要展示到视图上的数据(紫色部分),触发这些数据的 get 方法从而收集到本次渲染的所有依赖。收集依赖和更新派发都是基于蓝色部分的 Watcher 观察者。而当我们在修改这些收集到依赖的数据时,会触发数据中的 set 属性方法,该方法会修改数据的值并 notify 到依赖到它的观察者,从而触发视图的重新渲染。绿色部分是渲染过程中生成的 Virtual DOM Tree,这棵树不仅关系到视图渲染,更是 Vue 优化视图更新过程的基础。

让数据变成响应式

基本上大家都了解 Vue 的数据响应式原理是由 JS标准内置对象方法 Object.defineProperty 来实现的,而这个方法是不兼容IE8和FF22及以下版本的浏览器的,所以 Vue 也只能在这些版本之上的浏览器中才能正常使用。这个方法的作用是直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。那么数据响应式用这个方法为什么对象添加或修改了什么属性呢?我们从 Vue 的初始化讲起。

99d9a00af95016a8e9b0b558fd4249ab.png

Vue 的初始化

1
2
3
4
5
6
7
8
function Vue (options) {
// ...
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
// ...
export default Vue

src/core/index.js 中我们可以看到一个 Vue 被导出,这个Vue是在 src/core/instance/index 中定义的,Vue 是一个方法,参数是options,我们大胆猜想这个 Vue 就是进行 vue 实例化的方法,而 options 就是我们传入的 data、computed、methods 等属性。这个 Vue 方法中主要调用了 _init 方法,这个方法是在 initMixin 方法调用时定义在 Vue 原型上的一个方法,_init 方法中对当前传入的 options 进行了一些处理(主要是判断当前实例化的是否为组件,使用 mergeOptions 方法对 options 进行加工,此处不做赘述),然后又调用了一系列方法进行了生命周期、事件、渲染器的初始化,我们主要来关注 initState 这个方法(src/core/instance/state.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)
// a*
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)
}
}

将 Vue 实例上的 data 响应式化

上面的方法中对props、methods、data、computed和watch进行了初始化,这些都是Vue实例化方法中传入参数options对象的一些属性,这些属性都需要被响应式化。而针对于data的初始化分了两种情况[a*],一种是options中没有data属性的,该方法会给data赋值一个空对象并进行observe(该方法之后我们会详细讲述),如果有data属性,则调用initData方法进行初始化。我们主要通过对data属性的初始化来分析Vue中的数据响应式原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function initData (vm: Component) {
let data = vm.$options.data
// a*
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
if (!isPlainObject(data)) {
data = {}
process.env.NODE_ENV !== 'production' && warn(
'data functions should return an object:\n' +
'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
vm
)
}
// b*
const keys = Object.keys(data)
const props = vm.$options.props
const methods = vm.$options.methods
let i = keys.length
while (i--) {
// c*
const key = keys[i]
if (process.env.NODE_ENV !== 'production') {
if (methods && hasOwn(methods, key)) {
warn(
`Method "${key}" has already been defined as a data property.`,
vm
)
}
}
if (props && hasOwn(props, key)) {
process.env.NODE_ENV !== 'production' && warn(
`The data property "${key}" is already declared as a prop. ` +
`Use prop default value instead.`,
vm
)
} else if (!isReserved(key)) {
proxy(vm, `_data`, key)
}
}
// d*
observe(data, true)
}

initData 方法对 options 中的 data 进行处理,主要是有两个目的:

  • 一方面将 data 代理到 Vue 实例上,同时检查 data 中是否使用了 Vue 中的保留字、是否与 props、methods 属性中的数据同名
  • 使用 observe 方法将data中的属性变成响应式的

之前我们提到在调用 initState 方法之前会有对 Vue 实例化传入的 options 参数进行处理的环节(mergeOptions),这个过程会将 data 处理成一个函数(例如我们在 vue 组件中的 data 属性),之后会调用 callHook(vm, 'beforeCreate') 方法来触发 beforeCreate 的生命周期钩子,这个方法的调用可能会对 data 属性进行进一步处理,所以方法一开始会对 data 统一做处理使其成为一个function[a*]。

接下来会对 data 中的每一个数据进行遍历[b*],遍历过程将会使用 hasOwn(methods, key)hasOwn(props, key)!isReserved(key) 方法对该数据是否占用保留字、是否与 props 和 methods 中的属性重名进行判断,然后调用 proxy 方法将其代理到 Vue 实例上。此处需要注意的是:如果 data 中的属性与 props、methods 中的属性重名,那么在 Vue 实例上调用这个属性时的优先级顺序是 props > methods > data

最后对每一个 data 中的属性调用 observe 方法,该方法的功能是赋予 data 中的属性可被监测的特性。这个方法和其中使用到的 Observe 类是在src/core/observer/index.js文件中,observe 方法主要是调用Observer类构造方法,将每一个 data中的 value 变成可观察、响应式的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0

def(value, '__ob__', this)

if (Array.isArray(value)) {
//...
this.observeArray(value)
} else {
this.walk(value)
}
}

可以看到该构造函数有以下几个目的:

  • 针对当前的数据对象新建一个订阅器;
  • 为每个数据的value都添加一个ob属性,该属性不可枚举并指向自身;
  • 针对数组类型的数据进行单独处理(包括赋予其数组的属性和方法,以及 observeArray 进行的数组类型数据的响应式);
  • this.walk(value),遍历对象的 key 调用 defineReactive 方法;

defineReactive是真正为数据添加 get 和 set 属性方法的方法,它将 data 中的数据定义一个响应式对象,并给该对象设置 get 和 set 属性方法,其中 get 方法是对依赖进行收集, set 方法是当数据改变时通知 Watcher 派发更新。

依赖收集

依赖收集的原理是:当视图被渲染时,会触发渲染中所使用到的数据的 get 属性方法,通过 get 方法进行依赖收集。

其真实的逻辑我们举例来说明:当前正在渲染组件 ComponentA,会将当前全局唯一的监听器置为这个 Watcher,这个组件中使用到了数据 data () { return { a: b + 1} },此时触发b的 getter 会将当前的 watcher 添加到b的订阅者列表 subs 中。也就是说如果 ComponentA 依赖 b,则将该组件的渲染 Watcher 作为订阅者加入b的订阅者列表中。

1e9dfeb2187b782e42b78196a723f860.jpeg

我们先看下 get 属性方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
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
}

Dep - 订阅器

数据对象中的 get 方法主要使用 depend 方法进行依赖收集,该方法是 Dep 类中的属性方法,继续来看 Dep 类是怎样实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export default class Dep {
// a*
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)
}
}
}

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 类中有三个属性:targetidsubs,分别表示当前全局唯一的静态数据依赖的监听器 Watcher、该属性的 id 以及订阅这个属性数据的订阅者列表[a*]。另外还提供了将订阅者添加到订阅者列表的 addSub方法、从订阅者列表删除订阅者的 removeSub 方法。

Dep.target 是当前全局唯一的订阅者,这是因为同一时间只允许一个订阅者被处理。target 的意思就是指当前正在处理的目标订阅者,而这个订阅者是有一个订阅者栈 targetStack,当某一个组件执行到某个生命周期的 hook 时(例如 mountComponent),会将当前目标订阅者 target 置为这个 watcher,并将其压入 targetStack 栈顶。

接下来是添加当前数据依赖的 depend方法Dep.target 对象是一个 Watcher 类的实例,调用 Watcher 类的 addDep 方法,我们看下 addDep 方法将当前的依赖 Dep 实例放在了哪里:

1
2
3
4
5
6
7
8
9
10
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)
}
}
}

我们稍后会对 Watcher 类进行分析,目前先来简单看下 addDep 这个属性方法做了什么。可以看到入参是一个 Dep 类实例,这个实例实际上是当前全局唯一的订阅者,newDepIds 是当前数据依赖 dep 的 id 列表,newDeps 是当前数据依赖 dep 列表,depsId 则是上一个 tick 的数据依赖的 id 列表。这个方法主要的逻辑就是调用当前数据依赖 dep 的类方法 addSub,而这个方法在上面Dep 类方法中可以看到,就是将当前全局唯一的 watcher 实例放入这个数据依赖的订阅者列表中。

target Watcher的产生

我们以生命周期中的 mountComponent 这一个阶段为例来分析上面提到的Dep.target 这个全局唯一的当前订阅者 Watcher 是怎样产生的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export function mountComponent (
vm: Component,
el: ?Element, hydrating?: boolean
): Component {
// ...
let updateComponent
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
updateComponent = () => { // 首次渲染视图会进入,生成虚拟Node,更新DOM}
} else {
updateComponent = () => { // 数据更新时会进入,调用渲染函数对视图进行更新
vm._update(vm._render(), hydrating)
}
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
// ...
return vm
}

通过mountComponent的代码可以看到在触发了beforeMount生命周期钩子后,紧接着创建了一个用于渲染Watcher实例。我们通过Watcher类的构造函数来看创建Watcher实例时做了哪些工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
constructor (
vm: Component, // 当前渲染的组件
expOrFn: string | Function, // 当前Watcher实例的getter属性方法
cb: Function, // 回调函数
options?: ?Object, // 手动传入watch时的option
isRenderWatcher?: boolean // 是否为渲染watcher的标识
) {
this.vm = vm
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
// 此处处理我们传入的watcher参数(在组件内手动新建watch属性)
if (options) {} else {}
// ...
// parse expression for getter
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()
}

通过 Watcher 类的构造函数传参可以看到 mountComponent 生命周期中创建的 Watcher 是一个渲染 Watcher,将当前的 getter 设置为了 updateComponent 方法(也就是重新渲染视图的方法),最后调用了 get 属性方法,我们接下来看 get 方法做了什么:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 方法首先调用了在 Dep类文件中定义的全局方法 pushTarget,将 Dep.target 置为当前正在执行的渲染 Watcher,并将这个 watcher 压到了 targetStack。接下来调用 wathcer 的 getter 方法:由于此时的 getter 就是 updateComponent 方法,执行 updateComponent 方法,也就是 vm._update(vm._render(), hydrating) 进行渲染,渲染过程中必然会 touch 到 data 中相关依赖的属性,此时就会触发各个属性中的 get 方法(也就是将当前的渲染 Watcher 添加到所有渲染中使用到的数据的依赖列表 subs 中)。

渲染完之后会将当前的渲染 Watcher 从 targetStack 推出,进行下一个 watcher 的任务。最后会进行依赖的清空,每次数据的变化都会引起视图重新渲染,每一次渲染又会进行依赖收集,又会触发 data 中每个属性的 get 方法,这时会有一种情况发生:<div v-if="someBool">{{ a }}</div><div v-else>{{ b }}</div> 第一次当 someBool 为真时,进行渲染,当我们把 someBool 的值 update 为 false 时,这时候属性 a 不会被使用,所以如果不进行依赖清空,会导致不必要的依赖通知。依赖清空主要是对 newDeps 进行更新,通过对比本次收集的依赖和之前的依赖进行对比,把不需要的依赖清除。

派发更新

6ff6878b57427d1b0115d669089d54d3.png

当我们修改一个存在 data 属性上的值时,会触发数据中的 set 属性方法,首先会判断并修改该属性数据的值,并触发自身的 dep.notify 方法开始更新派发:

1
2
3
4
5
6
7
8
9
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
// ...
childOb = !shallow && observe(newVal)
dep.notify()
}

接下来让我们进入dep.notify这个方法:

1
2
3
4
5
6
7
8
9
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()
}
}

notify 这个方法是每一个数据在响应式化之后自己内部闭包的 dep 实例的方法,还记得之前讲过的 subs 保存着所有订阅该数据的订阅者,所以在 notify 方法中首先对 subs 这个订阅者序列按照其 id 进行了排序。接下来就是调用每一个订阅者 watcher 实例的 update 方法进行更新的派发。update 方法是在 Watcher 类文件中,使用了队列的方式来管理订阅者的更新派发,其中主要调用了 queueWatcher 这个方法来实现该逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) { // a*
has[id] = true
if (!flushing) { // b*
queue.push(watcher)
} else {
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
if (!waiting) { // c*
waiting = true
// ...
nextTick(flushSchedulerQueue)
}
}
}

进入该方法后,首先使用一个名为 has 的 Map 来保证每一个 watcher 仅推入队列一次[a]。flushing 这个标识符表示目前这个队列是否正在进行更新派发,如果是,那么将这个 id 对应的订阅者进行替换,如果已经路过这个 id,那么就立刻将这个 id 对应的 watcher 放在下一个排入队列[b]。接下来根据waiting 这个标识符来表示当前是否正在对这个队列进行更新派发[c*],如果没有的话,就可以调用 nextTick(flushSchedulerQueue) 方法进行真正的更新派发了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function flushSchedulerQueue () {
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()
// ...
}

这个方法首先对队列中的 watcher 按照其 id 进行了排序,排序的主要目的有三:

  • 组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher 的创建也是先父后子,执行顺序也应该保持先父后子。
  • 用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的。
  • 如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行。
    排序结束之后,便对这个队列进行遍历,并执行watcher.run()方法去实现数据更新的通知,run方法的逻辑是:
    • 新的值与老的值不同时会触发通知;
    • 但是当值是对象或者deep为true时无论如何都会进行通知

所以 watcher 有两个功能,一个是将属性的值进行更新,另一个就是可以执行 watch 中的回调函数 handler(newVal, oldVal),这也是为何我们可以在 watcher 中拿到新旧两个值的原因。

至此,数据的更新派发也通知到了各个组件,便又可以进行视图的更新渲染,收集依赖,派发更新……