通过 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
}

subs 和 subsTail 用来保存订阅当前节点的 effect。

deps 和 depsTail 用来保存当前 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 不会被放回更新队列,因此不会进入无限循环。