Vue3 响应式(2)-分支切换、嵌套、和无限递归

上文Vue3 响应式(1)-基础原理介绍了响应式系统的基本原理并利用 Proxy 进行了简单的实现,能够应对简单的响应式场景。但该系统仍然面临着一些问题,比如当副作用函数中既包含对响应式对象的读取操作也包含设置操作时会使系统进入无限递归的死循环、当代码分支切换时冗余的关联没有被及时清理等问题。本文就来一一介绍这些问题以及解决方法。

分支切换

分支切换问题的描述

对于原有的响应式系统,我们考虑这样一个场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const data = reactive({
isShow: true,
text: 'Hi!'
})

function fn() {
console.log('Debug: update text')
document.body.innerText = data.isShow ? data.text : ''
}

effect(fn)

data.isShow = false
data.text = 'Hello World!'

根据原有的实现,当执行 effect 时会立即调用 fn,由于 data.isShowtrue,因此会读取 data.text 的值,此时代理的 get 拦截器会把 fn 分别与 data.isShowdata.text 关联。因此当执行 data.isShow = false 时,会重新执行 fn,将页面的文本置空。至此都没有任何问题,但是由于此时 data.isShowfalse,副作用函数 fn 并不依赖于 data.text 了,也就是说无论 data.text 的值发生什么变化都不产生任何副作用,无需再次调用 fn 了。但由于开始时已经将 fndata.text 相关联了,所以任何时候设置 data.text 时都会重新调用 fn,即使这样是没有意义的。

对于这种副作用函数中包含分支切换的情况,每次分支切换时所依赖的响应式变量可能发生变化。由于每次调用副作用函数时都会触发 get 拦截器,所以并不担心由于分支切换带来的新增依赖。但对于已经无用的依赖代码中还没有任何清理的操作,这就会导致冗余的副作用函数依然被执行。这样虽然不会对系统产生额外的副作用,但当副作用函数执行开销较大时会对性能产生影响。

分支切换问题的解决方法

对于以上问题,解决方法非常简单,那就是在每次副作用函数执行前把该副作用函数从所有与之相关的依赖集合中移除。然后根据此时的分支情况重新收集副作用函数。例如对于上述例子,第一次收集依赖时,集合 bucket[data].isShow 和集合 bucket[data].text 中都有副作用函数 fn。当分支切换时,首先将 fn 从这两个集合中删除,然后根据 data.isShowfalse,本次执行将只会把 fn 收集到集合 bucket[data].isShow 中。因此data.text 的变化将不会再调用 fn

为了方便的知道副作用函数 fn 都存在于哪些集合中,我们需要对副作用函数增加一个数组属性来保存与之关联的集合,并在 track 中把相关的集合放入副作用函数数组中。然后在每次副作用函数执行对依赖进行清理。具体的修改如下

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
function effect(fn) {
const effectFn = () => {
cleanup(effectFn) // 为了调用 cleanup 需要重新包装 fn
activeEffect = effectFn // 设置为包装后的 effectFn
fn()
}
effectFn.depSets = [] // 保存相关的集合
effectFn()
}

function track(target, key) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
activeEffect.depSets.push(deps) // 记录相关的集合
}

function cleanup (effectFn) {
effectFn.depSets.forEach(deps => {
deps.delete(effectFn)
})
effectFn.depSets.length = 0 // 清理完毕,数组置空
}

但请注意,通过以上修改虽然完成了对冗余依赖的清理,但是运行时发现系统会进入死循环!这是由于在 trigger 中有这么一段代码:

1
2
3
4
function trigger(target, key){
// ...
deps.forEach(fn => fn())
}

在执行该代码时,每次执行 fn 时都会触发 cleanupfn 从集合 deps 中清除,然后又触发 trackfn 又重新收集到集合 deps 中,就相当于执行以下代码造成死循环:

1
2
3
4
5
const s = new Set([1])
for (const x of s) {
s.delete(1)
s.add(1)
}

因此,我们还要对 trigger 做一点修改。只需要把要运行的副作用函数集合做一个“快照”,使得接下来的 cleanup 不影响本次的运行即可:

1
2
3
4
5
6
function trigger(target, key){
// ...
// deps.forEach(fn => fn())
const depsToRun = new Set(deps)
depsToRun.forEach(fn => fn())
}

嵌套的副作用函数

嵌套问题的描述

如果副作用函数发生嵌套会发生什么呢?例如对于下面的代码,我们希望 data.foo 与外层的副作用函数发生绑定,而 data.bar 与内层的副作用函数绑定。但执行 data.foo = false 时发现,并没有触发外部的副作用函数而是仅仅触发了内部的副作用函数,这是怎么回事呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
const data = reactive({
foo: true,
bar: false
})

effect(() => {
effect(() => {
console.log('inner effect', data.bar)
})
console.log('out effect', data.foo)
})

data.foo = false

问题出在 activeEffect 变量的处理上,当开始执行外部的副作用函数时,先把 activeEffect 指向外部函数,然后进入内部的副作用函数,此时 activeEffect 指向内部的函数,当内部的函数执行完后。运行到 console.log('out effect', data foo) 时,track 开始收集依赖,但此时的 activeEffect 指向的是内部的副作用函数,因此将 data.foo 与内部的副作用函数发生了绑定。导致 data.foo 本应该与外部副作用函数进行关联的预期没有实现,也就导致了上面的运行结果。

嵌套问题的解决

对于嵌套的副作用函数导致外层函数丢失的问题,可以通过栈来解决。修改 effect 函数,每当开始执行副作用函数时,把当前的副作用函数入栈,并把 activeEffect 标记为当前函数。执行完毕后,把栈顶副作用函数出栈,并把 activeEffect 指回栈顶的副作用函数 (如果有的话)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const effectStack = []

function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn) // 入栈,防止执行 fn 时使 effectFn 丢失
fn()
effectStack.pop() // 执行完毕,出栈
activeEffect = effectStack[effectStack.length - 1] // 将 activeEffect 指回栈顶
}
effectFn.depSets = [] // 保存相关的集合
effectFn()
}

经过如此修改后,在执行外部副作用函数时会把当前副作用函数入栈,进入内部副作用函数时虽然会把 activeEffect 指向内部副作用函数,但当它执行完毕后,会将 activeEffect 指回栈顶的外部副作用函数。因此可以实现正确的关联。

这里可能会有疑问,对栈的更符合直觉的使用应该是在 effectFn 中将其入栈,然后在消费它的地方 (track 方法处) 直接出栈使用即可。为什么全部放在 effectFn 里手动管理呢?这是因为,一个 effectFn 里可能涉及多个响应式变量及属性,会触发多次 track,在 track 里多次弹出使用会导致绑定错误的副作用函数,以及栈的下溢。

无限递归问题

无限递归问题的描述

考虑这样一个场景,我们可能会在副作用函数中先读取某个响应式变量的值,然后又改变它的值。例如下面的代码,当执行 data.cnt++ (即 data.cnt = data.cnt + 1) 时,首先读取 data.cnt 触发 track 将该函数与 data.cnt 进行关联。然后为 data.cnt 设置新值时,又会触发该副作用函数,继续执行 data.cnt++。导致以上过程无限递归执行。

1
2
3
4
5
6
7
const data = reactive({
cnt: 0
})

effect(() => {
console.log(data.cnt++)
})

再考虑下面的场景同样是在副作用函数中既读值又赋值,为什么不会发生无限递归?

1
2
3
4
5
6
7
8
const data = reactive({
cnt: 0
})

effect(() => {
data.cnt = 0
console.log(data.cnt)
})

这个例子不同的是先赋值后取值。由于副作用函数执行前会先调用 cleanup 清除所有关联,所以当赋值 data.cnt 还没有把副作用函数关联起来,因此不会无限递归。

无限递归问题的解决

对于上面无限递归的例子,其本质是当响应式数据发生变化时,我们希望能够通过某种方法通知所有与之相关的函数,让它们重新执行以达到更新状态的目的。但是我们不需要通知使得数据发生变化的函数本身,因为函数本身能够获得到此时数据的变化。因此解决方法就很明确了,那就是在 trigger 中不要去“通知” activeEffect 自身。

1
2
3
4
5
6
7
8
function trigger(target, value) {
// ...
const depsToRun = new Set(deps)
// depsToRun.forEach(fn => fn())
depsToRun.forEach(fn => {
if (fn !== activeEffect) fn()
})
}

其实这里还有一个小问题,那就是如何保证全局变量 activeEffect 指向的是触发 trigger 的副作用函数本身?其实并不能保证,因为大多数情况下在 triggeractiveEffect 都是 undefined。但是只要能保证在可能发生无限递归的情况下,activeEffect 指向的是触发 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
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
let activeEffect;
const effectStack = [];
const bucket = new WeakMap();

function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key); // 搜集依赖函数
return Reflect.get(target, key);
},
set(target, key, value) {
const res = Reflect.set(target, key, value);
trigger(target, key); // 触发关联的依赖函数
return res;
}
});
}

function track(target, key) {
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
activeEffect.depSets.push(deps); // 记录相关的集合
}

function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) return;
let deps = depsMap.get(key);
if (!deps) return;
const depsToRun = new Set(deps);
// depsToRun.forEach(fn => fn())
depsToRun.forEach((fn) => {
if (fn !== activeEffect) fn();
});
}

function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn); // 入栈,防止执行 fn 时使 effectFn 丢失
fn();
effectStack.pop(); // 执行完毕,出栈
activeEffect = effectStack[effectStack.length - 1]; // 将 activeEffect 指回栈顶
};
effectFn.depSets = []; // 保存相关的集合
effectFn();
}

function cleanup(effectFn) {
effectFn.depSets.forEach((deps) => {
deps.delete(effectFn);
});
effectFn.depSets.length = 0; // 清理完毕,数组置空
}

点击这里在线运行和测试。

小结

至此就基本上实现了一个比较完整的响应式系统,其实在使用 Vue 的过程中我们几乎不会去调用 effect 方法,我们只是声明响应式变量,并把它应用到模板中。Vue 会解析模板来通过 effect 绑定数据与渲染函数。

下一章将介绍如何在已有的响应式系统上实现 computed 方法。

另外,对于第二个问题嵌套的副作用函数,仍然有一个问题。那就是由于内部的副作用函数是由闭包创建的,在依赖集合中无法去重。这样导致每次执行一次外部函数都会创建一个新的内部副作用函数并将其与依赖的变量绑定。当该变量更新时,会把所有绑定的副作用函数都执行一遍,虽然它们的效果是一样的。

参考资料

[1] vuejs/core: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.[EB/OL]. [2023-09-22]. https://github.com/vuejs/core.

[2] Vue.js - 渐进式 JavaScript 框架 | Vue.js[EB/OL]. [2023-09-22]. https://cn.vuejs.org/.

[3] 霍春阳. Vue.js设计与实现[M/OL]. 人民邮电出版社, 2022[2023-09-19]. https://book.douban.com/subject/35768338/.

[4] JavaScript | MDN[EB/OL]. 2023-04-10. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript.