响应式数据系统 二
响应式数据系统 二
代码分支切换导致的遗留的副作用函数
接着上回说到,我们已经完成了一个简单的响应式数据系统。
这回我们来发现一些问题,并提出解决方法。
来看下面的用例:
<script src="./index.js"></script>
<script>
const span = document.querySelector('span')
const p = document.querySelector('p')
const weather = reactive({
isRaining: true,
raincoat: "雨衣",
sunscreen: "防晒霜"
})
effect(()=>{
p.textContent = weather.isRaining ? weather.raincoat : weather.sunscreen
console.log('当前激活的代码分支:' + (weather.isRaining ? '下雨天':'晴天'))
})
setTimeout(() => {
weather.isRaining = false
}, 2000);
setTimeout(() => {
weather.raincoat = '雨伞'
}, 4000);
</script>
weather定义了一个响应式数据,有三个属性:isRaining,raincoat、sunscreen。
并且注册了一个副作用函数,用于根据isRaining的值显示p元素的内容。
可以发现,由于三元表达式的缘故,track函数只能为特定代码分支下的响应式数据收集到副作用函数:
当 isRaining 为 true 时,为 weather.isRaining 和 weather.raincoat 收集此副作用函数;
当 isRaining 为 false 时,为 weather.isRaining 和 weather.sunscreen 收集此副作用函数。
比如说,当 isRaining 起初为 true ,虽然effect里使用了weather.sunscreen,但是代码分支中,没有执行到那行代码,所以没有被track收集到。
当 weather.sunscreen 变化时 ,不会触发此副作用函数。这是没有问题的,因为副作用函数不会用到这个数据。
而且,当 isRaining 发生改变后,也就是代码分支发生变化后,track也总能为应有的响应式数据收集到副作用函数,这似乎没有问题。
但是问题出现在,当 isRaining 发生变化后,也就是代码分支发生变化后,起初被收集副作用函数的某些响应式数据,可能在新的代码分支不再需要,而它和副作用函数的关联依然存在,我们称之为“遗留的副作用函数”。
这就导致,当这些不被依赖的响应式数据再次发生变化时,依然会触发副作用函数,而这种触发是不必要的,因为这个副作用函数不依赖此响应式数据,调用是没有效果的。这不是我们希望发生的。
虽然这不会造成实质性的错误,但是可能会造成性能问题,尤其是在代码量大和响应式变量频繁改变的情况下。
我们希望,副作用函数的每次调用都是必要的。
解决方法是,在每次调用副作用函数之前,也就是为响应式数据收集副作用函数之前,先为响应式数据清理掉所有有关此副作用函数的依赖,再让这个副作用函数被应有的响应式数据收集。
这样每次响应式数据每次触发这个副作用函数,都是上次以已经重新收集的全新的依赖集合。
关键在于清理cleanup函数,我们可以我在每次effectFunction中,先执行cleanup函数清理掉以前的依赖,再让activeFunctin = effectFunction,最后调用副作用函数,触发依赖收集。
问题在于,由于bucket的数据结构,我们可以很方便的找到,某个响应式数据关联的副作用函数,而不容易找到某个副作用函数所有依赖这个函数的响应式数据。
我们可以给effectFunction添加一个depsList数组,用于保存与此副作用函数相关的响应式数据的deps集合。
在于在收集依赖时,先往副作用函数中推入deps集合。然后在cleanup函数中,对于depsList里的每一个deps集合,清理掉其中的自己!
这样问题看似就解决了。但是trigger函数中还有一个问题。每次为某个响应式数据触发deps中的每一个副作用函数时,会先导致“清理函数清理deps的副作用函数、触发收集添加deps的副作用函数、再次被Set遍历到,再次清理函数”的无限循环。
解决办法很简单,在执行deps的副作用函数之前,先将其保存另一个集合,对这个集合进行遍历执行。
这样就避免了直接操作原deps导致的无限循环问题。
下面是完整的代码:
const bucket = new WeakMap()
let activeEffect = null
function reactive(obj){
return new Proxy(obj, {
get:(targe, key) => {
track(targe, key)
return targe[key]
},
set:(targe, key, newValue) => {
targe[key] = newValue
trigger(targe, key)
}
})
}
function track(targe, key){
if(!activeEffect) return
let depsMap = bucket.get(targe)
if(!depsMap){
depsMap = new Map()
bucket.set(targe, depsMap)
}
let deps = depsMap.get(key)
if(!deps){
deps = new Set()
depsMap.set(key, deps)
}
deps.add(activeEffect)
activeEffect.depsList.push(deps) //在副作用函数的 deps属性中保存 与之依赖 的对象属性
console.log(key+'的副作用函数收集完毕')
}
function trigger(target, key){
let depsMap = bucket.get(target)
if(!depsMap) return
let deps = depsMap.get(key)
if(deps.size === 0) return
const depsToRun = new Set(deps) //新建一个Set集合,避免直接操作原集合 ‘清除、收集’ 无限循环。
console.log(depsToRun)
depsToRun.forEach(fn =>fn());
console.log(key+'的副作用函数执行完毕')
}
function cleanup(effectFunction){
if(effectFunction.depsList){
//对于depsList里的每一个deps(Map),清理掉其中的 自己!
effectFunction.depsList.forEach(deps => {
deps.delete(effectFunction)
deps.delete(effectFunction)
})
effectFunction.depsList.length = 0
console.log('副作用函数的所有依赖清理成功')
}
}
function effect(fn){
function effectFunction() {
cleanup(effectFunction) //放在 effectFunction 内,确保每次 执行副作用函数都能清除
activeEffect = effectFunction
fn()
}
effectFunction.depsList = [] //为effectFunction添加一个数组 保存 相关的响应式数据
effectFunction()
}
嵌套的effect函数
当我们在一个effect函数内部创建另一个effect函数时,就形成了嵌套的effect函数。
这种情况时可能的,因为组件是嵌套的,所以为这些组件创建的响应式数据也是变化的。
主要问题是,当执行到内部的副作用函数时,会将 activeEffect 修改为内部的副作用函数。当继续执行到外部副作用函数的后续代码时,会导致后面的响应式数据错误的收集内部的副作用函数。
effect(() => {
console.log('Outer effect running');
effect(() => {
console.log('Inner effect running');
console.log('value:', data.value);
});
data.count++; //data.count错误地收集内部effect
});
解决办法十分巧妙,就是使用一个栈来维护正在执行的副作用函数,称为effect栈。我们可以用数组的push和pop方法实现栈的行为。
在副作用函数执行时,先将当前副作用函数压入栈中,待副作用函数执行完毕后,将其在栈中弹出,始终让activeEffect 函数指向栈顶的函数。
let activeEffect;
const effectStack = [];//effect栈
function effect(fn){
function effectFunction() {
cleanup(effectFunction)
activeEffect = effectFunction
effectStack.push(effectFunction) //在副作用函数执行前 将副作用压入栈中
fn()
effectStack.pop() // 弹出栈顶的副作用函数
activeEffect = effectStack[effectStack.length - 1] //恢复activeEffect的上一个指向
}
effectFunction.depsList = []
effectFunction()
}
这样就能让activeEffect一直指向正在执行的副作用了,栈顶保存的是内层副作用函数,栈底保存的是外层副作用函数。这样响应式数据就只会收集直接读取其值的副作用函数,从而避免发生错乱。
触发的副作用函数和执行中的副作用函数相同导致的无限递归循环
举一个简单的例子说明,obj是一个响应式对象,注册一个副作用函数:
effect(()=>obj.foo++)
这个操作会引起栈溢出!
obj.foo++等价于obj.foo = obj.foo + 1,这实际上会先后触发obj.foo的读取和设置。
当读取时,会将这个()=>obj.foo++
这个函数收集到obj.foo的依赖集合中,接着触发设置操作,又会立即执行obj.foo的依赖集合中的此函数,如此这个函数还没有结束就会再次执行,导致无限递归,最终产生栈溢出。
“清理依赖,activeEffect = effectFunction,执行函数,’读取操作’触发obj.foo收集此副作用函数,’设置操作‘触发执行此函数,清理依赖...”无限循环。
解决方法很简单,不难可以发现,当触发的副作用函数和执行中的副作用函数相同时,应该跳过这次tigger函数中这个副作用函数的执行。
只需要在tigger函数开头添加一个判断,
function trigger(target, key){
let depsMap = bucket.get(target)
if(!depsMap) return
let deps = depsMap.get(key)
if(deps.size === 0) return
const depsToRun = new Set(deps)
console.log(depsToRun)
depsToRun.forEach(fn =>{
if(fn === effectFunction) return// 跳过当前正在执行的副作用函数
fn()
});
}
这样我们就发现并解决了可能存在的三个问题,让我们的响应式数据系统更加健康茁壮!
最后,补充一下数据结构的示意图: