从零到一打造 Vue3 响应式系统 Day 8 - Effect:深入剖析嵌套 effect

今天我们来探讨一个棘手的边界情况:嵌套 effect。

当一个 effect 内部又定义了另一个 effect 时,我们的系统会如何运作呢?

JavaScript 复制代码
import { ref, effect } from '../dist/reactivity.esm.js'

const count = ref(0)

const effect1 = effect(() => {
  const effect2 = effect(() => {
    console.log('内层的 Effect', count.value)
  })
  
  console.log('外层的 Effect', count.value)
})

setTimeout(() => {
	count.value = 1
}, 1000)

在这种情况下,我们预期内外层都有输出,但是我们得到的结果如下:

arduino 复制代码
console.log('内层的 Effect', 0)
console.log('外层的 Effect', 0)
// 1秒后
console.log('内层的 Effect', 1)

官方不建议使用嵌套 effect,你可能会想:"既然官方不建议,我只要不这么写就好了。"

但是遇到这种"嵌套执行"的场景比想象中更常见。比方说,当一个 effect 依赖了一个 computed 属性时,就会隐式触发嵌套执行:

JavaScript 复制代码
const count = ref(0);
// computed 内部会为计算函数创建一个 effect (我们先称之为 effect B)
const double = computed(() => count.value * 2);

// 这是我们手动创建的 effect (我们称之为 effect A)
effect(() => {
  // 当 effect A 执行,并在这里读取 double.value 时...
  // effect B 就必须先执行并返回计算结果。
  // 这就形成了 effect A 内部触发了 effect B 执行的嵌套情况。
  console.log('The double value is:', double.value);
});

因此,为了处理这种隐式触发的问题,我们就需要解决嵌套 effect 的触发机制。

问题解析

初始化页面

  • 执行 effect1 (ReactiveEffect A):

    • activeSub 设为 A

    • 开始执行 effect1 的函数 fnA

    • 进入 fnA 内部,遇到 effect2 (ReactiveEffect B):

      1. activeSub 被覆盖 ,更新为 B
      2. 开始执行 effect2 的函数 fnB
      3. fnB 中,读取 count.value,触发 getter
      4. 依赖收集: count 的依赖列表中,只收集了当前的 activeSub ,也就是 B
      5. console.log 输出 内层的 Effect 0
      6. fnB 执行完毕,activeSub 被清空 (undefined)。
  • 回到 effect1fnA 继续执行:

    • 此时,程序读取 count.value
    • 依赖收集失败: 因为 activeSub 已经是 undefined,所以 A 无法被 count 收集。
    • console.log 输出 外层的 Effect 0
  • 结果: count 的依赖链表上,只有 B (effect2),没有 A (effect1)。

关键问题:执行外层匿名函数 fn 时,activeSub 就被覆盖,导致外层没有进行依赖收集。

一秒后执行 count.value = 1

由于依赖收集只收集了内层的 ReactiveEffect(也就是 ReactiveEffect B),因此它在触发更新时,只会执行 Brun 方法。

核心思路

后来的 effect 覆盖了前面的 effect,这个情况是不是跟函数的"调用栈 (Stack)"有点像?

调用栈 (Stack) 有两个主要特性:

  1. 后进先出 (Last-In, First-Out)。
  2. 一维线性结构。

函数在层层调用时,就是被放入一个"调用栈"中,我们也可以利用这个特性来管理 activeSub

  • 在进入内层 effect 时,将外层的 effect 暂存起来。
  • 在内层结束后,再从栈中"弹出"并还原外层的 effect

要完成这个方法,可以通过一个暂存变量来模拟。

解决方法

具体做法

  1. 外层 effect 开始:activeSub = ReactiveEffect A

  2. 外层 effect 执行,遇到内层 effect

  3. 在内层 effect 执行之前:

    • 我们先检查 activeSub 是不是有值。
    • 如果有值,我们可以先把它存储起来。
  4. 内层 effect 执行完成后:

    • 不再简单地设置 activeSub = undefined
    • 而是将 activeSub 恢复成执行之前的状态。

于是我们这样写:

TypeScript 复制代码
export let activeSub;

class ReactiveEffect {
  constructor(public fn){}

  run(){
    // 先将当前的 Effect 存储,用于处理嵌套逻辑
    const prevSub = activeSub
    activeSub = this
    try {
      return this.fn()
    } finally {
      // 执行完毕后,恢复之前的 activeSub
      activeSub = prevSub
    }
  }
}

export function effect(fn){
  const e = new ReactiveEffect(fn)
  e.run()
}

它的运作模式是这样:

此时你会发现,在触发更新的时候,内层会多输出一次:

触发更新为何会多输出一次?

初始状态

  • 内层的 Effect 0
  • 外层的 Effect 0

分别输出一次,这里没什么问题。

初始化后的链表结构

setTimeout 触发更新后

  • count.value = 1 触发 setter,执行 propagate

  • propagate 遍历依赖链表。

  • 执行 B.run() (effect2)

    • console.log 输出 内层的 Effect 1 (第一次)
  • 执行 A.run() (effect1)

    • console.log 输出 外部的 Effect 1

    • A 的函数内部,会重新创建并执行 一个全新的内层 effect

    • 执行这个新的内层 effect.run()

      • console.log 输出 内层的 Effect 1 (第二次)。

因为这样,所以内层会执行两次。

乍看之下,内层 effect 多执行一次似乎没什么关系。

但思考一下,如果现在内层的 effect 执行的不是 console.log,而是更耗费资源的操作呢?

像是:

  • 网络请求
  • 复杂且大量的计算
  • DOM 的重新布局

因此我们知道,不必要的重复执行会导致性能浪费,甚至有可能引发无法预期的 Bug。

这也就是为什么官方不推荐我们写嵌套 effect


想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

相关推荐
小lan猫2 小时前
React学习笔记(一)
前端·react.js
晨米酱2 小时前
JavaScript 中"对象即函数"设计模式
前端·设计模式
拜无忧2 小时前
【教程】Nuxt v4 入门指南与实践 (vue前端角度开发)
前端·nuxt.js
云枫晖2 小时前
手写Promise-什么是Promise
前端·javascript
拜无忧2 小时前
html,svg,花海扩散效果
前端·css·svg
DevUI团队2 小时前
🚀 MateChat V1.8.0 震撼发布!对话卡片可视化升级,对话体验全面进化~
前端·vue.js·人工智能
RoyLin2 小时前
TypeScript设计模式:责任链模式
前端·后端·typescript
一枚前端小能手2 小时前
📋 前端复制那点事 - 5个实用技巧让你的复制功能更完美
前端·javascript
三小河2 小时前
解决vite环境下调用获取二进制文件流 部分文件报错 (failed)net::ERR_INVALID_HTTP_RESPONSE)
前端