深入浅出基于依赖收集的响应式原理


每当问到 VueJS 响应式原理,大家可能都会脱口而出“Vue 通过 Object.defineProperty 方法把 data 对象的全部属性转化成 getter/setter,当属性被访问或修改时通知变化”。然而,其内部深层的响应式原理可能很多人都没有完全理解,网络上关于其响应式原理的文章质量也是参差不齐,大多是贴个代码加段注释了事。本文将会从一个非常简单的例子出发,一步一步分析响应式原理的具体实现思路。

使数据对象变得“可观测”

首先,我们定义一个数据对象,就以王者荣耀里面的其中一个英雄为例子:

1
2
3
4
const hero = {
health: 3000,
IQ: 150,
};

我们定义了这个英雄的生命值为 3000,IQ 为 150。但是现在还不知道他是谁,不过这不重要,只需要知道这个英雄将会贯穿我们整篇文章,而我们的目的就是通过这个英雄的属性,知道这个英雄是谁。

现在我们可以通过 hero.health 和 hero.IQ 直接读写这个英雄对应的属性值。但是,当这个英雄的属性被读取或修改时,我们并不知情。那么应该如何做才能够让英雄主动告诉我们,他的属性被修改了呢?这时候就需要借助 Object.defineProperty 的力量了。

关于 Object.defineProperty 的介绍,MDN 上是这么说的:

1
codeObject.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。

在本文中,我们只使用这个方法使对象变得“可观测”,更多关于这个方法的具体内容,请参考https://developer.mozilla.org...,就不再赘述了。

1
2
3
4
5
6
7
console.log(hero.health);

// -> 3000
// -> 我的health属性被读取了!

hero.health = 5000;
// -> 我的health属性被修改了

可以看到,英雄已经可以主动告诉我们其属性的读写情况了,这也意味着,这个英雄的数据对象已经是“可观测”的了。为了把英雄的所有属性都变得可观测,我们可以想一个办法:

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
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
// 触发getter
console.log(`我的${key}属性被读取了!`);
return val;
},
set(newVal) {
// 触发setter
console.log(`我的${key}属性被修改了!`);
val = newVal;
},
});
}

/**
* 把一个对象的每一项都转化成可观测对象
* @param { Object } obj 对象
*/
function observable(obj) {
const keys = Object.keys(obj);
keys.forEach((key) => {
defineReactive(obj, key, obj[key]);
});
return obj;
}

现在我们可以把英雄这么定义:

1
2
3
4
const hero = observable({
health: 3000,
IQ: 150,
});

读者们可以在控制台自行尝试读写英雄的属性,看看它是不是已经变得可观测的。

计算属性

现在,英雄已经变得可观测,任何的读写操作他都会主动告诉我们,但也仅此而已,我们仍然不知道他是谁。如果我们希望在修改英雄的生命值和 IQ 之后,他能够主动告诉他的其他信息,这应该怎样才能办到呢?假设可以这样:

1
2
3
watcher(hero, "type", () => {
return hero.health > 4000 ? "坦克" : "脆皮";
});

我们定义了一个 watcher 作为“监听器”,它监听了 hero 的 type 属性。这个 type 属性的值取决于 hero.health,换句话来说,当 hero.health 发生变化时,hero.type 也应该发生变化,前者是后者的依赖。我们可以把这个 hero.type 称为“计算属性”。

那么,我们应该怎样才能正确构造这个监听器呢?可以看到,在设想当中,监听器接收三个参数,分别是被监听的对象、被监听的属性以及回调函数,回调函数返回一个该被监听属性的值。顺着这个思路,我们尝试着编写一段代码:

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
/**
* 当计算属性的值被更新时调用
* @param { Any } val 计算属性的值
*/
function onComputedUpdate(val) {
console.log(`我的类型是:${val}`);
}

/**
* 观测者
* @param { Object } obj 被观测对象
* @param { String } key 被观测对象的key
* @param { Function } cb 回调函数,返回“计算属性”的值
*/
function watcher(obj, key, cb) {
Object.defineProperty(obj, key, {
get() {
const val = cb();
onComputedUpdate(val);
return val;
},
set() {
console.error("计算属性无法被赋值!");
},
});
}

现在我们可以把英雄放在监听器里面,尝试跑一下上面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
watcher(hero, "type", () => {
return hero.health > 4000 ? "坦克" : "脆皮";
});

hero.type;

hero.health = 5000;

hero.type;

// -> 我的health属性被读取了!
// -> 我的类型是:脆皮
// -> 我的health属性被修改了!
// -> 我的health属性被读取了!
// -> 我的类型是:坦克

现在看起来没毛病,一切都运行良好,是不是就这样结束了呢?别忘了,我们现在是通过手动读取 hero.type 来获取这个英雄的类型,并不是他主动告诉我们的。如果我们希望让英雄能够在 health 属性被修改后,第一时间主动发起通知,又该怎么做呢?这就涉及到本文的核心知识点——依赖收集。

依赖收集

我们知道,当一个可观测对象的属性被读写时,会触发它的 getter/setter 方法。换个思路,如果我们可以在可观测对象的 getter/setter 里面,去执行监听器里面的 onComputedUpdate()方法,是不是就能够实现让对象主动发出通知的功能呢?

由于监听器内的 onComputedUpdate()方法需要接收回调函数的值作为参数,而可观测对象内并没有这个回调函数,所以我们需要借助一个第三方来帮助我们把监听器和可观测对象连接起来。

这个第三方就做一件事情——收集监听器内的回调函数的值以及 onComputedUpdate()方法。

现在我们把这个第三方命名为“依赖收集器”,一起来看看应该怎么写:

1
2
3
const Dep = {
target: null,
};

就是这么简单。依赖收集器的 target 就是用来存放监听器里面的 onComputedUpdate()方法的。

定义完依赖收集器,我们回到监听器里,看看应该在什么地方把 onComputedUpdate()方法赋值给 Dep.target:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function watcher(obj, key, cb) {
// 定义一个被动触发函数,当这个“被观测对象”的依赖更新时调用
const onDepUpdated = () => {
const val = cb();
onComputedUpdate(val);
};

Object.defineProperty(obj, key, {
get() {
Dep.target = onDepUpdated;
// 执行cb()的过程中会用到Dep.target,
// 当cb()执行完了就重置Dep.target为null
const val = cb();
Dep.target = null;
return val;
},
set() {
console.error("计算属性无法被赋值!");
},
});
}

我们在监听器内部定义了一个新的 onDepUpdated()方法,这个方法很简单,就是把监听器回调函数的值以及 onComputedUpdate()给打包到一块,然后赋值给 Dep.target。这一步非常关键,通过这样的操作,依赖收集器就获得了监听器的回调值以及 onComputedUpdate()方法。作为全局变量,Dep.target 理所当然的能够被可观测对象的 getter/setter 所使用。

重新看一下我们的 watcher 实例:

1
2
3
watcher(hero, "type", () => {
return hero.health > 4000 ? "坦克" : "脆皮";
});

在它的回调函数中,调用了英雄的 health 属性,也就是触发了对应的 getter 函数。理清楚这一点很重要,因为接下来我们需要回到定义可观测对象的 defineReactive()方法当中,对它进行改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function defineReactive(obj, key, val) {
const deps = [];
Object.defineProperty(obj, key, {
get() {
if (Dep.target && deps.indexOf(Dep.target) === -1) {
deps.push(Dep.target);
}
return val;
},
set(newVal) {
val = newVal;
deps.forEach((dep) => {
dep();
});
},
});
}

可以看到,在这个方法里面我们定义了一个空数组 deps,当 getter 被触发的时候,就会往里面添加一个 Dep.target。回到关键知识点 Dep.target 等于监听器的 onComputedUpdate()方法,这个时候可观测对象已经和监听器捆绑到一块。任何时候当可观测对象的 setter 被触发时,就会调用数组中所保存的 Dep.target 方法,也就是自动触发监听器内部的 onComputedUpdate()方法。

至于为什么这里的 deps 是一个数组而不是一个变量,是因为可能同一个属性会被多个计算属性所依赖,也就是存在多个 Dep.target。定义 deps 为数组,若当前属性的 setter 被触发,就可以批量调用多个计算属性的 onComputedUpdate()方法了。
完成了这些步骤,基本上我们整个响应式系统就已经搭建完成,下面贴上完整的代码:

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
/**
* 定义一个“依赖收集器”
*/
const Dep = {
target: null,
};

/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function defineReactive(obj, key, val) {
const deps = [];
Object.defineProperty(obj, key, {
get() {
console.log(`我的${key}属性被读取了!`);
if (Dep.target && deps.indexOf(Dep.target) === -1) {
deps.push(Dep.target);
}
return val;
},
set(newVal) {
console.log(`我的${key}属性被修改了!`);
val = newVal;
deps.forEach((dep) => {
dep();
});
},
});
}

/**
* 把一个对象的每一项都转化成可观测对象
* @param { Object } obj 对象
*/
function observable(obj) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]]);
}
return obj;
}

/**
* 当计算属性的值被更新时调用
* @param { Any } val 计算属性的值
*/
function onComputedUpdate(val) {
console.log(`我的类型是:${val}`);
}

/**
* 观测者
* @param { Object } obj 被观测对象
* @param { String } key 被观测对象的key
* @param { Function } cb 回调函数,返回“计算属性”的值
*/
function watcher(obj, key, cb) {
// 定义一个被动触发函数,当这个“被观测对象”的依赖更新时调用
const onDepUpdated = () => {
const val = cb();
onComputedUpdate(val);
};

Object.defineProperty(obj, key, {
get() {
Dep.target = onDepUpdated;
// 执行cb()的过程中会用到Dep.target,
// 当cb()执行完了就重置Dep.target为null
const val = cb();
Dep.target = null;
return val;
},
set() {
console.error("计算属性无法被赋值!");
},
});
}

const hero = observable({
health: 3000,
IQ: 150,
});

watcher(hero, "type", () => {
return hero.health > 4000 ? "坦克" : "脆皮";
});

console.log(`英雄初始类型:${hero.type}`);

hero.health = 5000;

// -> 我的health属性被读取了!
// -> 英雄初始类型:脆皮
// -> 我的health属性被修改了!
// -> 我的health属性被读取了!
// -> 我的类型是:坦克

上述代码可以直接在 code pen 点击预览或者浏览器控制台上执行。

代码优化

在上面的例子中,依赖收集器只是一个简单的对象,其实在 defineReactive()内部的 deps 数组等和依赖收集有关的功能,都应该集成在 Dep 实例当中,所以我们可以把依赖收集器改写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Dep {
constructor() {
this.deps = [];
}

depend() {
if (Dep.target && this.deps.indexOf(Dep.target) === -1) {
this.deps.push(Dep.target);
}
}

notify() {
this.deps.forEach((dep) => {
dep();
});
}
}

Dep.target = null;

同样的道理,我们对 observable 和 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
class Observable {
constructor(obj) {
return this.walk(obj);
}

walk(obj) {
const keys = Object.keys(obj);
keys.forEach((key) => {
this.defineReactive(obj, key, obj[key]);
});
return obj;
}

defineReactive(obj, key, val) {
const dep = new Dep();
Object.defineProperty(obj, key, {
get() {
dep.depend();
return val;
},
set(newVal) {
val = newVal;
dep.notify();
},
});
}
}
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
class Watcher {
constructor(obj, key, cb, onComputedUpdate) {
this.obj = obj;
this.key = key;
this.cb = cb;
this.onComputedUpdate = onComputedUpdate;
return this.defineComputed();
}

defineComputed() {
const self = this;
const onDepUpdated = () => {
const val = self.cb();
this.onComputedUpdate(val);
};

Object.defineProperty(self.obj, self.key, {
get() {
Dep.target = onDepUpdated;
const val = self.cb();
Dep.target = null;
return val;
},
set() {
console.error("计算属性无法被赋值!");
},
});
}
}

然后我们来跑一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const hero = new Observable({
health: 3000,
IQ: 150,
});

new Watcher(
hero,
"type",
() => {
return hero.health > 4000 ? "坦克" : "脆皮";
},
(val) => {
console.log(`我的类型是:${val}`);
}
);

console.log(`英雄初始类型:${hero.type}`);

hero.health = 5000;

// -> 英雄初始类型:脆皮
// -> 我的类型是:坦克

尾声

看到上述的代码,是不是发现和 VueJS 源码里面的很像?其实 VueJS 的思路和原理也是类似的,只不过它做了更多的事情,但核心还是在这里边。

在学习 VueJS 源码的时候,曾经被响应式原理弄得头昏脑涨,并非一下子就看懂了。后在不断的思考与尝试下,同时参考了许多其他人的思路,才总算把这一块的知识点完全掌握。希望这篇文章对大家有帮助,如果发现有任何错漏的地方,也欢迎向我指出,谢谢大家~

作者

jrainlau

发布于

2017-09-14

更新于

2015-01-01

许可协议

You need to set install_url to use ShareThis. Please set it in _config.yml.
You forgot to set the business or currency_code for Paypal. Please set it in _config.yml.