Vue响应式原理剖析(data、watch、computed)

能用 Vue 改写的应用,终将会用 Vue 来改写。—— 尤大大(没说过)

都 2020 年了,我想下面三句话不过分吧:

  • Vue 是一个非常优秀的前端框架
  • 每个前端开发都应该会一点 Vue
  • 不会 Vue 的前端不是优秀的前端

其实 Vue 的 API 是非常容易上手的,官方文档读一遍就能开发了,分分钟实现一个 TODO App。但只会用 Vue 其实是远远不够的,每一个 Vue 开发者都应该了解 Vue 响应式原理(datawatchcomputed),否则永远只是知其然,不知其所以然。

为了让大家彻底搞懂,本文采用渐进式的分析方式,即从零开始一行行写代码,最终完整实现一个具备响应式简版 Vue。 好了,大家把 vscode 打开,新建一个 vue.js 空文件,开始撸吧!

Vue 初始化流程

Vue 本质上就是一个构造函数,只有一行代码:

1
2
3
function Vue(options) {
this._init(options)
}

到这里,你可能就懵逼了,肯定会问 this._init 方法是什么?别急,这个方法是定义原型上的,而尤大大喜欢通过下面的方式来给原型添加方法:

1
2
initMixin(Vue)
stateMixin(Vue)

目的是拆分成一个个文件方便维护,尤大大把 initMixin 方法写在了 init.js 文件中,stateMixin 方法写在了 state.js 文件中,由于我们今天只写一个文件,所以就把它们放在一起好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function initMixin(Vue) {
Vue.prototype._init = function (options) {
const vm = this
vm.$options = options
initData(vm)
initComputed(vm)
initWatch(vm)
}
}

function stateMixin(Vue) {
Vue.prototype.$watch = function (exprOrFn, cb, options = {}) {
const watcher = new Watcher(this, exprOrFn, cb, { ...options, user: true })
if (options.immediate) cb.call(vm, watcher.value)
}
}

你看,这两个 mixin 也没啥特别的,不就是给 Vue 原型加了个 _init$watch 方法嘛!先看 _init 方法吧,总共就 5 行,前面两行的作用是把用户传过来的 option 参数赋值给了实例的 $options 属性上,然后跟了仨 initXXX,不要怕,这就是尤大大撸码的风格:套娃。即把代码拆分、拆分又拆分而已。我们分别来看下:

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
// 初始化 data
function initData(vm) {
let { data } = vm.$options
data = vm._data = typeof data === 'function' ? data.call(vm) : data || {}
for (let key in data) proxy(vm, '_data', key)
Observer.observe(data)
}
// 初始化 computed
function initComputed(vm) {
const { computed } = vm.$options
const watchers = (vm._computedWatchers = {})
for (let key in computed) {
const userDef = computed[key] // 取出对应的值来
const getter = typeof userDef == 'function' ? userDef : userDef.get // 兼容函数和对象
watchers[key] = new Watcher(vm, getter.bind(vm), () => {}, { lazy: true })
defineComputed(vm, key, userDef)
}
}
// 初始化 watch
function initWatch(vm) {
const { watch } = vm.$options
for (const key in watch) {
const handler = [].concat(watch[key])
handler.forEach((it) => createWatcher(vm, key, it))
}
}

你看,这三个函数就是处理用户传过来的 datacomputedwatch 的,从 vm.$options 也就是用户传过来的 options 中结构出相应的值,然后进行处理而已。这三个 initXXX 函数里面可能用到了一些其他函数,例如:

  • initData 中有 proxyObserver.observe
  • initComputed 中有 new WatcherdefineComputed
  • initWatch 中有 createWatcher

我们先看下 proxy 函数:

1
2
3
4
5
6
function proxy(target, sourceKey, key) {
Object.defineProperty(target, key, {
get() { return target[sourceKey][key] },
set(val) { target[sourceKey][key] = val },
})
}

其实就是把从某个对象中取值,代理到从其内部某个属性中取值而已。例如:proxy(vm, '_data', 'name') 就是做了下面两件事:

  • 当用户调用 vm.name 的时候,实际上是从 vm._data.name 中取的
  • 当用户调用 vm.name = 'xxx' 的时候,实际上是操作 vm._data.name = 'xxx'

剩下的一些函数都跟接下来要讲的「三剑客」有关,大家最好先把上面讲的 Vue 初始化的整个流程梳理清楚,接下来会详细介绍「三剑客」。

三剑客

Vue 响应式原理的核心只有 3 个类,DepWatcherObserve,只要弄懂了这三剑客的实现,响应式原理就弄懂了。

Dep 依赖收集者

Dep 类是跟响应式数据相关,它有以下两个特点:

  • 每个响应式对象的属性都关联了一个 dep 实例
  • 每个响应式对象或数组本身也关联了一个 dep 实例

在 dep 的内部有个 subs 数组,存放当前 dep 实例收集到的 watcher,当 dep 关联的对象属性或对象/数组本身发生变化的时候,就会触发 notify 方法通知 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
class Dep {
static target = null // 当前watcher
static targetStack = [] // watcher栈
static pushTarget(target) { // watcher入栈
this.targetStack.push(target)
Dep.target = target
}
static popTarget() { // watcher出栈
this.targetStack.pop()
Dep.target = this.targetStack[this.targetStack.length - 1]
}
constructor() {
this.subs = [] // 保存watcher实例
}
depend() {
if (Dep.target) Dep.target.addDep(this) // 让watcher把当前实例保存到watcher内部
}
addSub(sub) {
this.subs.push(sub) // 添加watcher
}
notify(newVal, val) {
this.subs.forEach((sub) => sub.update(newVal, val)) // 通知watcher
}
}

Watcher 数据观察者

Watcher 类用于给 Vue 实例注册观察者,例如:

1
new Watcher(vm, 'name', ()=>console.log('name变了')

上面就为 name 注册了一个观察者,当调用 vm.name=newName 的时候,就会执行上面的回调,打印 name变了。这里的设计非常巧妙,要结合 Dep 一起看,核心在于 get 方法,注册流程是这样的:

  • 首先把当前实例放到 Depwatcher 栈里面
  • 接下来执行 getter 方法的时候会触发响应式数据的 get 方法
  • 响应式数据关联的 Dep 实例把 watcher 收集起来

watcher 的第二个参数可以是一个路径字符串或 getter 函数,例如: a.b.c.d,那么 getter 函数会被定义为取 vm.a.b.c.d 这种深层的属性,函数会更灵活一些,例如既要观察 name 又要观察 age 的话,只能用函数了。

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
class Watcher {
constructor(vm, expOrFn, cb, options = {}) {
this.vm = vm
if (typeof expOrFn === 'function') this.getter = expOrFn
else this.getter = (vm) => expOrFn.split('.').reduce((it, k) => it && it[k], vm)
this.cb = cb
this.deps = []
this.lazy = !!options.lazy
this.user = !!options.user
this.value = this.get()
}
get() { // 调用 getter 取值,触发 dep 收集
Dep.pushTarget(this)
const value = this.getter.call(this.vm, this.vm)
Dep.popTarget()
return value
}
update(newVal, val) { // 依赖变化的时候被调用(即dep.notify中调用)
if (this.lazy) {
this.dirty = true
} else {
this.cb.call(this.obj, newVal, val) // 调用 cb 函数
}
}
evaluate() { // 手动取值(计算属性会用到)
this.value = this.get()
this.dirty = false
}
addDep(dep) { // 把 dep 实例添加到 deps 数组中,实现双向引用
if (!this.deps.includes(dep)) {
this.deps.push(dep)
dep.addSub(this)
}
}
depend() { // 手动收集依赖(计算属性会用到)
this.deps.forEach((it) => it.depend())
}
}

Observer 响应式缔造者

Observer 类就是 Vue 中实现数据响应式和核心类了,它的流程为:

  • 如果数据是对象类型,就用 Object.defineProperty 拦截其所有属性,在 get 中收集,在 set 中通知
  • 如果对象的属性还是一个对象,那么就递归拦截,确保所有对象的所有属性都被拦截
  • 如果数据是数组类型,就重写 Array.prototype 原型,拦截 pushpopreverse 等方法
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
44
45
46
47
48
49
50
class Observer {
static observe(value) { // 静态方法,用于创建observer实例实现数据响应式
if (typeof value !== 'object' || value == null || value.__ob__) return
return new Observer(value)
}
constructor(value) {
this.value = value
this.dep = new Dep()
Object.defineProperty(value, '__ob__', {
enumerable: false,
configurable: false,
value: this,
})
if (Array.isArray(value)) {
rewriteArrayMethods(value) // 重写数组方法
this.observeArray(value)
} else {
this.walk(value)
}
}
walk(obj) { // 对象响应式
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
this.intercept(obj, keys[i], obj[keys[i]])
}
}
intercept(data, key, val) { // 对象属性拦截
const ob = Observer.observe(val) // 对象递归响应式
const dep = new Dep()
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.depend() // 把 watcher,即 Dep.target 收集到 key 属性对应的 dep 实例中
if (ob) ob.dep.depend() // 把 watcher,即 Dep.target 收集到 observer 内部的 dep 实例中
return val
},
set: function (newVal) {
if (val === newVal) return
const oldVal = val
val = newVal
Observer.observe(newVal)
dep.notify(newVal, oldVal) // 通知观察者
},
})
}
observeArray(arr) {
arr.forEach((it) => Observer.observe(it)) // 数组内部元素响应式
}
}

Observer 类里面用到了下面的函数来重写数组原型上的方法:

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
function rewriteArrayMethods(value) {
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
// 只有这几个方法会改变原数组
const methods = 'push,pop,shift,unshift,reverse,sort,splice'.split(',')
let oldVal = JSON.stringify(value)
methods.forEach((method) => {
arrayMethods[method] = function (...args) {
const result = arrayProto[method].apply(this, args)
let inserted,
ob = value.__ob__
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
}
if (inserted) ob.observeArray(inserted)

const newVal = JSON.stringify(value)
ob.dep.notify(newVal, oldVal) // 通知数组更新
oldVal = newVal
return result
}
})
Object.setPrototypeOf(value, arrayMethods) // 重写数组原型
}

这个类是 Vue 响应式中最核心和最复杂的一个类,响应式数据和 dep 实例之间的关系就是通过 Observer 类建立起来的,一旦对象或数组变成响应式了,就会多出一个 __ob__ 属性,所以 Observer.observe 方法的首行做了处理,只要存在 __ob__ 就不需要对数据进行再次观测了。

Computed 和 watch 的响应式

computedwatch 的底层也是利用了响应式原理,其中 watch 是比较简单的,只要创建一个 Watcher 实例即可:

1
2
3
4
5
6
7
8
9
10
function createWatcher(vm, exprOrFn, handler, options) {
if (typeof handler == 'object') {
options = handler
handler = handler.handler // 是一个函数
}
if (typeof handler == 'string') {
handler = vm[handler] // 将实例的方法作为handler
}
vm.$watch(exprOrFn, handler, options)
}

其实代码就是最后一行,上面几行是为了做兼容处理而已,即兼容下面两种写法:

1
2
3
4
5
6
7
8
9
10
11
12
{
watch: {
name(newVal, val) {
console.log(`name变化了:${val}->${newVal}`)
},
age: {
handler(newVal, val) {
console.log(`age变化了:${val}->${newVal}`)
}
}
}
}

Computed 则稍稍复杂一点,因为它是有缓存的,是惰性求值,所以它对应的 watcher 实例上有 lazydirty 属性,其中 lazy 标识该 watcher 是计算属性的,dirty 标识依赖是否发生过变化,如果没变,下次获取计算属性的时候直接从缓存取值,如果变化了,则重新执行计算属性函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
function defineComputed(vm, key, userDef) {
const watcher = vm._computedWatchers && vm._computedWatchers[key]
Object.defineProperty(vm, key, {
get: function () {
if (watcher) {
if (watcher.dirty) watcher.evaluate() // 只有dirty才会去重新计算
if (Dep.target) watcher.depend() // 手动收集依赖(收集computed里面用到的依赖)
return watcher.value
}
},
set: userDef.set || function () {},
})
}

可以看到,计算属性的 watcher 被放到了 vm._computedWatcher 对象中,键就是计算属性名,值就是用户定义的计算函数,当获取计算属性的时候会执行到 get 方法,内部做了一个判断,只有当 dirty 的时候才执行函数,否则直接取缓存中的值,即 watcher.value

结果验证

到这里,完整的响应式原理就写完了,为了验证结果,我们创建一个 Vue 实例,里面有 datawatchcomputed 属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const vm = new Vue({
data: {
name: 'keliq',
age: 12,
hobbies: ['reading', 'football'],
},
watch: {
name(newVal, val) {
console.log(`name变化了:${val}->${newVal}`)
},
age: {
handler(newVal, val) {
console.log(`age变化了:${val}->${newVal}`)
}
}
},
computed: {
reversedName() {
console.log('-----执行反转函数-----')
return this.name.split('').reverse().join('')
},
},
})

为了演示响应式原理,即数据改变,视图自动更新,我们先定义一个视图模板:

1
2
3
4
5
const template = `<ol>
<li>姓名:{{name}}</li>
<li>年龄:{{age}}</li>
<li>兴趣爱好:{{hobbies}}</li>
</ol>`

不过还需要实现一个模板引擎才行,完整的模板引擎的实现不在本文的内容里面,感兴趣的同学可以看这篇文章,这里用字符串拼接的方式实现最简单的模板引擎,只处理 {{}} 语法:

1
2
3
4
5
6
7
function render(vm) {
let tpl = 'let str="";str+=`'
tpl += template.replace(/{{(.+)}}/g, '`+$1+`')
tpl += '`;return str;'
const f = new Function('vm', `with(vm) {${tpl}};`)
return f(vm, tpl)
}

然后手动观测一下即可:

1
2
3
4
5
vm.$watch(render, (newVal, oldVal) => {
console.log('渲染watcher检测到变化 ${oldVal}->${newVal}`,页面更新')
console.log('最新渲染结果为:')
console.log(render(vm))
})

万事俱备只欠东风,接下来我们开始响应式交互吧,先把 name 改一下试试:

1
vm.name = 'david'

此时输出:

1
2
3
4
5
6
7
8
name变化了:keliq->david
渲染watcher检测到变化 keliq->david,页面更新
最新渲染结果为:
<ol>
<li>姓名:david</li>
<li>年龄:12</li>
<li>兴趣爱好:reading,football</li>
</ol>

可以看到用户自定义的 watcher 和 渲染 watcher 都收到了通知。接下来再更新一下数组试试:

1
vm.hobbies.push('eating')

发现视图也被更新了:

1
2
3
4
5
6
7
渲染watcher检测到变化 ["reading","football"]->["reading","football","eating"],页面更新
最新渲染结果为:
<ol>
<li>姓名:david</li>
<li>年龄:12</li>
<li>兴趣爱好:reading,football,eating</li>
</ol>

最后来看计算属性的惰性求值:

1
2
3
4
5
6
7
console.log(vm.reversedName)
console.log(vm.reversedName)
console.log(vm.reversedName)
vm.name = 'lucy'
console.log(vm.reversedName)
console.log(vm.reversedName)
console.log(vm.reversedName)

我们先调用三次 vm.reversedName 发现只执行了以此反转函数,接下来更新了 name 之后再调用三次,发现反转函数第一次重新执行了,因为此时 dirty=true,后面两次还是用的缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-----执行反转函数-----
divad
divad
divad
name变化了:david->lucy
渲染watcher检测到变化 david->lucy,页面更新
最新渲染结果为:
<ol>
<li>姓名:lucy</li>
<li>年龄:12</li>
<li>兴趣爱好:reading,football,eating</li>
</ol>
-----执行反转函数-----
ycul
ycul
ycul

总结

本文从零完整的实现了 Vue 响应式原理,对尤大大高山仰止,惊叹其如此巧妙的设计,只用了 DepWatcherObserver 三个类就构建出了一个 MVVM 框架。在刚开始读源码的时候,是很懵的,读完之后有种豁然开朗的感觉,建议大家把「三剑客」代码认真阅读几遍,梳理它们之间的关系,例如:

  • depwatcher 之间是一对多还是多对多的关系?
  • data 进行递归响应式的时候,到底创建了几个 dep 实例?
  • 对于给定的 options,总共有多少个 watcher 呢?