2025.09.07

重复收集陷阱:Effect 触发的性能危机

💥 问题重现:失控的 effect

经过模块化重构的响应式系统,在与 DOM 交互结合时,暴露了一个严重的性能问题:每次触发更新,effect 的执行次数都呈指数级增长。

示例代码与现象:


<button id="btn">切换状态</button>
<script type="module">
  import {ref, effect} from '../dist/reactivity.esm.js'

  const flag = ref(true)

  effect(() => {
    flag.value // 依赖收集点
    console.count('effect 触发次数') // 计数器
  })

  btn.onclick = () => {
    flag.value = !flag.value // 触发更新
  }
</script>

运行结果显示:第一次点击执行 1 次,第二次点击执行 2 次,第三次 4 次,第四次 8 次... 触发次数呈 $2^n$ 增长。

🔬 深入分析:重复收集的恶性循环

这个问题的根源在于 每次 effect 重新执行时,它都会进行重复的依赖收集。

  1. 初始化与首次收集
    • 操作: 页面首次加载, effect.run() 执行
    • 结果: 读取 count.value 触发 getter
    • 依赖链: 创建 link1, flag 成功依赖 effect
    • 链表长度: 1
  2. 首次点击: 依赖翻倍
    • 操作: flag.value 变化,触发 setterpropagate 执行。
    • 触发: 找到 link1,执行 effect.run() (1 次)
    • 重新收集: effect 再次运行,再次读取 flag.value,又触发 getter
    • 结果: 重复收集,创建 link2
    • 链表长度: 2(包含两个指向同一个 effect 的链接节点)。
  3. 第二次点击: 指数级翻倍
    • 操作: 再次点击按钮,触发 setter
    • 触发: propagate 发现链表长度为 2,因此 effect.run() 被执行 2 次。
    • 重新收集: 两次执行,导致创建 2 个 新链接节点。
    • 链表长度: 4(每次点击都使链表长度翻倍,下次将执行 4 次)。

💡 问题本质与解决方案

核心问题: 重复收集与盲目执行

  1. 盲目添加: 每次 getter 触发依赖收集时,都 没有检查 当前 effect 是否已存在。
  2. 恶性循环: 链表长度 L -> 触发次数 L -> 新增节点数 L -> 下次链表长度 L + L。
  3. 本质: 我们的双向链表没有实现 Set(集合) 的去重特性。

🛡️ 解决方案

要解决这个问题,我们需要在依赖收集过程中实现去重机制

  思路: 在收集依赖之前,必须检查当前 effect 是否已经存在于该响应式数据的依赖链中。

通过实现去重机制,我们能确保:一个响应式数据,对同一个 effect 只收集一次依赖。