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

相关推荐
ziyue75757 分钟前
vue修改element-ui的默认的class
前端·vue.js·ui
树叶会结冰28 分钟前
HTML语义化:当网页会说话
前端·html
冰万森33 分钟前
解决 React 项目初始化(npx create-react-app)速度慢的 7 个实用方案
前端·react.js·前端框架
牧羊人_myr1 小时前
Ajax 技术详解
前端
浩男孩1 小时前
🍀封装个 Button 组件,使用 vitest 来测试一下
前端
蓝银草同学1 小时前
阿里 Iconfont 项目丢失?手把手教你将已引用的 SVG 图标下载到本地
前端·icon
布列瑟农的星空1 小时前
重学React —— React事件机制 vs 浏览器事件机制
前端
程序定小飞2 小时前
基于springboot的在线商城系统设计与开发
java·数据库·vue.js·spring boot·后端
一小池勺2 小时前
CommonJS
前端·面试