本文为《Vue.js设计与实现》的笔记。
1. 响应式数据与副作用函数
副作用函数指的是会产生副作用的函数,副作用函数的执行会直接或间接影响其他函数的执行。
let val = 1;
function effect(){
val = 2; // 修改全局变量,产生副作用
}
function fn(){
console.log(val);
}
effect函数修改了val,会影响fn函数的行为。
const obj = {text: '123'};
function effect(){
document.body.innerText = obj.text;
}
effect函数的执行会读取obj.text,我们希望当其值变化时,effect这个副作用函数会自动重新执行,如果能实现这个目标,则obj就是响应式数据。
2. 响应式数据的基本实现
以上的流程涉及两个操作:
- effect执行时,会进行obj.text的读取操作
- 修改obj.text时,会进行设置操作
要完成上述需求,我们便需要劫持数据的读取与设置操作,vue2中使用的是Object.defineProperty,Vue3中则使用proxy来实现。
const bucket = new Set();
let activeEffect;
const data = { text: "hello, world" };
const obj = new Proxy(data, {
get(target, key) {
if (activeEffect) {
bucket.add(activeEffect);
}
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
bucket.forEach((fn) => fn());
return true;
},
});
function effect(fn) {
activeEffect = fn;
fn();
}
effect(() => {
console.log(obj.text);
});
obj.text = "123";
以上代码使用 bucket 来收集副作用函数,当被代理对象执行读取操作时,进行收集,进行设置操作时,执行副作用函数。结果也按设计一般,打印出了新设置的“123”。
hello, world
123
但以上代码仍存在问题,若此时我们执行一下代码
obj.xxx = 'xxx'
该副作用函数同样会被触发执行,但该函数并不依赖xxx这一属性。问题就出在bucket的设计上,任意读取了obj上字段数据的函数均会被收集到,而我们需要的是将副作用函数的收集细分到不同属性上。
以上结构为两个层级:
- 由于会有很多响应式数据(对象),WeakMap由target---》Map构成,不同对象对应不同的Map
- 由于一个对象会有多个属性,Map由key---》Set构成,不同属性(键)对象不同集合,集合中存储的就是键对应的副作用函数集合。
const bucket = new WeakMap();
let activeEffect;
const data = { text: "hello, world" };
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) {
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);
}
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach((fn) => fn());
}
function effect(fn) {
activeEffect = fn;
fn();
}
effect(() => {
console.log(obj.text);
});
obj.text = "123";
obj.xxx = "xxx";
以上代码修改了bucket的结构,将收集副作用函数封装为track,将触发副作用函数封装为trigger。
3. 分支切换与cleanup
const data = { text: "hello, world", ok: true };
const obj = new Proxy(data, ...);
effect(() => {
console.log(obj.ok ? obj.text : "not");
});
代码中存在三元表达式,根据字段obj.ok的值的不同会执行不同的代码分支。 当首次执行时,此时OK的值为true,所以依赖收集的结果会是如下所示:
data
--- ok
--- fn
--- text
--- fn
当我们修改obj.ok=false时,此时副作用函数会重新执行,但此时由于其值为false,所以不会读取obj.text,理想情况下,之后修改obj.text,副作用函数不会执行,但事实上,上边所展现的依赖收集的结果仍然存在,此时修改obj.text,仍会导致副作用函数执行。
解决此问题的思路为,每次副作用函数执行时,先将它从所有与其关联的依赖集合中删除。
按照前面所描述的bucket的结构,我们只能单向地从target找到对应的Map,从key找到对应的Set,Set中可能包含这个副作用函数,将所有的set遍历一遍并不现实,所以我们应该建立一个从副作用函数到包含它的Set的联系。
重新设计effect函数
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
在effect函数中定义一个effectFn函数,并为其添加effectFn.deps属性,该属性时一个数组,用来存储所有包含当前副作用函数的依赖集合。
修改track函数,添加将依赖集合添加至副作用函数的deps的逻辑
function track(target, key) {
// ...
deps.add(activeEffect);
// 新增
activeEffect.deps.push(deps);
}
前面提到的解决思路为,每次副作用函数执行时,先将它从所有与其关联的依赖集合中删除。继续修改effect,进行删除操作:
function effect(fn) {
const effectFn = () => {
cleanup(effectFn); // 删除操作
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
其中cleanup的实现如下:
function cleanup(effectFn) {
// 遍历删除
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
deps.delete(effectFn);
}
effectFn.deps.length = 0; // 清空
}
以上代码已经可以避免副作用函数产生遗留,但实际运行会导致无限循环执行,问题在trigger函数中:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach((fn) => fn()); // 问题所在
}
遍历effects集合执行副作用函数时,会先调用cleanup进行清除,当前执行的副作用函数从集合中删去,但副作用函数执行时又会导致其重新被收集到集合中,这样导致集合中的内容永远无法遍历执行完。
修改trigger函数:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
// 修改
const effectToRun = new Set(effects);
effectToRun && effectToRun.forEach((fn) => fn());
}
上述代码重新构造了一个effectToRun集合用于遍历执行,避免直接遍历effects集合。
4. 嵌套effect与effect栈
考虑如下的effect嵌套情况:
const data = { a: "a", b: "b" };
const obj = new Proxy(data, ...);
effect(() => {
console.log("fn1");
effect(() => {
console.log("fn2");
temp2 = obj.b;
});
temp1 = obj.a;
});
理想情况下,我们希望副作用函数与对象属性的联系如下:
data
--- a
--- fn1
--- b
--- fn2
实际修改obj.a的值,结果为:
fn1
fn2
fn2
分析一下,前两个打印结果分别为fn1和fn2首次执行的结果,而修改a的值,反而导致了fn2的执行。
问题出在activeEffect上,原有的代码在副作用函数执行时将activeEffect赋值为当前的函数,这就导致了内部的函数的执行会覆盖 activaEffect的值,且不会恢复。
为解决这个问题,引入副作用函数栈 effectStack
- 副作用函数执行时,当前副作用函数入栈
- 执行完毕后,将其从栈中弹出
- 始终让activeEffect指向栈顶的副作用函数
修改effect:
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn); // 新增
fn();
effectStack.pop(); // 新增
activeEffect = effectStack[effectStack.length - 1]; // 新增
};
effectFn.deps = [];
effectFn();
}
此时修改obj.a的值,结果为:
fn1
fn2
fn1
fn2
5. 避免无限递归循环
考虑如下代码
const data = { a: 1, b: "b" };
const obj = new Proxy(data, ...);
effect(() => {
obj.a = obj.a + 1;
});
以上代码会引起无限递归循环,分析effect中的代码,可以发现该函数先读取了obj.a的值,又设置了obj.a的值。读取时,触发了track,副作用函数收集,而设置时,又触发了trigger,执行副作用函数,这就导致了该函数无限递归地调用了自己。
解决问题的思路为,在trigger时增加守卫条件,如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
修改trigger:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
// 修改
const effectToRun = new Set();
effects &&
effects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectToRun.add(effectFn);
}
});
effectToRun && effectToRun.forEach((fn) => fn());
}
以上代码修改了effects中的函数放入effectToRun的逻辑,只有非activeEffect的副作用函数才可加入其中并执行。
6. 完整代码
const bucket = new WeakMap();
let activeEffect;
const effectStack = [];
const data = { a: 1, b: "b" };
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) {
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.deps.push(deps);
}
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
// 修改
const effectToRun = new Set();
effects &&
effects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectToRun.add(effectFn);
}
});
effectToRun && effectToRun.forEach((fn) => fn());
}
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn); // 新增
fn();
effectStack.pop(); // 新增
activeEffect = effectStack[effectStack.length - 1]; // 新增
};
effectFn.deps = [];
effectFn();
}
function cleanup(effectFn) {
// 遍历删除
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
deps.delete(effectFn);
}
effectFn.deps.length = 0; // 清空
}
effect(() => {
obj.a = obj.a + 1;
});