抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

本文探究了Vue.js中响应式系统的实现,通过介绍副作用函数与响应式数据,利用Proxy实现了一个相对完善的响应系统,同时讨论了分支切换导致冗余副作用的清理问题。

副作用函数

如果我们使用js中的某个函数改变了html标签里的某个内容,由于在这个函数以外的地方都能访问或者设置这个html标签的内容

所以我们说这个函数可能会直接或者间接影响到其他函数的执行,也就是产生了副作用

1
2
3
4
5
const obj = { text: 'nihao' }
// effect函数产生了副作用
function effect() {
document.body.innerText = obj.text
}

响应式数据

还是上面的代码:

1
2
3
4
5
6
7
const obj = { text: 'nihao' }
// effect函数执行会读取obj.text
function effect() {
document.body.innerText = obj.text
}

obj.text = "hello"

effect中我们将body.innerTextobj.text的值绑定在了一起。


朴素做法

我们的所希望的是body.innerText总是显示obj.text,要达到这个效果,只要每次修改obj.text的时候都调用一次effect就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
const obj = { text: 'nihao' }
// effect函数执行会读取obj.text
function effect() {
document.body.innerText = obj.text
}
effect()

obj.text = "hello"
effect()

obj.text = "like"
effect()
...

这未免也有些太麻烦了,我们是否可以让js智能一点?

在每次修改obj的时候调用一下所有会读取obj内容的副作用函数。

这样我们就把obj变成了所谓的响应式数据


基本实现响应式数据

按照前面的说法,我们需要在obj被读取的时候,把这个函数给记录下来,作为obj的副作用函数。

obj被修改的时候,把之前记录下来的关于它的副作用函数再调用一遍。

接下来的关键在于如何拦截obj的读取和修改操作。


Vue.js 3使用ES2015+中的代理对象Proxy来实现这个拦截操作。

Proxy代理对象

根据MDN文档中对Proxy的说明:

语法

1
new Proxy(target, handler)

Proxy会对target(可以是任意类型)进行包装,最后返回一个Proxy对象。

handler: 一个对象,其属性是定义了在对代理执行操作时的行为的函数。

正常情况下我们不能拦截target的一些行为,但我们使用Proxy把它"绑架"之后,返回的一个跟它一模一样的Proxy对象就可以被我们拦截了。


在这里我们只拦截对target读取修改操作。

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
// 存储当前的副作用函数
let activeEffect

// effect用于注册副作用函数
function effect(fn) {
activeEffect = fn
fn()
activeEffect = null
}


// 使用Set来存储副作用函数
const effectFuncs = new Set()

const data = { text: 'nihao' }
// 劫持data
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 记录副作用函数
effectFuncs.add(activeEffect)
// 返回属性
return target[key]
},
// 拦截修改操作
set(target, key, newVal) {
// 先修改
target[key] = newVal
// 再调用所有副作用函数
effectFuncs.forEach(fn => fn())
// 表示设置成功
return true
}
})

// 一个匿名的副作用函数, 用于更新页面
effect(() => {
document.body.innerText = obj.text
})
// 修改响应式数据
setTimeout(() => {
obj.text = 'hello'
}, 2000)

当页面运行的时候第一个副作用函数被调用,把obj.text绑定到body.innerText上,

同时将这个副作用函数放到Set内,以便以后调用。

当时间经过2s后,obj受到修改,同时调用与之相关的副作用函数(也就是前一个绑定obj.textbody.innerText的副作用函数),更新body.innerText的值。


细心的读者可能发现上面的代码有些不合适,我们的劫持操作是对obj整个对象来做的

同时副作用函数也绑定在整个对象上。

如果我们修改obj对象,但是我们修改的是obj别的属性(例如value),似乎也会触发对set()的劫持,把所有绑定到obj上的副作用函数都调用一遍。

我们应该让副作用函数跟对象的属性绑定在一起,如下图的结构:

结构图

effect1effect2会读取obj1.text属性,所以在修改obj1.text属性的时候应该执行这两个函数。

effect1effect3会读取obj1.value属性,所以在修改obj1.value属性的时候应该执行这两个函数。

现在修改一下上面的代码:

只存储涉及副作用函数的对象和属性,并不是追踪所有属性。

下文所有对象、属性都为跟副作用函数的有关的对象和属性

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
let activeEffect
// 存储对象,对象->属性
const bucket = new WeakMap() // 后文解释为什么这里用WeakMap

const obj = new Proxy(data, {
get(target, key) {
// 将当前对象的属性和副作用函数关联
track(target, key);
return target[key];
},

set(target, key, newVal) {
target[key] = newVal;
// 当属性发生变化时,触发所有副作用函数
trigger(target, key);
return true;
}
});

function track(target, key) {
// 不是通过副作用函数读取的属性,直接return
if (!activeEffect) return target[key];
// 获取当前target的属性,depsMap类型为Map,存储属性->副作用函数
let depsMap = bucket.get(target);
// 当前对象还没有属性
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 获取当前属性的副作用函数,类型为Set,存储副作用函数
let deps = depsMap.get(key);
// 当前属性还没有存储副作用函数
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 将当前副作用函数存储进Set中
deps.add(activeEffect);
}

function trigger(target, key) {
// 获得当前对象的属性
const depsMap = bucket.get(target);
// 当前对象不存在涉及副作用函数的属性
if (!depsMap) return;
// 获得当前属性的副作用函数
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn());
}

weakMap

根据MDN文档中对weakMap的说明:

weakMap和Map很相似,但区别在于

  • 不会阻止垃圾回收,直到垃圾回收器移除了键对象的引用
  • 任何值都可以被垃圾回收,只要它们的键对象没有被 WeakMap 以外的地方引用

weakMap不会阻止它的键被垃圾回收,一旦键的生命周期结束,键被垃圾回收了,那么weakMap的键值对也都会被回收。

我们使用weakMap存储对象->属性的原因也在于此。

如果这个响应式数据被销毁了或是生命周期结束了,那么它所存储的那些副作用函数都应该被释放。这样才能避免发生内存泄露


现在让我们来尝试使用一下这个简单的响应式数据。

1
2
3
4
5
6
./index.html
<body>
<div class="div1"></div>
<div class="div2"></div>
<script src="./js/my.js"></script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
./js/my.js
const data = {text: 'nihao', value: 1};
...
const div1 = document.querySelector('.div1');
const div2 = document.querySelector('.div2');

effect(function effect1() {
div1.innerHTML = obj.text;
});

effect(function effect2() {
div2.innerHTML = obj.value;
});

setTimeout(() => {
obj.text = 'hello world';
}, 1000);

setTimeout(() => {
obj.value = 2;
}, 2000);

effect1effect2被注册为副作用函数。

在读取obj.textobj.value的时候被get方法劫持,将对应的副作用函数与obj对应的属性绑定。

1
2
3
4
obj -> text -> effect1
|
|
-> value -> effect2

过了1s之后,修改obj.text的值,被set方法劫持,得到text属性对应的所有副作用函数effect1,重新执行effect1函数,更新页面。

过了2s之后,修改obj.value的值,被set方法劫持,得到value属性对应的所有副作用函数effect2,重新执行effect2函数,更新页面。


优化分支切换

分支切换

也许我们可能会根据对象的某个属性的值来执行不同的操作,如果某个操作分支当中又发生了get操作,那么可能会产生遗留的副作用函数。

1
2
3
4
5
const data = {text: 'hello world', ok: true};
...
effect(function effect1() {
div1.innerHTML = obj.ok ? obj.text : 'not';
})

这里的三元运算就产生了分支操作。

obj.ok的值为真时,读取obj.text的值,触发两次get操作(读取ok一次,读取text一次),所以让effect1这个函数被绑定在oktext属性上。

1
2
3
4
obj -> ok -> effect1
|
|
-> text -> effect1

如果我们修改obj.text的值时,调用effect1函数,更新页面内容。

如果我们修改obj.ok的值时,也会调用effect1函数

但由于ok控制了分支切换,如果它为false,那么div1内容就是固定的not,与obj.text无关了。

但由于obj.text仍然绑定着这个effect1副作用函数,修改obj.text仍然会重新执行effect1函数,尽管这并不会导致页面更新(因为obj.ok此时为false)。

理想情况下我们希望在obj.okfalse的时候解除obj.texteffect1的绑定,因为obj.text已经与这个副作用函数脱钩了。


解决的思路也简单,我们需要在每次副作用函数执行的时候把它自己从所有与之有关的对象属性中删除,称为cleanup操作。

在副作用函数执行的过程中重新把自己添加进相应的对象属性中。

还是上面的例子:

obj.oktrue,读取了obj.okobj.text,所以把副作用函数绑定到了这两个属性上。

接下来修改obj.okfalse,触发set方法,调用effect1函数。

在调用的时候先把effect1从所有属性上脱离绑定,接着运行函数。

触发对obj.ok的读取,触发get方法,把effect1重新注册到obj.ok属性上。

读取到当前obj.okfalse,显示not。(不会触发读取obj.text的操作,因此effect1不会被注册到obj.text上了)


接下来就是实现以上思路了。

我们需要完成两个操作:

  1. 收集effect1与哪些属性绑定的信息
  2. 对绑定了effect1的属性进行cleanup操作

既然要收集信息,所以我们要先创建一个list来用于存放相关的对象属性。

修改一下注册副作用函数时的effect函数与track函数与trigger函数。

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
// 存储当前的副作用函数
let activeEffect

function effect(fn) {
// 清理副作用函数
function cleanup() {
effectFn.deps.forEach(dep => {
dep.delete(effectFn);
});
effectFn.deps.length = 0;
}

const effectFn = () => {
cleanup();
activeEffect = effectFn;
fn();
activeEffect = null;
};
effectFn.deps = [];
effectFn();
}

...
// 收集属性
function track(target, key) {
...
// 收集当前属性包含的副作用函数,放到触发set操作的副作用函数的deps里
activeEffect.deps.push(deps);
}

function trigger(target, key) {
...
// 创建一个新的effectsToRun来使用forEach,否则会在执行副作用函数的时候先cleanup接着再次添加进Set,这样子会进入死循环
const effectsToRun = new Set(effects);
effectsToRun.forEach(fn => fn());
}

感谢您阅读到这里,本文就先写到这里吧。

响应式系统还有更多复杂的内容,例如effect函数嵌套和调度执行,以及computedwatch等属性,这些内容以后再写写吧。

我的理解和分析很大程度上得益于《Vue.js 设计与实现》一书 (作者:霍春阳)。书中对响应系统的实现原理进行了深入的讲解,为我撰写本文提供宝贵的参考。在此我向霍春阳先生表示衷心的感谢。

评论