1. 无限循环问题解决
在前文《Vue响应式原理(3)-断开副作用函数与响应式数据联系》最后,我们会发现改进后的响应式系统会导致死循环的产生。问题主要产生在 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 集合,里面存储着副作用函数。当副作用函数执行时,会调用 cleanup 函数进行清除,实际上就是从 effects 集合中将当前执行的副作用函数剔除,而副作用函数的执行会重新建立函数与响应式对象间的联系,导致其重新被收集到集合中,而此时对于 effects 集合的遍历仍在进行。对整个过程进行简化可以写成下列代码:
const set = new Set([1])
set.forEach(item => {
set.delete(1)
set.add(1)
})
对于 Set 类型的变量,在其循环遍历过程中,删除元素又新增元素会导致该循环无限次执行。ECMAScript 2020 Language Specification中对Set.prototype.forEach的规范有明确的说明:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么该值会重新被访问。因此,上面的代码会无限执行。
为解决这个问题,我们可以构造另一个 Set 来执行循环,在循环过程中对原始 Set 进行删除和新增操作,如下列代码:
const set = new Set([1])
const newSet = new Set(set)
newSet.forEach(item => {
set.delete(1)
set.add(1)
})
这种方法可以避免无限循环,我们可以将这种形式应用在 trigger 函数中:
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => effectFn())
}
至此,我们成功解决了无限循环的问题。
2. effect嵌套问题
在先前的例子中,我们用于注册副作用函数的 effect 函数通常只会调用一次,但实际在 vue3 的模板文件中通常会有 effect 函数嵌套的情况,以下面的代码为例:
effect(function effectFn1() {
effect(function effectFn2() { /* ... */ })
/* ... */
})
在上面这段代码中,effectFn1 内部嵌套了 effectFn2,effectFn1 的执行会导致 effectFn2 的执行。在什么场景下会出现嵌套的 effect 呢?实际上 Vue.js 的渲染函数就是在一个 effect 中执行的:
// Foo组件
const Foo = {
render() {/*...*/}
}
render 函数传递给 effect 函数实现副作用函数的注册:
effect(() => {
Foo.render()
})
当 Foo 组件内部引入了其它组件 Bar 时:
// Bar组件
const Bar = {
render() {/*...*/}
}
// Foo 组件
const Foo = {
render() {
return <Bar /> // jsx语法
}
}
此时就会存在 effect 函数嵌套,相当于如下代码:
effect(() => {
Foo.render()
effect(() => {
Bar.render()
})
})
在当前的实现中,effect 嵌套使用会存在一个问题。运行下列代码:
// 原始数据
const data = { foo: true, bar: true }
// 代理对象
const obj = new Proxy(data, { /* ... */ })
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
// 在 effectFn2 中读取 obj.bar 属性
obj.bar
})
// 在 effectFn1 中读取 obj.foo 属性
obj.foo
})
上述代码中分别在 effectFn1 中读取了 obj.foo 属性, effectFn2 中读取了 obj.bar 属性,我们理想中副作用函数与对象属性间应建立的联系为:
data
└── foo
└── effectFn1
└── bar
└── effectFn2
在这种情况下,我们希望当修改 obj.foo 时会触发 effectFn1执行。由于 effectFn2 嵌套在 effectFn1 里,所以会间接触发 effectFn2 执行,因此期望的输出为:
'effectFn1 执行'
'effectFn2 执行'
'effectFn1 执行'
'effectFn2 执行'
但实际上输出为:
'effectFn1 执行'
'effectFn2 执行'
'effectFn2 执行'
因为实际上我们建立的联系为:
data
└── foo
└── effectFn2
└── bar
└── effectFn2
为了清楚这个问题的产生原因,我们需要对effect嵌套函数的执行过程进行分析:首先执行外层 effect 函数,在其执行过程中会先将 activeEffect 指向 effectFn1 (实际是指向一个包含 effectFn1 的箭头函数,为方便表述为指向 effectFn1 ),随后执行 effectFn1 函数会触发内部 effect 函数执行;在执行过程中会将 activeEffect 指向 effectFn2,随后执行 effectFn2;在 effectFn2 执行过程中读取了字段 obj.bar,此时触发依赖收集过程,在 get 代理函数中建立了 obj.bar 和 effectFn2的联系;随后内层effect函数运行结束,继续运行外层 effect 函数代码,执行了 obj.foo 的读取操作,问题就发生在这一过程中,此时的 activeEffect 依然指向的是 effectFn2,因此会将 effectFn2 收集到 obj.foo 的 依赖集合deps中。最终导致obj.foo和obj.bar的依赖集合收集到的都是 effectFn2。
总结来说,产生上述问题的主要原因是在effect函数中,我们用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且当内层副作用函数执行完成后也无法让 activeEffect 恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数。
因此我们需要对effect函数进行改造。直观的想法是在effectFn内部真正的副作用函数fn执行完毕以后,让activeEffect重新指向外层的副作用函数,这就意味着我们需要对副作用函数进行存储,并且在内层副作用函数执行完成后将其丢弃,让指针重新指向外层的副作用函数。用栈的形式就能完美实现,我们需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况。
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// 副作用函数栈
const effectStack = []
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
完成上述改造后,我们再次修改 obj.foo 进行测试,发现如预期输出:
'effectFn1 执行'
'effectFn2 执行'
'effectFn1 执行'
'effectFn2 执行'
至此问题解决。