文章目录
- 前言
- 分支切换与cleanup
- 分支切换的问题
- 依赖集合的收集
- cleanup的实现
- 完整的代码展示
前言
本篇文章代码思路来自 Vue3.0 源码, 部分理解来源于霍春阳 《Vue.js设计与实现》这本书的理解, 感兴趣的小伙伴可以自行购买阅读。可以非常明确的感受到作者对 Vue 的深刻理解以及用心, 富含非常全面的 Vue 的知识点, 强烈推荐给大家。
接上文
Vue响应式原理和本质 - 实现一个完善的响应式系统
分支切换与cleanup
前文回顾
在上一篇文章中, 我们实现了一个基本的响应式系统代码如下, 实现过程可以根据自己的需要进行阅读, 链接给到大家: http://lanan.blog.csdn.net/article/details/134127326。但是目前实现的响应式系统仍然存在一些问题, 本文针对分支切换产生的问题继续完善响应式系统
// 前文已实现的响应式系统const data = { name: "chenyq", age: 18 };
const bucket = new 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);},
});function track(target, key) {// 没有activeEffect, 直接returnif (!activeEffect) return target[key];// 根据target从桶中取出depsMaplet depsMap = bucket.get(target);// 如果depsMap不存在, 那么就需要创建一个depsMap与之关联if (!depsMap) bucket.set(target, (depsMap = new Map()));// 再根据key, 从depsMap中取出deps, deps是一个Set集合, 里面存放的是与当前key相关的所有副作用函数let deps = depsMap.get(key);// 如果deps不存在, 则创建一个deps, 并将其添加到depsMap中if (!deps) depsMap.set(key, (deps = new Set()));// 最后将当前激活的副作用函数添加到桶里deps.add(activeEffect);
}function trigger(target, key) {// 根据target从桶中取出depsMapconst depsMap = bucket.get(target);if (!depsMap) return;// 取出与key相关的副作用函数const effects = depsMap.get(key);// 执行副作用函数effects && effects.forEach((fn) => fn());
}let activeEffect;
function effect(fn) {activeEffect = fn;fn();
}// 测试部分
// 执行副作用函数, 触发读取
effect(() => {document.body.innerText = obj.name;
});// 1秒后对obj.name属性进行修改
setTimeout(() => {obj.name = "abc";// obj.age = 19;// obj.notExist = "abc";
}, 1000);
分支切换的问题
首先, 我们需要知道分支切换会给我们上面实现的响应式系统带来哪些问题, 如有下面一段代码, effectFn 函数内部存在一个三元表达式, 当字段 obj.flag 发生变换时, 代码所需要执行的分支也会跟随变化。
const data = { flag: true, text: "分支切换问题" };
const obj = new Proxy(data, { /* ... */ });
function effect(fn) { /* ... */ }effect(function effectFn() {document.body.innerText = obj.flag ? obj.text : "not fount";
});
上面的分支切换会产生遗留的副作用函数, 例如上面代码中, 当 obj.flag 为 true 的时候, 会读取 obj.text 属性的值, 此时就会触发 flag 和 text 属性的 get 操作, 对应的会跟踪 flag 和 text 属性所依赖的副作用函数 effectFn。如下所示, effectFn 分别被字段 flag 和 text 所对应的依赖集合进行收集:
data└── flag└── effectFn└── text└── effectFn
当我们将 obj.flag 的值修改为 false 时, 会触发 obj.flag 的 set 操作, 会重新执行 effectFn 函数, 但由于此时 obj.flag 的值为 false , 不会读取 obj.text 属性。所以我们期望副作用函数 effectFn 不被 obj.text 对应的依赖集合进行收集, 此时副作用函数 effectFn 与响应式建立的关系如下:
data└── flag└── effectFn
但按照我们目前的实现, 无法做到这一点, 在我们将 obj.flag 修改为 false, 并重新执行了副作用函数 effectFn 后, 它们之间对应的依赖关系任然为之前的样子, 这也就产生了副作用函数的遗留问题, 这个问题会导致不必要的更新, 如用下面代码来举例:
调用 effect 函数会执行一次 effectFn 函数, 打印一次 “effect is running”
const data = { flag: true, text: "分支切换问题" };
const obj = new Proxy(data, { /* ... */ });
function effect(fn) { /* ... */ }effect(function effectFn() {console.log("effect is running");document.body.innerText = obj.flag ? obj.text : "not fount";
});
我们将 obj.flag 修改为 false 后, 会触发 obj.flag 的 set 操作, 即副作用函数 effectFn 会重新执行, 打印第二次 “effect is running”
setTimeout(() => {obj.flag = false;
}, 1000);
若此时我们继续修改 obj.text, 可以发现打印了第三次 “effect is running”, 说明 obj.text 对应的依赖集合中, 仍然保留了 effectFn 函数
obj.text = "测试分支切换";
我们想要实现的是, 当 obj.flag 为 false 的时候, 由于此时不在读取 obj.tex t属性, 那么该情况下 obj.text 无论如何进行变换, 都不应该重新执行副作用函数 effectFn 。目前的情况就是, obj.flag 为 false 的时候, 只要修改了 obj.text 的值, 就会重新执行副作用函数 effectFn, 归根结底还是因为副作用函数遗留产生的问题。
依赖集合的收集
其实解决这个问题的思路很简单, 我们只需要每次执行副作用函数的时候, 把这个副作用函数从所有被关联的依赖集合中删除, 比如上文中的建立的对应关系, obj.flag 和 obj.text 都和副作用函数 effectFn 相关联, 我们就可以在执行副作用函数 effectFn, 将它与 obj.flag 和 obj.text 的关系都断开, 如下所示:
data└── flag└── text
待副作用函数执行完成之后, 我们再重新建立联系, 这样一来新建立的联系中就不会包含遗留的副作用函数。那么现在的问题就是, 想要将一个副作用函数从所有与之关联的依赖集合中移除, 我们需要明确的知道哪些依赖集合包含这个副作用函数。这样的话我们就需要对 effect 函数进行重新设计, 我们在 effect 函数中, 定义一个新的函数 effectFn, 将定义的 effectFn 函数设置为当前激活的副作用函数, 并为这个 effectFn 函数添加 deps 属性, deps 属性为一个数组, 数组中存放当前副作用函数的依赖集合, 代码如下所示:
let activeEffect;
function effect(fn) {// 定义一个effectFn函数const effectFn = () => {// 将定义的effectFn函数设置为当前激活的副作用函数activeEffect = effectFn;fn();};// deps属性中用来存储所有与该副作用函数相关联的依赖集合effectFn.deps = [];effectFn();
}
那么我们只需要在 track 函数中, 将当前副作用函数的依赖集合添加到 effectFn.deps 数组中, 就可以完成副作用函数相关联的依赖集合的收集, 如下代码所示:
function track(target, key) {if (!activeEffect) return target[key];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数组中activeEffect.deps.push(deps)
}
cleanup的实现
有了上面的依赖集合收集之后, 我们就可以做到每次在副作用函数执行时, 根据 effectFn.deps , 将副作用函数从所有依赖集合中移除:
let activeEffect;
function effect(fn) {const effectFn = () => {// 调用cleanup函数完成清除cleanup(effectFn)activeEffect = effectFn;fn();};effectFn.deps = [];effectFn();
}
cleanup 函数接收一个副作用函数作为参数, 遍历 effectFn.deps 数组, 数组中每一项都是一个依赖集合, 依次从每一个依赖集合中将副作用函数 effectFn 移除, 实现代码如下所示:
function cleanup(effectFn) {// 遍历effectFn.deps数组const length = effectFn.deps.length;for (let i = 0; i < length; i++) {// 取出依赖集合const deps = effectFn.deps[i];// 将副作用函数从依赖集合中移除deps.delete(effectFn);}// 将effectFn.deps重置为空数组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()); // 这行代码有问题
}
我们来分析一下问题的原因, 在 trigger 内部, 我们遍历的 effects, 它是一个 Set 集合, 里面存储着副作用函数; 我们遍历这个 Set 集合时, 会取出每一个副作用函数进行执行, 在执行副作用函数时会调用 cleanup 函数进行清除, 会将这个副作用函数从 Set 集合中移除; 但是我们继续执行函数, 会导致它又重新被收集到 Set 集合当中, 而此时 Set 集合遍历任在进行。
语言规范中对此有明确的说明: 在调用 forEach 遍历 Set 集合时, 如果一个值已经被访问过了, 但该值被删除并重新添加到集合, 如果此时 forEach 遍历没有结束, 那么该值会重新被访问。
例如下面代码, Set 集合当中只有一个元素, 在遍历时我们将这个元素先删除, 再添加; 那么该值就会被重新访问, 进而这个循环就会无限执行进行下去:
const set = new Set([1])set.forEach(item => {set.delete(1)set.add(1)
})
因此 trigger 中的代码同理, 也会无限的执行下去。解决办法很简单, 我们可以构造另外一个 Set 集合并遍历它, 这样就不会无限的执行下去了。具体做法: 由于我们将副作用函数移除和添加的函数是 effects, 那么我们就不要直接遍历 effects, 创建一个新的集合 effectsToRun 代替直接遍历 effects, 代码如下所示:
function trigger(target, key) {const depsMap = bucket.get(target);if (!depsMap) return;const effects = depsMap.get(key);// 创建一个新的Set集合用来遍历const effectsToRun = new Set(effects)// 执行副作用函数effectsToRun.forEach(effectFn => effectFn())
}
完整的代码展示
到这里我们就彻底解决了副作用函数产生遗留的问题, 到这里我们实现的响应式系统的完整代码给到大家:
const data = { flag: true, text: "分支切换问题" };
const bucket = new 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);},
});// 依赖收集
function track(target, key) {// 没有activeEffect, 直接returnif (!activeEffect) return target[key];// 根据target从桶中取出depsMaplet depsMap = bucket.get(target);// 如果depsMap不存在, 那么就需要创建一个depsMap与之关联if (!depsMap) bucket.set(target, (depsMap = new Map()));// 再根据key, 从depsMap中取出deps, deps是一个Set集合, 里面存放的是与当前key相关的所有副作用函数let deps = depsMap.get(key);// 如果deps不存在, 则创建一个deps, 并将其添加到depsMap中if (!deps) depsMap.set(key, (deps = new Set()));// 最后将当前激活的副作用函数添加到桶里deps.add(activeEffect);// 将依赖集合收集到activeEffect.deps数组中activeEffect.deps.push(deps);
}// 派发更新
function trigger(target, key) {// 根据target从桶中取出depsMapconst depsMap = bucket.get(target);if (!depsMap) return;// 取出与key相关的副作用函数const effects = depsMap.get(key);// 创建一个新的Set集合用来遍历const effectsToRun = new Set(effects);// 执行副作用函数effectsToRun.forEach((effectFn) => effectFn());
}let activeEffect;
function effect(fn) {// 定义一个effectFn函数const effectFn = () => {// 调用cleanup函数完成清除cleanup(effectFn);// 将定义的effectFn函数设置为当前激活的副作用函数activeEffect = effectFn;fn();};// deps属性中用来存储所有与该副作用函数相关联的依赖集合effectFn.deps = [];effectFn();
}// 清除依赖
function cleanup(effectFn) {// 遍历effectFn.deps数组const length = effectFn.deps.length;for (let i = 0; i < length; i++) {// 取出依赖集合const deps = effectFn.deps[i];// 将副作用函数从依赖集合中移除deps.delete(effectFn);}// 将effectFn.deps重置为空数组effectFn.deps.length = 0;
}// 测试部分
// 执行副作用函数, 触发读取
effect(function effectFn() {console.log("effect is running");document.body.innerText = obj.flag ? obj.text : "not fount";
});// 1秒后对obj.name属性进行修改
setTimeout(() => {obj.flag = false;obj.text = "测试分支切换";
}, 1000);