现代前端框架都引入了数据的响应式系统:模型层(Model)只是普通的 JavaScript 对象,修改它则自动更新视图(View),这让状态管理简单而直观。 Vue、Meteor Tracker、Mobx 中的数据响应式系统原理基本相同,本文将对其进行解析,并从零开始一步步实现一个与框架解耦的数据响应式系统。
注: 原始链接: https://www.404forest.com/2017/06/28/modern-web-development-tech-analysis-data-reactivity-system/ 文章备份: https://github.com/jin5354/404forest/issues/60 本文内容所对应的 Vue2.0 源码: https://github.com/vuejs/vue/tree/dev/src/core/observer 本文构建的数据监测库(附注释和 100% 测试):https://github.com/jin5354/leaf-observable
1. 何为数据响应式系统 1.1 抛砖引玉 先看一个 Vue 官网上再熟悉不过的双向绑定示例:
这个示例展现了 model – view 数据双向绑定的便利。先思考一下,这两个方向的绑定要如何实现?
input
数据 view –> model 绑定,我们可以通过事件响应来做到:
1
2
3
4
5
6
7
let data = {
message : 'Hello!'
}
let $input = document .querySelector('input' )
$input.addEventListener('change' , (e) => {
data = e.target.value
})
凭借浏览器内建的 DOM event,我们得以在 input 数据变化时及时得到通知并改变 model。然而 model –> view 绑定似乎并没有方便的手段,若想保证 model 改变时 view 跟随改变,我们只能在每次执行 model 变化操作的地方,手动执行 render 函数重新渲染 view。
如果能有类似事件响应的方便 API 就好了!就像这样:
1
2
3
4
5
let data = {
message : 'Hello!'
}
watch(data, render)
data.message = 'New Message!'
如果数据的变动能自动发出通知并得到响应,我们就称其为响应式的数据。接下来我们从实现一个基本的 watch 函数开始,逐步构建一个数据响应式系统。
1.2 ES6 前,数据追踪的基础设施:Object.defineProperty JavaScript 中对象的每个属性都拥有一个属性描述对象 ,用来定义该属性的行为。Object.defineProperty
是 ES5.1 规范中提供的方法,用来修改对象属性的属性描述对象,文档可参见 MDN-Object.defineProperty() 。通过 Object.defineProperty
函数,我们可以通过定义对象属性的存取器(getter/setter)来劫持数据的读取,实现数据变动时的通知功能。
例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let o = {
a : 1
}
let value = o['a' ]
Object .defineProperty(o, 'a' , {
enumerable : true ,
configurable : true ,
get() {
console .log('a 属性被访问到了!' )
return value
},
set(newValue) {
console .log('a 属性被修改了,新值为:' , newValue, ',旧值为:' , value)
value = newValue
}
})
o.a
o.a = 2
getter/setter 劫持了数据的读写操作,使我们可以在数据读写时得到通知并执行自定义的操作,构成了数据响应式系统的基石。Object.defineProperty 方法不支持 IE9 以下浏览器且无法被 polyfill,这也是 Vue 等框架不支持 IE9 以下浏览器的原因。
2. 数据的响应化 Object.defineProperty 方法一次只能定义一个键值的属性描述对象。我们可以很轻松的写出针对一个键值的监测函数:
查看在线示例
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
let o = {
a : 1
}
function watch (obj, key, callback ) {
let value = obj[key]
Object .defineProperty(obj, key, {
enumerable : true ,
configurable : true ,
get() {
return value
},
set(newValue) {
if (newValue === value) {
return
}else {
let oldValue = value
value = newValue
callback(newValue, oldValue)
}
}
})
}
watch(o, 'a' , (newValue, oldValue) => {
console .log('属性被修改了,新值为:' , newValue, ',旧值为:' , oldValue)
})
o.a = 2
o.a = 3
2.1 递归遍历整个对象 为了让整个对象响应化,我们需要遍历对象中的所有键并为其应用 Object.defineProperty 方法。对于键值为对象的情况,递归进去处理。同时,为了让 『使数据响应化』和『添加回调函数』两个操作解耦,我们引入 Dep 和 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
class Dep {
constructor () {
this .subs = []
}
addSub(sub) {
this .subs.push(sub)
}
notify(newValue, oldValue) {
this .subs.forEach(sub => {
sub.update(newValue, oldValue)
})
}
}
const globalDep = new Dep()
class Watcher {
constructor (callback) {
this .callback = callback
}
update(newValue, oldValue) {
this .callback(newValue, oldValue)
}
}
function observify (value ) {
if (!isObject(value)) {
return
}
Object .keys(value).forEach((key ) => {
defineReactive(value, key, value[key])
})
}
function isObject (value ) {
return typeof value === 'object' && value !== null
}
function defineReactive (obj, key, value ) {
observify(value)
Object .defineProperty(obj, key, {
enumerable : true ,
configurable : true ,
get() {
return value
},
set : (newValue ) => {
if (newValue === value) {
return
}else {
let oldValue = value
value = newValue
observify(newValue)
globalDep.notify(newValue, oldValue)
}
}
})
}
let o = {
a : 1 ,
c : {
d : 1
}
}
observify(o)
globalDep.addSub(new Watcher((newValue, oldValue ) => {
console .log('发生改变!新值:' , newValue, ",旧值:" , oldValue)
}))
o.a = 2
o.c.d = 4
globalDep.addSub(new Watcher((newValue, oldValue ) => {
console .log('新订阅' )
}))
o.a = 3
2.2 数组的特殊响应化处理 我们已经清楚:变动能够通知全是凭借 setter 的能力,在数据被修改时执行自定义操作。然而我们操作数组时往往使用 push
、pop
、shift
等函数来操作——很遗憾,调用这些函数修改数组内容并不会触发 setter。
为了能监测到这些函数操作带来的变动,我们需要『偷梁换柱』:用自制的变异函数替代这些原生函数。首先使用 Object.create
创建一个原型为 Array.prototype
的对象,拿到 Array 实例方法;然后在该对象上定义同名的变异函数,shadow 掉原生函数;随后将要响应化的数组的原型指向该对象,欺骗数组使用变异函数。
查看在线示例
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
import _ from 'lodash'
// 数组响应化
function observifyArray(arr) {
const aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'] //需要变异的函数名列表
let arrayAugmentations = Object.create(Array.prototype) // 创建一个 __proto__ 到 Array.prototype 的 arrayAugmentations 对象
aryMethods.forEach(method => { // 在 arrayAugmentations 对象上将需要变异的函数重写
arrayAugmentations[method] = function(...arg) {
Array.prototype[method].apply(this, arg) // 执行默认操作
globalDep.notify() // 重写后的函数会先执行默认操作,随后通知 Dep
}
})
Object.setPrototypeOf(arr, arrayAugmentations) // 将要监测的数组的原型对象设置为 arrayAugmentations 对象,这样执行 push 等方法时就会执行我们替换后的变异方法啦
}
// 修改上文中的 observify 函数,加入 observifyArray
function observify(value) {
if(!isObject(value)) {
return
}
if(Array.isArray(value)) { // 由于性能问题,我们不再对数组的每个 key 执行 Object.defineReactive
observifyArray(value)
for(let i = 0; i < value.length; i++) {
observify(value[i])
}
}else {
Object.keys(value).forEach((key) => {
defineReactive(value, key, value[key]) // 遍历每个键使其响应化
})
}
}
... 其他部分引用 #2.1 示例
let o = {
a: [1, 2, 3]
}
observify(o)
globalDep.addSub(new Watcher((newValue, oldValue) => {
console.log('发生改变!新值:', newValue, ",旧值:", oldValue)
}))
o.a.push(4) // 输出:发生改变!新值: (4) [1, 2, 3, 4] ,旧值: (3) [1, 2, 3]
3. 精准依赖收集 回顾一下我们的数据响应式系统,现在我们有 observify 方法使一个对象(还有难伺候的数组!)响应化,有个全局 Dep 类存放多个订阅,还有 Watcher 生成订阅源。将对象响应化之后,我们可以生成订阅,添加订阅,追加订阅。每当对象被修改,Dep 就会收到通知,然后通知所有订阅源都执行回调。
考虑生产环境中一个常见的场景:页面模板中只引用了 o.a
和 o.b
,如 <p>{o.a + o.b}</p>
。我们期望数据变化时,自动执行 render
函数重新渲染页面,然而当 o.c
等无关数据发生变化时,也会触发订阅源的更新,这就造成了性能的浪费。如何让我们的监测更智能一些,只在『用到的数据』变化时,才触发订阅源的更新呢?这时 getter
就派上用武之地了。
<p>{a + b}</p>
模板引擎在编译时会被解释为 render 函数。本文不会涉及编译相关内容,我们可以简单的理解为,该段模板最终会编译为:
1
2
3
function render ( ) {
return '<p>' + (o.a + o.b) + '</p>'
}
在页面的初始化阶段,render 函数会执行一次,得到初始结果。函数执行时,所依赖的数据一定会被访问,并触发其 getter,未依赖的数据则不会触发 getter。我们可以在 getter 过程中标记当前的键,即『依赖收集』,仅在被标记的键修改时,才去触发订阅的更新。
由于现在的订阅颗粒度由数据整体细化到了单独的键,所以一个全局 Dep 是不够用的。我们需要为每个键都维护一个 Dep。
在键的 getter 触发时将当前 watcher 加入 dep 中,完成依赖收集。setter 触发时通知当前键的 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
let uid = 1
class Dep {
constructor () {
this .id = uid++
this .subs = []
}
addSub(sub) {
this .subs.push(sub)
}
depend() {
Dep.target && Dep.target.addDep(this )
}
notify() {
this .subs.forEach(sub => {
sub.update()
})
}
}
Dep.target = null
class Watcher {
constructor (expFn, cb, options = {}) {
this .context = options.context
this .expFn = expFn
this .depIds = new Set ()
this .cb = cb
this .value = this .subAndGetValue()
this .clonedOldValue = _.cloneDeep(this .value)
}
update() {
let value = this .subAndGetValue()
if (!_.isEqual(value, this .clonedOldValue)) {
this .value = value
this .cb.call(this .context, value, this .clonedOldValue)
this .clonedOldValue = _.cloneDeep(value)
}
}
subAndGetValue() {
Dep.target = this
let value = this .expFn.call(this .context)
Dep.target = null
return value
}
addDep(dep) {
if (!this .depIds.has(dep.id)) {
this .depIds.add(dep.id)
dep.addSub(this )
}
}
}
function watch (expFn, cb, context ) {
return new Watcher(expFn, cb, context)
}
function defineReactive (obj, key, value ) {
let dep = new Dep()
observify(value)
Object .defineProperty(obj, key, {
enumerable : true ,
configurable : true ,
get() {
if (Dep.target) {
dep.depend()
}
return value
},
set : (newValue ) => {
if (newValue === value) {
return
}else {
value = newValue
observify(newValue)
dep.notify()
}
}
})
}
... 其他部分不变
let o = {
a : 1 ,
b : 2 ,
c : {
d : 3
}
}
observify(o)
watch(() => {
return o.a + o.b
}, (newValue, oldValue) => {
console .log('更新!新值为:' , newValue, ',旧值为:' , oldValue)
})
o.b = 3
o.c.d = 4
由于不存在全局 dep,且向 dep 中注册订阅的操作在 getter 中依赖收集阶段自动执行了,所以我们不再需要手动操作 dep。现在我们的数据响应式系统只需要暴露两个 api:observify
和 watch
就够了。watch
中接收两个函数,第一个 expFn 函数根据依赖返回需要的结果;第二个函数则是第一个函数结果的回调。
我们的响应式系统已经可以实现 Vue 中的 watch 和 computed 功能:watch 某个键值的变动,如 o.a ,实际就是 expFn 函数返回该键的值;而计算属性就是将计算属性的函数体作为 expFn,回调中赋值即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
watch(() => {
return o.a
}, (newValue, oldValue) => {
console .log('更新!新值为:' , newValue, ',旧值为:' , oldValue)
})
watch(() => {
return o.a + o.b
}, (newValue, oldValue) => {
something = newValue
})
watch(() => {
return '<p>' + (o.a + o.b) + '</p>'
}, (newValue) => {
document .getElementById('app' ).innerHTML = newValue
})
现在我们来看 Vue 文档中响应式系统的示意图,就非常好理解了。
4. 异步更新队列 考虑这种情况:我们拥有形如 <p>{o.a + o.b + o.c.d}</p>
的模板,它会被编译为拥有 3 个依赖的 render 函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let o = {
a : 1 ,
b : 2 ,
c : {
d : 3
}
}
observify(o)
watch(() => {
return o.a + o.b + o.c.d
}, (newValue, oldValue) => {
...更新 DOM 的操作...
console.log('更新DOM!新值为:' , newValue, ',旧值为:' , oldValue)
})
o.a = 2
o.a = 3
o.a = 4
这段代码执行了三次 render 函数。实际上我们只需要执行最后一次的 render 即可得到结果,前两次的 render 可以丢弃。我们可以开启一个队列 ,将一个事件循环 内的全部数据变动缓冲在其中,并对 watcher 做去重操作。在下一个 tick 中我们再将队列中的 watcher 依次取出并执行更新。这对避免不必要的计算和 DOM 操作非常重要。
继续改进我们的系统:默认情况下数据变动会缓冲,推入队列;同时为 watcher 函数的配置项中增加 immediate
字段,显式标记为 true
的 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
const supportMO = typeof MutationObserver !== 'undefined'
function nextTick (task ) {
return (( ) => {
if (supportMO) {
let counter = 1
const observer = new MutationObserver(task)
const textNode = document .createTextNode(String (counter))
observer.observe(textNode, {characterData : true })
textNode.textContent = 2
}else {
return Promise .resolve().then(task)
}
})()
}
const queue = []
let watcherIds = {}
let waiting = false
function flushSchedulerQueue ( ) {
queue.forEach(watcher => {
watcher.run()
})
queue.length = 0
watcherIds = {}
waiting = false
}
function queueWatcher (watcher ) {
const uid = watcher.uid
if (!watcherIds[uid]) {
watcherIds[uid] = true
queue.push(watcher)
if (!waiting) {
waiting = true
nextTick(flushSchedulerQueue)
}
}
}
let watcherUid = 1
class Watcher {
constructor (expFn, cb, options = {}) {
this .uid = watcherUid++
this .context = options.context
this .immediate = options.immediate
this .expFn = expFn
this .depIds = new Set ()
this .cb = cb
this .value = this .subAndGetValue()
this .clonedOldValue = _.cloneDeep(this .value)
}
* [update 更新,根据 immediate 参数判断是否立刻执行回调]
*/
update() {
if (this .immediate) {
this .run()
}else {
queueWatcher(this )
}
}
* [run 执行回调]
*/
run() {
let value = this .subAndGetValue()
if (!_.isEqual(value, this .clonedOldValue)) {
this .value = value
this .cb.call(this .context, value, this .clonedOldValue)
this .clonedOldValue = _.cloneDeep(value)
}
}
}
...其他部分相同
let o = {
a : 1 ,
b : 2 ,
c : {
d : 3
}
}
observify(o)
watch(() => {
return o.a + o.b
}, (newValue, oldValue) => {
console .log('更新!新值为:' , newValue, ',旧值为:' , oldValue)
})
watch(() => {
return o.b + o.c.d
}, (newValue, oldValue) => {
console .log('更新!新值为:' , newValue, ',旧值为:' , oldValue)
}, {
immediate : true
})
o.b = 3
o.b = 4
o.a = 2
o.a = 3
o.a = 4
5. 缺陷与补充 5.1 key 的添加与删除 由于 watcher 在 getter 注册的 dep 只能被 setter 操作所触发更新,所以不触发 setter 操作的修改行为就无法触发订阅更新,比如对象键值的添加与删除,以及数组的变异方法(上文中的变异方法通知的是全局 Dep,使用依赖收集后的 get/set Dep 无法被变异方法访问到)。
由于只有对象和数组存在这些问题,所以 Vue 除了每个 key 的 dep 外,还为每个 key 对应值为对象/数组创建了另一个 dep,挂在该对象和数组的 __ob__ 属性上。在依赖收集阶段,watcher 将被同时注册在这两个 dep 上,准备接收响应。setter 操作,通知 set/get 中的 dep;非 setter 操作,如对象 key 添加删除、数组变异方法调用,通知 __ob__ 中的 dep。
添加函数 set
与删除函数 del
的代码不再赘述,详见 leaf-observable 内 observable.js
的相关代码 。
5.2 深度数据追踪 考虑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let o = {
a : { b : { c: { d: { e: 1 }}}}}
observify (o)
watch (() => {
return o.a .b .c
}, (newValue, oldValue) => {
console.log('更新!新值为:' , newValue, ',旧值为:' , oldValue)
})
o.a .b = {
c: {
d: {
e: 2
}
}
}
o.a .b .c .d .e = 3
依赖收集过程中访问 o.a.b.c 时,依次触发了 o.a、o.a.b、o.a.b.c 的 getter,将 watcher 注册进对应的 dep,所以 o.a.b.c 的上游变化同样可以监测到;但是修改 c 内部的属性时便无法得到通知。(对于复杂类型值的 setter,只监测引用变化,不监测内部变化)
我们可以增加一个 deep watch 功能。原理很简单:当发现依赖目标为一个对象时,递归进去遍历每一个子属性,主动触发一下 getter 即可。
具体代码不再赘述,可参见 leaf-observable 内 watcher.js
中 deep watch
的相关代码 。
6. Proxy:过去未去,未来已来
ES6 为 JavaScript 增加了几种新的元编程形式,其中最明显的特性即为代理(Proxy)。一个 Proxy 是一种特殊对象,包在另一个普通对象前面,你可以在代理对象上注册特殊的处理器,当对这个代理实施各种操作时被调用。这些处理器除了将操作传送 到原本的目标/被包装的对象上之外,还有机会运行额外的逻辑。
代理能够观察到的处理器(一部分):
处理器
触发时机
get(..)
在代理上访问一个属性(Reflect.get(..),.属性操作符或[ .. ]属性操作符)
set(..)
在代理对象上设置一个属性(Reflect.set(..),=赋值操作符,或者解构赋值 —— 如果目标是一个对象属性的话)
deleteProperty(..)
在代理对象上删除一个属性 (Reflect.deleteProperty(..)或delete)
apply(..)
代理作为一个普通函数/方法被调用(Reflect.apply(..),call(..),apply(..),或者(..)调用操作符)
…
…
Proxy 不但可以取代 Object.defineProperty
并且还扩增了非常多的功能。Proxy 技术支持监测数组的 push 等方法操作,支持对象属性的动态添加和删除,极大的简化了响应化的代码量。
当前 Proxy 的不足也很明显:不支持 Polyfill,浏览器兼容性非常差(IE 全系列不支持,iOS 10+ 支持,Android 5+ 支持),且因为是『代理』而非劫持,不能直接操作原对象,只能操作代理对象。但是相信,随着浏览器支持逐步跟上,强大的 Proxy 一定会成为下一代数据监测的首选方案。
几个由 Proxy 驱动的数据监测库,推荐阅读源码:
7. 参考资料
JavaScript 标准参考教程 - Object.defineProperty()
vue 源码分析之如何实现 observer 和 watcher
vue早期源码学习系列
Vue源码详细解析(一)–数据的响应化
深入响应式原理
剖析Vue实现原理 - 如何实现双向绑定mvvm
Vue源码注释版
Mobx 思想的实现原理,及与 Redux 对比
从零开始用 proxy 实现 Mobx
使用 ES6 Proxy 实现数据绑定
Can I use Proxy?
leaf-observable