前言
在我们编写vue文件的时候,会定义很多组件,每个组件都会有一个对应的渲染函数,如果组件引用到的响应式数据发生了变化,组件的渲染函数就必须要重新执行,以保证界面展示的数据与真实数据同步更新。为了实现这样的效果,通常渲染函数就是响应式数据的依赖(副作用函数),我们前面的文章有讲解过依赖(副作用函数)的概念,此处不展开细讲,并且我们上一篇文章已经初步实现了一个支持同步更新的响应系统,但它还有很多需要优化的地方。今天我们主要解决两个问题:
- 用effect栈解决effect嵌套的问题
- 解决无限递归循环
用effect栈解决effect嵌套的问题
问题描述
在前文实现的响应系统中运行以下代码:
// 原始数据
const data = {
foo: true,
bar: true,
}
// 代理对象
const p = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
},
})
// 注册依赖
effect(function effectFn1() {
console.log('effectFn1执行')
effect(function effectFn2() {
console.log(`effectFn2执行,bar值为${p.bar}`)
})
console.log(`foo值为${p.foo}`)
})
复制代码
观察上面代码,我们在effectFn1
中嵌套了一个effectFn2
,二者都是需要注册的依赖,并且在注册完effectFn2
依赖之后,我们才在effectFn1
函数内部的顶层读取被代理的数据foo
,另外effectFn2
中读取的是被代理的数据bar
。
我们希望上述代码可以实现这样的效果:
- 更新
bar
的值,依赖effectFn2
重新执行 - 更新
foo
的值,依赖effectFn1
重新执行,因为effectFn2
嵌套其中,也会执行effectFn2
但目前的实现无法达到上述效果,我们在收集完依赖之后,打印输出“桶”来看看:
console.log(bucket.get(data))
复制代码
控制台输出:
可以看到只有bar
属性收集到了依赖,验证一下:
p.foo = false // 无输出
p.bar = false // 输出:effectFn2执行,bar值为false
复制代码
产生问题的原因
由于我们每次执行依赖的时候,不管是注册依赖还是重新执行依赖,都会在执行真正的依赖fn()
之前,将当前依赖effectFn
赋值给activeEffect
,然后在收集完依赖之后,将activeEffect
的值重置为null,所以导致了如下执行过程中的效果:
track
函数收集的依赖永远为activeEffect
指向的函数,所以读取foo
属性没有收集到依赖。
解决问题
我们可以构建一个存储当前正在执行的依赖的effect栈,每个effectFn执行前都要入栈,执行完之后出栈,而activeEffect
一直指向位于栈顶的依赖,这样就可以解决上面的问题了。关于的栈的概念,如果有不了解的读者,可以在网上翻阅相关文章,这里就不多赘述了。下面来实现这个效果:
let activeEffect
// 声明一个数组当作栈使用
let effectStack = []
// 注册副作用函数
function effect(fn) {
function effectFn() {
cleanup(effectFn)
// 将当前依赖压入栈中
effectStack.push(effectFn)
// 让activeEffect指向栈顶的依赖
activeEffect = effectStack.at(-1)
// 执行真正的依赖,在其中会完成依赖的收集
fn()
// 当前依赖收集完毕,将它弹出栈
effectStack.pop()
// 让activeEffect重新指向栈顶的依赖
activeEffect = effectStack.at(-1)
}
effectFn.deps = []
effectFn()
}
复制代码
定义effectStack数组,用它来模拟栈,当前的依赖会被压入栈顶,这样当依赖嵌套时,栈底存储的就是外层依赖,而栈顶存储的就是内层依赖:
当内层的依赖effectFn2
执行完毕,它会被弹出栈,并将依赖effectFn1
设置为activeEffect
。这样响应式数据就只会收集直接读取其值的依赖,避免发生漏收、错收的情况。
遗留bug
外层effectFn1每次被重新执行都会使得effectFn2被重复收集进bar的依赖集合当中,因为依赖集合的数据结构是Set,Set对对象类型判断是否重复决定是否去重时,是根据内存地址去判断的,如果两个数据的内存地址不同将不去重,所以两个函数即使函数体和形参一模一样也会被判定为两个不同的数据被同时保留下来。因此导致下列问题:
effect(() => {
console.log('effectFn1执行')
effect(() => {
console.log(`effectFn2执行,bar值为${p.bar}`)
})
console.log(`foo值为${p.foo}`)
})
p.foo = false // effectFn1重新执行,函数体内执行effectFn2,一切正常
p.bar = false // effectFn2重新执行两次
复制代码
这个问题书中未教如何解决,笔者经过各种尝试也没有解决,所以先记录在此,希望有妙招的读者在评论区留言,感激不尽。
解决无限递归循环问题
如果想让一个Number类型的原始值自增,最简单的办法就是data++
,方便快捷,但是不知道有没有读者在副作用函数中尝试过这样做呢?如下:
// 原始数据
const data = {
count: 0,
}
// 代理对象
const p = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
},
})
// 注册依赖
effect(() => {
p.count++
})
复制代码
这样做会报错,大家可以试一试,控制台会提示栈溢出:
产生问题的原因
count++
这行代码实际上既有读取操作,又有设置操作:
p.count = p.count + 1
复制代码
每次设置的时候,都会触发依赖重新执行,依赖一执行就往effectStack
栈中压入一个函数,当前的依赖未执行结束又触发了下一次依赖执行,如此往复形成了无限递归循环,因此最后栈溢出了。
解决问题
在依赖被重新执行前,判断一下该依赖是否和当前正在执行的依赖也就是activeEffect
相等,如果相等则不执行了。主要的修改在trigger
函数中,如下:
// trigger触发响应,重新执行依赖
function trigger(target, key) {
let depsMap = bucket.get(target)
if (!depsMap) return
let effectFns = depsMap.get(key)
if (!effectFns) return
let effectFnsToRun = new Set(effectFns)
effectFnsToRun.forEach(effectFn => {
// 执行前的判断
effectFn !== activeEffect && effectFn()
})
return
}