响应系统的作用与实现-2

回顾上篇文章 响应系统的作用与实现-1,完成的代码:

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
function track(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let effectFns = depsMap.get(key);
if (!effectFns) {
depsMap.set(key, (effectFns = new Set()));
}
effectFns.add(activeEffect);
}

function trigger(target, key) {
const depsMap = bucket.get(target);
if (depsMap) {
const effectFns = depsMap.get(key);
effectFns && effectFns.forEach((fn) => fn());
}
}

let activeEffect = null;
const bucket = new WeakMap();
function effect(fn) {
activeEffect = fn;
fn();
}
const proxy = new Proxy(obj, {
get(target, key) {
if (!activeEffect) {
return target[key];
}
track(target, key);
return target[key];
},
set(target, key, newValue) {
target[key] = newValue;
trigger(target, key);
return true;
},
});

再稍微封装一下成 reactive 方便后面创建响应式数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
if (!activeEffect) {
return target[key];
}
track(target, key);
return target[key];
},
set(target, key, newValue) {
target[key] = newValue;
trigger(target, key);
return true;
},
});
}

我们的响应式系统还存在很多缺陷,比如如何合理触发副作用,触发的频率,嵌套副作用,解决无限循坏等问题,本篇文章继续完善…

合理触发响应

vue 框架对于响应数据何时更新的处理很巧妙和细致,如果对 vue2 熟悉的人可能会发现一些细节,用下面代码举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div>
<div v-if="flag">
{{msg}}
</div>
<div else>
hello vue
</div>
</div>
</template>
<script>
...
data() {
return {
flag: true,
msg: 'hello world'
}
}
</script>

此时,flagtrue,页面显示 msg 的值 hello world。 若修改 msg 的值我们都知道视图会因此重新渲染。

1
this.msg = 'ok'; // 重新渲染,页面显示 ok

然后将 flag 设置为 false ,视图也将重新渲染显示 hello vue ,那么如果再次修改 msg 的值,视图会因此更新吗?

1
2
3
this.flag = false; // 重新渲染,页面显示 hello vue
...
this.msg = 'hi'; // 视图会因此更新?

按直觉来说,当 flag 修改成 false 时,视图将走到 else 分支显示 hello vue,此时视图已经不需要 msg 了,理论上 msg 的变化不影响视图。
答案确实如此,不会因此更新,因为视图重新渲染前会先清除所有依赖(cleanupDeps),然后再收集有效依赖,减少不必要的代码执行。

回到我们的响应式系统,该如何做到这点呢?也就是下面代码:

1
2
3
4
5
6
7
8
9
const obj = {
flag: true,
msg: 'hello world',
};
const proxy = reactive(obj);

effect(function effectFn() {
console.log(proxy.flag ? proxy.msg : 'hello vue');
});

代码的执行情况希望是这样的:

  • flagtrue , 改变 msgeffectFn 重新执行
  • flag 改变时, effectFn 重新执行
  • flagfalse
    • 改变 msgeffectFn 不执行

以现在的代码不能达到这样的效果。

我们可以先分析一下,现在代码执行后会发生什么,当第一次 effect 执行时, 首先会读取 flagflagtrue , 然后读取 msg ,此时 '桶' 的数据关系是这样的:

bucket 数据关系1

从图中可以看出,当 flag 或者 msg 发生变化的时候对应的 effectFn 都会执行,当 flagfalse

1
proxy.flag = false;

此时读取常量 hello vue,关系还是如 bucket 数据关系1 所示。也就是说 当 flag 为 false 时, msg 发生变化对应的 effectFn 还是会执行。这明显不符合我们预期,所以理想的情况下, 当 flagfalse 时, effectFn 不应该被 msg 收集,如图所示:

bucket 数据关系2

正确的思路是当 flagfalse 时,在 effectFn 执行之前将所关联的依赖清除掉如 bucket 数据关系3 图所示,,执行后再重新收集相关的依赖

bucket 数据关系3

有关依赖的概念: 为了方便理解,可以将依赖理解成副作用函数对应的一系列 Set 集合

先看看如何收集与 effectFn 相关依赖,回到 effect 函数并且改造:

1
2
3
4
5
6
7
8
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}

创建 effectFn 函数,将注册副作用函数和执行副作用函数放在里面,在 effectFn 上添加静态属性 deps ,当外面执行 effectFn 时,那么 activeEffect 会自带 deps 属性,接着改造 track

1
2
3
4
5
6
7
8
9
10
11
12
function track(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let effectFns = depsMap.get(key);
if (!effectFns) {
depsMap.set(key, (effectFns = new Set()));
}
effectFns.add(activeEffect);
activeEffect.deps.push(effectFns); // 此步可以收集副作用函数里面的所有依赖
}

经过上面改造已经收集到了与 effectFn 相关依赖,如图 bucket 数据关系4 所示

bucket 数据关系4

再按思路,effectFn 执行之前将所关联的依赖清除掉

1
2
3
4
5
6
7
8
9
10
11
12
13
function effect(fn) {
const effectFn = () => {
for (let i = 0; i < effectFn.deps.length; i++) {
const depsSet = effectFn.deps[i]; // 取出副作用函数所关联的依赖集合
depsSet.delete(effectFn); // 切断该副作用函数所有依赖
}
effectFn.deps = [];
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}

这样我们就完成了依赖的清除,把代码提出来封装一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const depsSet = effectFn.deps[i]; // 取出副作用函数所关联的依赖集合
depsSet.delete(effectFn); // 切断该副作用函数所有依赖
}
effectFn.deps = [];
}
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}

但是试着 将 flag 改为 false 发现代码进入死循坏

1
proxy.flag = false; // effectFn 死循坏

这是什么原因?先看看下面的例子:

1
2
3
4
5
6
const seen = new Set([1]);
seen.forEach((v) => {
seen.delete(1);
seen.add(1);
console.log('代码运行中');
});

将上面的例子贴到浏览器控制台运行发现代码死循坏了,怎么做才能解决这个问题呢?答案是新建一个 Set ,如:

1
2
3
4
5
6
7
const seen = new Set([1]);
const seenTorun = new Set(seen);
seenTorun.forEach((v) => {
seen.delete(1);
seen.add(1);
console.log('代码运行中');
});

接着分析我们的代码,可以发现副作用函数的执行行为和上面例子是一样的,在 trigger 中:

1
2
3
4
5
6
7
function trigger(target, key) {
const depsMap = bucket.get(target);
if (depsMap) {
const effectFns = depsMap.get(key);
effectFns && effectFns.forEach((fn) => fn()); // 此代码有问题
}
}

因为 trigger 中的 effectFns 是一个副作用函数的 Set 集合, 而当 foreEach 执行的时候会触发 effect 函数中的 effectFn ,执行 cleanup 清除依赖,而之后再执行副作用函数重新收集依赖,这样就造成了死循环,为了方便理解过程如图所示:

cleanup

所以我们需要改造 trigger 函数,新建一个新的 Set 存放:

1
2
3
4
5
6
7
8
function trigger(target, key) {
const depsMap = bucket.get(target);
if (depsMap) {
const effectFns = depsMap.get(key);
const effectFnsToRun = new Set(effectFns);
effectFnsToRun && effectFnsToRun.forEach((fn) => fn());
}
}

这样就成功清除了遗留副作用,合理的触发响应。

effect 嵌 effect 问题

既然我们的响应式系统是基于 vue 的, vue 中的组件是可以嵌套使用的,那么我们就不得不讨论嵌套问题。

1
2
3
4
5
6
effect(function () {
/*...*/
effect(function () {
/*...*/
});
});

也先稍微提一下 effectvue render 怎么联系,举个简单渲染组件的例子:

Foo 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
const Foo = {
render() {
return; /*...*/
},
};
const Bar = {
render() {
return; /*...*/
},
};
effect(() => {
Foo.render();
});

假设 Foo 内部数据已经是响应式数据,当数据发生变化时 Foo.render 将会重新执行,这和我们之前学习的一样。让 FooBar 嵌套, Foo 渲染 Bar 组件:

1
2
3
4
5
6
effect(() => {
Foo.render();
effect(() => {
Bar.render();
});
});

上面的例子说明了为什么要将 effect 设计成可嵌套的。那我们现在的代码算不算支持嵌套呢?又怎么算是嵌套的合理设计?
我们用现在的代码用下面例子测试一下会发生什么:

1
2
3
4
5
6
7
8
9
10
11
const data = reactive({ foo: true, bar: true });
let temp1, temp2;
effect(function effectFn1() {
console.log('effectFn1 执行');

effect(function effectFn2() {
console.log('effectFn2 执行');
temp2 = data.bar; // 在 effectFn2 读取 bar
});
temp1 = data.foo; // 在 effectFn1 读取 foo
});

当上面代码执行后打印:

1
2
effectFn1 执行
effectFn2 执行

这样看起来好像没问题,但是看看改变 foo 会发生了什么:

1
data.foo = false;

结果

1
effectFn2 执行

思考一下这个结果对不对,先分析一下:因为 foo 是被 effectFn1 收集,理论上当 foo 发生改变时将触发 effectFn1effectFn2effectFn1 里面,也会间接触发 effectFn2,所以这个结果对于响应系统来说是不合理的

那么这是为什么?原因出现在 effectactiveEffect 上:

1
2
3
4
5
6
7
8
9
10
let activeEffect = null;
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}

可以看得出来全局变量 activeEffect 在同一时刻只能存储一个副作用函数,观察上面嵌套代码,当 effect 发生嵌套时,内部 effect 的执行会覆盖 activeEffect , 当全部执行完, activeEffect 永远都取不到原先的副作用函数,这就是问题所在。

为了解决这个问题,我们引入一个 activeEffect 的栈 effectStack ,与函数的调用保持一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let activeEffect = null;
const effectStack = [];
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
effectStack.push(effectFn);
activeEffect = effectFn;
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1]; // 取栈顶
};
effectFn.deps = [];
effectFn();
}

利用 effectStack 来模拟栈, activeEffect 不变,不同的是,当副作用执行的时候当前函数会压入栈,将 activeEffect 指向栈顶 。这样当发生嵌套的时候,以上面的嵌套为例,栈底是外层副作用函数 effectFn1 ,而栈顶是内层副作用函数 effectFn2 ,栈顶函数 effectFn2 先执行,读取 data.bar ,完之后弹出, effectFn1 成为栈顶, 并将刷新 activeEffect 指向,如图所示,此时代码执行到 data.foo 读取, effectFn1 可以正常收集其依赖。

efeffectStack

现在再试试改变 foo 会发生了什么:

1
data.foo = false;

结果在预期之中:

1
2
effectFn1 执行
effectFn2 执行

参考文献: Vue.js设计与实现 - 霍春阳

作者

wuxunyu

发布于

2022-07-08

更新于

2022-07-11

许可协议