通过保存并恢复 activeSub,解决嵌套 effect 中外层依赖丢失的问题

2025-09-14 13:17:28

上一篇把 effect 包装成了 ReactiveEffect,并把依赖收集和派发更新拆到了 trackReftriggerRefsystem.ts 里。

还是熟悉的场景:嵌套 effect

嵌套 effect 的问题

先增加一个测试用例:

it('嵌套的 effect', () => {
  const count = ref(0)
  let runCount = 0

  effect(() => {
    effect(() => {
      count.value
      runCount++
    })

    count.value
    runCount++
  })

  count.value = 1

  expect(runCount).toBe(4)
})

默认执行时,内层 effect 和外层 effect 都会读取 count.value

所以第一次注册时会执行两次:

  • 内层 effect 执行一次,runCount 变成 1
  • 外层 effect 执行一次,runCount 变成 2

count.value = 1 后,按预期两个 effect 都应该重新执行,所以 runCount 至少应该变成 4

但当前实际只执行了三次。

关键原因是:count 的依赖链表上只有内层的 ReactiveEffect2,外层的 ReactiveEffect1 丢失了。

少执行一次的原因

当前 ReactiveEffect.run 的逻辑:

run() {
    activeSub = this

    try {
        return this.fn()
    }
    finally {
        activeSub = undefined
    }
}

执行嵌套 effect 时,会发生这样的过程:

  1. 执行外层 effect,此时 activeSub = ReactiveEffect1
  2. 外层函数内部又执行了内层 effect
  3. 执行内层 effect,此时 activeSub = ReactiveEffect2
  4. 内层读取 count.value,收集到 ReactiveEffect2
  5. 内层执行结束,把 activeSub 重置为 undefined
  6. 回到外层继续执行,外层读取 count.value
  7. 但此时 activeSub 已经是 undefined,所以外层依赖没有被收集

也就是说,内层 effect 执行完以后,把外层正在使用的 activeSub 清掉了。

所以后面触发更新时,count 只知道自己依赖了内层 effect,不知道还有外层 effect

解决方式

这个问题和之前“后面的订阅者覆盖前面的订阅者”很像,本质上都是当前状态被覆盖了。

这里可以把 activeSub 当成一个调用栈来处理:

  • 进入当前 effect 前,先保存上一个 activeSub
  • 当前 effect 执行时,把 activeSub 设置成自己
  • 当前 effect 执行完后,再恢复成上一个 activeSub
export let activeSub: ReactiveEffect | undefined

export class ReactiveEffect {
  constructor(public fn: Function) {
  }

  run() {
    const prevSub = activeSub
    activeSub = this

    try {
      return this.fn()
    }
    finally {
      activeSub = prevSub
    }
  }
}

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

这样内层 effect 执行结束后,不会直接把 activeSub 清空,而是恢复成外层的 ReactiveEffect1

接着外层继续读取 count.value 时,就能正常收集到外层依赖。

更新后多执行一次

修复 activeSub 后,外层依赖确实不会丢了。

但这个测试还有一个细节:更新时会比预期多执行一次。

触发更新时,propagate 会遍历 count 的依赖链表。假设链表里有两个依赖:

  • ReactiveEffect2:内层 effect
  • ReactiveEffect1:外层 effect

更新过程大概是:

  1. 执行内层 ReactiveEffect2.run()
  2. 内层函数执行,runCount++
  3. 执行外层 ReactiveEffect1.run()
  4. 外层函数内部会重新创建一个全新的内层 effect
  5. 新的内层 effect 会立即执行,runCount++
  6. 外层函数继续执行,runCount++

所以外层 effect 每次重新执行时,都会重新创建一个新的内层 effect

如果内层逻辑是网络请求、大数据量计算,或者会触发 DOM 重新布局,这种重复执行的成本就会很高。

这也是官方不推荐书写嵌套 effect 的原因。