通过 tracking 标记跳过正在执行的 effect,避免副作用函数修改自身依赖时无限循环

2025-09-14 13:17:28

上一篇处理了依赖有效性问题,让 effect 重新执行后可以清理失效依赖。

现在还有一个稳定性问题:如果 effect 内部修改了自己依赖的数据,就可能触发自己再次执行。

it('无限循环的问题处理', () => {
  const count = ref(0)

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

这段代码会出现无限循环:

为什么会无限循环

count.value++ 同时包含读取和修改,可以拆成下面这样:

effect(() => {
  const value = count.value
  count.value = value + 1
})

执行过程是:

  1. 读取 count.value,触发依赖收集
  2. 当前 effect 被注册为 count 的订阅者
  3. 修改 count.value,触发更新
  4. 更新时遍历 count 的所有订阅者
  5. 当前正在执行的 effect 本身也在订阅链表里
  6. 于是这个 effect 又被重新执行
  7. 重新执行后又读取、修改、触发更新,最终进入无限循环

解决思路

effect 增加一个 tracking 标记。 表示当前 effect 是否正在执行并收集依赖:

在 ReactiveEffect 中增加 tracking

先让 ReactiveEffect 实现 ReactiveNode,并增加 tracking 字段:

export class ReactiveEffect implements ReactiveNode {
  deps: Link | undefined
  depsTail: Link | undefined

  tracking = false

  constructor(public fn: Function) {
  }

  run() {
    const prevSub = activeSub
    activeSub = this

    startTrack(this)

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

这里把开始追踪和结束追踪分别交给两个函数:

  • startTrack(this):进入追踪状态
  • endTrack(this):结束追踪,并清理失效依赖

这样 ReactiveEffect.run 只负责执行流程,依赖链表相关的细节继续放到 system.ts 里。

把追踪逻辑迁移到 system.ts

上一章里的依赖清理逻辑包括:

  • startTrack
  • endTrack
  • clearTracking

这些逻辑本质上都在维护响应式系统里的链表关系,所以可以统一放到 system.ts 中。

这样 effect.ts 只需要在合适的时机调用它们,不需要关心具体怎么清理依赖。

修改 ReactiveNode 类型

因为 ReactiveEffect 现在也实现了 ReactiveNode,所以需要在 ReactiveNode 上增加 tracking 字段:

export interface ReactiveNode {
  subs?: Link
  subsTail?: Link | undefined

  deps?: Link
  depsTail?: Link | undefined

  tracking?: boolean
}

subssubsTail 用来保存订阅当前节点的 effect

depsdepsTail 用来保存当前 effect 依赖过的响应式节点。

tracking 用来标记当前节点是否正在执行依赖收集。

startTrack 和 endTrack

startTrack 负责进入追踪状态:

export function startTrack(sub: ReactiveNode) {
  sub.depsTail = undefined
  sub.tracking = true
}

endTrack 负责退出追踪状态,

export function endTrack(sub: ReactiveNode) {
  const depsTail = sub.depsTail

  sub.tracking = false
  // ...
}

更新派发时跳过正在执行的 effect

最后修改 propagate

触发更新时,遍历依赖链表,但只把没有处于执行中的 effect 加入队列:

export function propagate(subs: Link) {
  const queuedEffect = []
  let linkNode = subs

  while (linkNode) {
    const sub = linkNode.sub

    if (!sub.tracking) {
      queuedEffect.push(sub)
    }

    linkNode = linkNode.nextSub
  }

  queuedEffect.forEach(effect => effect.notify())
}

这样 count.value++ 仍然会触发 set,但当前正在运行的 effect 不会被放回更新队列,因此不会进入无限循环。