Vue3 响应式系统(四)—— effect 嵌套与effect 栈

593 阅读4分钟

前言

在上篇文章中,我们提到了分支切换与cleanup。意思是指在代码分支发生变化时,我们需要对遗留的副作用函数进行清除(cleanup)操作,避免已经失效的副作用函数在后续过程中执行。

effect 嵌套

effect 是可以嵌套的,例如:

effect(function effectFn1() {
    effect(function effectFn2(){/* ... */})
    /* ... */
})

在上面这段代码中,effectFn1 就嵌套了effectFn2,effectFn1 的执行会导致effectFn2 的执行。那么什么情况下会出现嵌套的effect 呢?在Vue 中,渲染函数就是在effect 函数中执行的:

// Foo 组件
const Foo = {
    render() {
        return /* ... */
    }
}
effect(() => {
    Foo.render()
})

当组件发生嵌套的时候,例如Foo 组件中嵌套了Bar 组件:

const Bar = {
    render() {
        /* ... */
    },
}
const Foo = {
    render() {
        return <Bar />
    },
}

其代码可等效为:

effect(() => {
    Foo.render()
    effect(() => {
        Bar.render()
    })
})

不支持嵌套的effect 函数

上面例子说明了为什么effect 要设计成可嵌套的,接下来需要搞清楚,如果effect 不能嵌套将会发生什么?实际上,我们之前所完成的effect 并不支持嵌套,我们可以用下面的代码来测试一下:

const data = { foo: true, bar: true }
const obj = new Proxy(data, { /* ... */})
let temp1, temp2
effect(function effectFn1() {
    // effectFn1 中嵌套了 effectFn2
    console.log('effectFn1 执行')
    effect(function effectFn1() {
        console.log('effectFn2 执行')
        // 在 effectFn2 中访问 obj.bar
        temp2 = obj.bar
    })
    // 在 effectFn1 中访问 obj.foo
    temp1 = obj.foo
})
obj.foo = false

在上面代码中

  1. 副作用函数effectFn1 嵌套了effectFn2
  2. effectFn1 的执行会导致effectFn2 的执行
  3. effectFn2 的执行在obj.foo 之前

在理想状态下,我们希望得到的依赖是:

obj
  └── foo
       └── effectFn1
  └── bar
       └── effectFn2

在这种情况下我们希望,当修改obj.foo 时,会先触发effectFn1 的执行,并间接触发effectFn2 的执行。当修改obj.bar 时,仅触发effectFn2 的执行。可实际执行结果并不是这样,我们修改obj.foo 发现结果如下

image.png 一共执行了三次,前两次输出符合预期,是两个副作用函数先后执行的结果,当修改obj.foo 时,并没有执行effectFn1 而是执行了effectFn2 ,这显然不符合我们的预期。
我们来看看这究竟是怎么回事,其实问题出在我们实现的effect 函数与acvtieEffect 变量上:

// 用全局变量存储当前激活的effect 函数
let activeEffect
function effect(fn) {
    function effectFn() {
        cleanup(effectFn)
        // 当调用effect 注册副作用函数时,将副作用函数赋值给activeEffect
        activeEffect = effectFn
        fn()
    }
        // activeEffect.deps 用来存储所以与该副作用函数相关的依赖集合
    effectFn.deps = []
    // 执行副作用函数
    effectFn()
}

使用effectStack 栈实现effect 嵌套

我们使用全局变量activeEffect 来存储effect 注册的副作用函数,这意味着同一时刻activeEffect 只能存储一个副作用函数。而在effect 函数发生嵌套的情况下,内部的副作用函数会将外部的副作用函数覆盖掉,且无法恢复,如果这时外部副作用函数中有响应式数据在收集依赖,那么收集到的副作用函数也是内层的副作用函数,这就是问题所在。
为了解决这个问题,我们引入一个储存副作用函数的栈effectStack 。在副作用函数执行时,将当前副作用函数压入栈中,等待副作用函数执行完毕将其从栈中弹出,并始终让activeEffect 指向栈顶元素。这样能做到一个响应式数据只会收集直接读取其值的副作用函数,不会出现相互影响的情况,代码如下所示:

let activeEffect
const effectStack = []
function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        // 执行副作用函数前,将其推入 effectStack
        effectStack.push(effectFn)
        // 发生嵌套时,effect 函数执行在fn 中
        fn()
        // 在当前副作用函数执行完毕后,将当前函数弹出,并将activeEffect 还原为之前的值
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }
    effectFn.deps = []
    effectFn()
}

在上面代码中,我们定义了effectStack 数组用户模拟栈,activeEffect 没有变化,它仍然指向当前正在执行的副作用函数。不同的是,当前副作用函数会被压入栈顶,当副作用函数发生嵌套时,栈底的存储的就是外部的副作用函数,栈顶储存的则是内部副作用函数。当内层副作用函数effectFn2 执行完毕后,它会从栈中弹出,并让activeEffect 设置外部副作用函数。如此一来,响应式数据只会收集读取其值的副作用函数,不会发生错乱。

总结

在这篇文章中,我们先是了解了effect 函数发生嵌套的情况,并且揭露了之前实现effect 函数的不足 —— 它并不支持嵌套。随后,我们使用了effectStack 栈来使响应式数据能够正常地收集读取其数据的副作用函数,从而使我们的effect 函数支持嵌套。