前文提要:
3.0 响应式系统的设计与实现
3.1 一个稍微完善的Vue.js响应式系统
1、解决副作用函数的死循环问题
在解决了分支的切换的问题,此时还有一个代码死循环的问题,其这个死循环很容易触发,如下代码:
const data = {ok: true, text: 'hello world'}
const obj = new Proxy(data, {
get: (target, key, receiver) => {
track(target, key, receiver)
return Reflect.get(target, key, receiver)
},
set: (target, key, value, receiver) => {
Reflect.set(target, key, value, receiver)
trigger(target, key)
return true
}
});
effect(()=>{
console.log(obj.ok)
})
obj.ok = true
function trigger(target, key) {
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
// 问题出在这里
deps && deps.forEach(fn => fn())
}
其实问题就出在trigger函数执行的过程中,这里对于obj.ok
的改变导致了副作用函数执行,在执行的过程中有console.log(obj.ok)
,这里再次访问了obj.ok
,则将副作用函数又插回了副作用函数的执行队列中,效果相当于
const set = new Set();
set.add('one');
set.forEach(e => {
console.log(e)
set.delete(e);
set.add('one')
})
set删除值之后,再插入相同的值也会让forEach
一直执行。
其实解决的方案很简单,可以在执行时将deps
内的值给取出来,放在新的一个Set中,然后遍历新的Set,这样如果有新加入的值会放入deps
中,而不是一边执行一边插入。
function trigger(target, key) {
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
// 重新放入,再执行
const effectsToRun = new Set(effects)
// 将副作用函数追踪下来,防止出现set在删除时插入新值
effectsToRun.forEach(effectFn => effectFn())
}
2、解决在effect函数嵌套时出现的问题
effect
函数嵌套是很常见的,比如我们嵌套的组件,父子组件都绑定了副作用函数,如:
// 当发生嵌套时
// Bar组件
const Bar = {
render() {
return...
}
}
// Foo组件为Bar的父组件
const Foo = () => {
render() {
return <Bar />
}
}
// 此时的effect发生了嵌套,相当于
effect(() => {
Foo.render()
effect(() => {
Bar.render
})
})
对于我们上面举例的副作用函数潜逃做个实验,实验代码如下
// 其余代码......
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn
//先清除再执行,自然就形成了分支切换
cleanup(effectFn)
fn()
}
effectFn.deps = []
effectFn()
}
// 这里是重点
effect(() => {
console.log('effect 1 has beeen executed')
effect(() => {
console.log('effect 2 has beeen executed')
temp2 = obj.ok
})
temp1 = obj.text
})
obj.text = '1'
此时我们期望执行为触发两次外层的的effect函数,即打印结果为
effect 1 has beeen executed
effect 2 has beeen executed
effect 1 has beeen executed
effect 2 has beeen executed
在建立时能够触发一次外层effect函数,然后obj.text = 1
会再次执行外层的effect函数,但是实际的结果是什么呢
effect 1 has beeen executed
effect 2 has beeen executed
effect 2 has beeen executed
这个明显和我们的期望不同,第一次外层effect
函数执行输出的前两句是符合预期的,但是第二次修改obj.text
触发的却是内层的effect函数。也就是说绑定的obj.text
的副作用函数变成了内层函数。
分析导致这个结果的原因还是看effect
实现的过程:
let activeEffect = null
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn
//先清除再执行,自然就形成了分支切换
cleanup(effectFn)
fn()
}
effectFn.deps = []
effectFn()
}
在第二层effect执行的时候activeEffect
赋值为obj.ok
的副作用函数,当第二层effect
函数执行完之后,回到第一层时的activeEffect
指向的还是第二层的effectFn
,所以此时obj.text
的副作用函数就成了第二层的effect
函数。
其实这个过程就是函数递归时,avtiveEffect
的执行也在递归过程中逐步指向内层的副作用函数,但是当递归出来的时候,activeEffect
并没有从内层向外层恢复。这个问题实质上就是一个入栈出栈的问题,解决也很轻松,我们可以使用一个栈来记录副作用函数递归时每一层的指向,这样在递归出来时副作用函数指向的就是栈顶元素,代码如下:
const effectStack = []
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn
//先清除再执行,自然就形成了分支切换
cleanup(effectFn)
// 用栈记录下当前的辅作用函数
effectStack.push(effectFn)
fn()
effectStack.pop()
// 递归出来时改变activeEffect指向
activeEffect = effectStack[effectStack.length-1]
}
effectFn.deps = []
effectFn()
}
其过程如图:
3、解决无限递归的问题
假设我们副作用函数的代码如下:
effect(() => {
obj.ok = obj.ok || 1
})
这个副作用函数存在的问题是对obj.ok
进行了访问,又对他赋值,这会在effectFn
中同时触发trigger
和track
,在trigger
中又会触发effectFn
这样下去就形成了一个死递归,报错信息为:RangeError: Maximum call stack size exceeded
解决思路就是在trigger
中判断一下,就根据这个死递归的过程,在执行追踪的时候查看副作用函数activeEffect
和当前调用函数是否相同,相同则跳过。
function trigger(target, key) {
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 判断正在执行的副作用函数和当前的即将执行的函数是否相同
if(effectFn !== activeEffect)
effectsToRun.add(effectFn)
})
// 将副作用函数追踪下来,防止出现set在删除时插入新值
effectsToRun.forEach(effectFn => effectFn())
}