Vue响应式原理(4)-无限循环和effect嵌套问题解决

245 阅读6分钟

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 内部嵌套了 effectFn2effectFn1 的执行会导致 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.bareffectFn2的联系;随后内层effect函数运行结束,继续运行外层 effect 函数代码,执行了 obj.foo 的读取操作,问题就发生在这一过程中,此时的 activeEffect 依然指向的是 effectFn2,因此会将 effectFn2 收集到 obj.foo 的 依赖集合deps中。最终导致obj.fooobj.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 执行'

至此问题解决。