回顾上篇文章 响应系统的作用与实现-1,完成的代码:
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 方便后面创建响应式数据:
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 熟悉的人可能会发现一些细节,用下面代码举例:
<template>
<div>
<div v-if="flag">
{{msg}}
</div>
<div else>
hello vue
</div>
</div>
</template>
<script>
...
data() {
return {
flag: true,
msg: 'hello world'
}
}
</script>
此时,flag 为 true,页面显示 msg 的值 hello world。 若修改 msg 的值我们都知道视图会因此重新渲染。
this.msg = 'ok'; // 重新渲染,页面显示 ok
然后将 flag 设置为 false ,视图也将重新渲染显示 hello vue ,那么如果再次修改 msg 的值,视图会因此更新吗?
this.flag = false; // 重新渲染,页面显示 hello vue
...
this.msg = 'hi'; // 视图会因此更新?
按直觉来说,当 flag 修改成 false 时,视图将走到 else 分支显示 hello vue,此时视图已经不需要 msg 了,理论上 msg 的变化不影响视图。
答案确实如此,不会因此更新,因为视图重新渲染前会先清除所有依赖(cleanupDeps),然后再收集有效依赖,减少不必要的代码执行。
回到我们的响应式系统,该如何做到这点呢?也就是下面代码:
const obj = {
flag: true,
msg: 'hello world',
};
const proxy = reactive(obj);
effect(function effectFn() {
console.log(proxy.flag ? proxy.msg : 'hello vue');
});
代码的执行情况希望是这样的:
- 当
flag为true, 改变msg,effectFn重新执行 - 当
flag改变时,effectFn重新执行 - 当
flag为false- 改变
msg,effectFn不执行
- 改变
以现在的代码不能达到这样的效果。
我们可以先分析一下,现在代码执行后会发生什么,当第一次 effect 执行时, 首先会读取 flag , flag 为 true , 然后读取 msg ,此时 '桶' 的数据关系是这样的:
bucket 数据关系1
从图中可以看出,当 flag 或者 msg 发生变化的时候对应的 effectFn 都会执行,当 flag 为 false
proxy.flag = false;
此时读取常量 hello vue,关系还是如 bucket 数据关系1 所示。也就是说 当 flag 为 false 时, msg 发生变化对应的 effectFn 还是会执行。这明显不符合我们预期,所以理想的情况下, 当 flag 为 false 时, effectFn 不应该被 msg 收集,如图所示:
bucket 数据关系2
正确的思路是当 flag 为 false 时,在 effectFn 执行之前将所关联的依赖清除掉如 bucket 数据关系3 图所示,,执行后再重新收集相关的依赖
bucket 数据关系3
有关依赖的概念: 为了方便理解,可以将依赖理解成副作用函数对应的一系列
Set集合
先看看如何收集与 effectFn 相关依赖,回到 effect 函数并且改造:
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
创建 effectFn 函数,将注册副作用函数和执行副作用函数放在里面,在 effectFn 上添加静态属性 deps ,当外面执行 effectFn 时,那么 activeEffect 会自带 deps 属性,接着改造 track :
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 执行之前将所关联的依赖清除掉:
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();
}
这样我们就完成了依赖的清除,把代码提出来封装一下:
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 发现代码进入死循坏
proxy.flag = false; // effectFn 死循坏
这是什么原因?先看看下面的例子:
const seen = new Set([1]);
seen.forEach((v) => {
seen.delete(1);
seen.add(1);
console.log('代码运行中');
});
将上面的例子贴到浏览器控制台运行发现代码死循坏了,怎么做才能解决这个问题呢?答案是新建一个 Set ,如:
const seen = new Set([1]);
const seenTorun = new Set(seen);
seenTorun.forEach((v) => {
seen.delete(1);
seen.add(1);
console.log('代码运行中');
});
接着分析我们的代码,可以发现副作用函数的执行行为和上卖弄例子是一样的,在 trigger 中:
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 存放:
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 中的组件是可以嵌套使用的,那么我们就不得不讨论嵌套问题。
effect(function () {
/*...*/
effect(function () {
/*...*/
});
});
也先稍微提一下 effect 和 vue render 怎么联系,举个简单渲染组件的例子:
Foo 组件
const Foo = {
render() {
return; /*...*/
},
};
const Bar = {
render() {
return; /*...*/
},
};
effect(() => {
Foo.render();
});
假设 Foo 内部数据已经是响应式数据,当数据发生变化时 Foo.render 将会重新执行,这和我们之前学习的一样。让 Foo 和 Bar 嵌套, Foo 渲染 Bar 组件:
effect(() => {
Foo.render();
effect(() => {
Bar.render();
});
});
上面的例子说明了为什么要将 effect 设计成可嵌套的?那我们现在的代码算不算支持嵌套呢?又怎么算是嵌套的合理设计?
我们用现在的代码用下面例子测试一下会发生什么:
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
});
当上面代码执行后打印:
effectFn1 执行
effectFn2 执行
这样看起来好像没问题,但是看看改变 foo 会发生了什么:
data.foo = false;
结果
effectFn2 执行
思考一下这个结果对不对,先分析一下:因为 foo 是被 effectFn1 收集,理论上当 foo 发生改变时将触发 effectFn1 , effectFn2 在 effectFn1 里面,也会间接触发 effectFn2,所以这个结果对于响应系统来说是不合理的
那么这是为什么?原因出现在 effect 和 activeEffect 上:
let activeEffect = null;
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
可以看得出来全局变量 activeEffect 在同一时刻只能存储一个副作用函数,观察上面嵌套代码,当 effect 发生嵌套时,内部 effect 的执行会覆盖 activeEffect , 当全部执行完, activeEffect 永远都取不到原先的副作用函数,这就时问题所在。
为了解决这个问题,我们引入一个 activeEffect 的栈 effectStack ,与函数的调用保持一致:
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 会发生了什么:
data.foo = false;
结果在预期之中:
effectFn1 执行
effectFn2 执行
查看原文: 响应系统的作用与实现-2
参考文献: Vue.js设计与实现 - 霍春阳