2025.09.11

依赖有效性:响应式系统的“依赖清理”挑战

🧐 什么是依赖有效性?

在 Vue 响应式系统中,一个 effect(例如 computed 或组件渲染函数)的执行路径是动态的。它依赖的响应式数据(refreactive 对象)集合可能会随着执行时的条件判断或逻辑分支而变化。

依赖有效性关注的核心问题是:如何确保 effect 只保留当前执行路径中真正需要的依赖,并及时清除不再需要的“过期依赖”?

如果“过期依赖”没有被正确清理,将导致以下两个主要问题:

  1. 🗑️ 内存泄漏 (Memory Leak): 不再需要的依赖关系(如链表节点)持续占用内存,无法被垃圾回收。
  2. 🔄 不必要的更新 (Unnecessary Update): effect 实际上已不再访问某个 ref,但该 ref 的变化仍会触发 effect 重新执行,造成性能浪费和逻辑错误。

💡 异常情况 1: 条件型依赖的切换

以下代码展示了基于 flag 的条件渲染,使得 nameage 成为互斥的条件依赖:


<div id="app"></div>
<button id="flagBtn">update flag</button>
<script type="module">
  const flag = ref(true)
  const name = ref('zhangsan')
  const age = ref(12)

  effect(() => {
    console.count('effect')
    if (flag.value) {
      app.innerHTML = `姓名: ${name.value}!` // 依赖 name
    } else {
      app.innerHTML = `年龄: ${age.value}!` // 依赖 age
    }
  })
  // ... 按钮点击事件 ...
</script>

🔍 异常现象分析

  1. 首次执行(flag = true): effect 收集到依赖: flag name
  2. 点击 flagBtn(flag = false): effect 重新运行, 进 else, 收集到新依赖 flag age
  3. 异常结果: 此时 effect 运行路径已不访问 name, 但在 name 的订阅集合(Subs)中, 仍然保留着执行该 effect 的连接
步骤示意图说明
① 首次执行收集 flag 依赖,建立 link1
② 进入 if 分支flagtrue,继续收集 name 依赖,建立 link2
③ 点击按钮更新 flageffect 重新运行,depsTail 被重置为 undefined,准备重新收集依赖
④ 复用 flag 节点检测到 link1 可复用,depsTail 指向 link1,此时 flag 值变为 false
⑤ 收集新依赖 age进入 else 分支,新建 link3 并追加到 deps 链表尾部

❗ 根本原因: 旧的依赖(name)在 effect 自身的依赖链表 (deps) 中被移除,但在 name 的订阅者链表 (subs) 中未被解除引用,导致 name 变化时仍会错误地通知 effect

💡 异常情况 2: effect 提前返回/中止

以下代码在 effect 首次运行后,通过 count > 0 的条件实现提前返回,理论上阻止了后续的依赖收集:


<script type="module">
  // ... ref 定义 ...
  let count = 0
  effect(() => {
    console.count('effect')
    if (count > 0) return  // ❗ 提前返回
    count++
    // ... 首次执行时会访问 flag 和 name ...
  })
  // ... 按钮点击事件 ...
</script>

期望行为: effect 运行一次(或两次,取决于何时进行依赖清理),之后任何依赖的变更都不应触发它。 实际现象: 持续点击 nameBtnconsole.count('effect') 计数不断增加。

🔍 异常现象分析

  1. 首次执行(count = 0)
    • count++ 变为 1
    • 执行后续逻辑, 收集到 flagname 依赖
    • effect.deps 链表结构: flag -> name
  2. 点击 nameBtn 触发更新:
    • effect 重新执行 run(), 但在函数体执行前, 会重置依赖收集器(depsTail) 设为 undefined
    • 函数体执行时遇到 if(count > 0) return 立即返回
    • 本次执行没收集到任何依赖
  3. 异常结果:
    • 虽然本次执行没有收集依赖, 但旧的依赖(flagname)并没被清理
    • name 的订阅集合中, 该 effect 依然存在
    • name 变化通知 effect, effect 重新执行, 进入提前返回, 形成不必要的循环

❗ 根本原因: effect 在每次重新执行时,必须先完整地清理所有旧的依赖,然后再根据当前执行路径重新收集。如果执行提前返回,导致新的依赖收集过程被中止,而旧依赖又没有被清理干净,就会残留“过时”的订阅关系。

⚙️ 解决方案

effect 重新执行之前, 需要:

  1. 遍历该 effect 之前收集到的所有依赖 (通过 effect.deps 链表)
  2. 解除 effect 与这些依赖(如nameagesubs 列表之间的双向引用关系)
  3. 重置 effect 的依赖链表头和尾指针, 回到 "待收集" 状态

这样,即使 effect 提前返回或进入新的条件分支,残留的旧依赖也会在执行前被完全移除,从而避免内存泄漏和不必要的更新。