从零到一打造 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」,一起跟日安当同学。

相关推荐
代码搬运媛8 小时前
Jest 测试框架详解与实现指南
前端
counterxing9 小时前
我把 Codex 里的 Skills 做成了一个 MCP,还支持分享
前端·agent·ai编程
wangqiaowq9 小时前
windows下nginx的安装
linux·服务器·前端
之歆9 小时前
DAY_12JavaScript DOM 完全指南(二):实战与性能篇
开发语言·前端·javascript·ecmascript
发现一只大呆瓜9 小时前
Vite凭什么这么快?3分钟带你彻底搞懂 Vite 热更新的幕后黑手
前端·面试·vite
Maimai1080810 小时前
React如何用 @microsoft/fetch-event-source 落地 SSE:比原生 EventSource 更灵活的实时推送方案
前端·javascript·react.js·microsoft·前端框架·reactjs·webassembly
kyriewen11 小时前
产品经理把PRD写成“天书”,我用AI半小时重写了一遍,他当场愣住
前端·ai编程·cursor
humcomm12 小时前
元框架的工作原理详解
前端·前端框架
canonical_entropy12 小时前
Attractor Before Harness: AI 大规模开发的方法论
前端·aigc·ai编程
zhangxingchao12 小时前
多 Agent 架构到底怎么选?从 Claude Agent Teams、Cognition/Devin 到工程落地原则
前端·人工智能·后端