在 effect 重新执行后清理失效依赖,避免旧依赖继续触发更新

2025-09-14 13:17:28

上一篇解决了 Link 节点复用的问题,避免同一个 effect 重新执行时重复收集依赖。

但复用节点之后,还需要处理另一个问题:依赖是否仍然有效。

比如条件分支发生变化时,effect 本次访问到的依赖可能和上一次不一样。上一次收集到的旧依赖,如果这次没有再访问,就应该从依赖链表里清理掉。

依赖切换的问题

先看一个测试用例:

it('依赖清理', () => {
  const flag = ref(true)
  const name = ref('zhangsan')
  const age = ref(12)
  const fn = vi.fn()
  let text = ''

  effect(() => {
    if (flag.value) {
      text = `姓名: ${name.value}!`
    }
    else {
      text = `年龄: ${age.value}!`
    }

    fn()
  })

  expect(text).toBe('姓名: zhangsan!')
  expect(fn).toBeCalledTimes(1)

  flag.value = false
  expect(text).toBe('年龄: 12!')
  expect(fn).toBeCalledTimes(2)

  name.value = 'lisi'
  expect(text).toBe('年龄: 12!')
  expect(fn).toBeCalledTimes(2)
})

这个用例里,fn 期望只执行两次:

  • 第一次是 effect 注册时立即执行
  • 第二次是修改 flag.value 后重新执行

初始化时,flag.valuetrue,所以会收集 flagname

flag.value = false 后,effect 会重新执行,这次会收集 flagage

此时 name 已经不是当前分支的依赖了,所以后面修改 name.value 时,不应该再次触发 effect

为什么会多执行一次

目前实际会多执行一次,原因是旧依赖没有被清理掉:

流程是:

  1. 初始化时读取 flag.value,收集 flag 依赖,建立 link1
  2. 因为 flag.valuetrue,继续读取 name.value,收集 name 依赖,建立 link2
  3. 修改 flag.value 后,effect 重新执行
  4. 重新执行时走上一章的节点复用逻辑,先把 depsTail 设置为 undefined
  5. 读取 flag.value 时,复用 flag 对应的 link1
  6. 因为此时 flag.valuefalse,继续读取 age.value,创建 age 对应的 link3
  7. 但旧的 link2 仍然挂在 name.subs 链表上

所以后面修改 name.value 时,name.subs 里仍然能找到这个 effect,于是又触发了一次更新。

还有没有收集任何依赖的情况

除了分支切换导致部分依赖失效,还有一种情况:本次执行提前返回,导致没有访问任何响应式数据。

it('依赖全部清理', () => {
  const flag = ref(true)
  const name = ref('zhangsan')
  const age = ref(12)
  const shouldReturn = ref(false)
  const fn = vi.fn()
  let text = ''

  effect(() => {
    if (shouldReturn.value) {
      return
    }

    if (flag.value) {
      text = `姓名: ${name.value}!`
    }
    else {
      text = `年龄: ${age.value}!`
    }

    fn()
  })

  expect(text).toBe('姓名: zhangsan!')
  expect(fn).toBeCalledTimes(1)

  shouldReturn.value = true

  name.value = 'lisi'
  expect(fn).toBeCalledTimes(1)
})

如果 effect 执行完成后,depsTail 仍然是 undefined,但 deps 头节点存在,说明本次执行没有确认任何旧依赖。

这时旧的依赖链表就应该全部清理掉。

解决思路

依赖清理要解决两种情况:

  • 本次只访问了一部分旧依赖:清理 depsTail 后面的节点
  • 本次没有访问任何旧依赖:清理整条 deps 链表

上一章已经把 depsTail 当成了复用进度指针。

现在可以继续利用它:

  • depsTail 表示本次执行最后一个有效依赖
  • depsTail.nextDep 后面的节点,就是本次没有访问到的旧依赖
  • 如果 depsTail 不存在但 deps 存在,说明所有旧依赖都失效

保留 nextDep

清理失效依赖时,需要沿着 effect.deps 链表继续往后走。

所以创建新节点时,要保留后续节点的指针:

// system.ts
const newLink: Link = {
  sub,
  prevSub: undefined,
  nextSub: undefined,

  dep,
  nextDep
}

这里的 nextDep 用来连接当前 effect 的依赖链表。

当某个节点后面的依赖失效时,就可以从 nextDep 开始继续清理。

在 run 结束时清理依赖

// effect.ts
export class ReactiveEffect {
  // ...

  run() {
    const prevSub = activeSub
    activeSub = this

    this.depsTail = undefined

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

endTrack 要基于当前这个 effectdepsdepsTail 做清理,清理完成后再回到外层 effect

因为只有执行完,才知道这次到底访问了哪些依赖。

endTrack 负责判断要清理哪一段依赖:

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

  if (depsTail?.nextDep) {
    clearTracking(depsTail.nextDep)
    depsTail.nextDep = undefined
  }
  else if (!sub.depsTail && sub.deps) {
    clearTracking(sub.deps)
    sub.deps = undefined
  }
}

这里分成两种情况:

  • depsTail?.nextDep 存在:说明 depsTail 后面的依赖本次没有访问到,需要清理尾部
  • !sub.depsTail && sub.deps:说明本次没有确认任何依赖,需要清理整条旧链表

clearTracking 的实现

clearTracking 要做的事情是:把失效的 Link 从对应的 dep.subs 链表里移除。

function clearTracking(link: Link) {
  while (link) {
    const { prevSub, nextSub, dep, nextDep } = link

    if (prevSub) {
      prevSub.nextSub = nextSub
      link.nextSub = undefined
    }
    else {
      dep.subs = nextSub
    }

    if (nextSub) {
      nextSub.prevSub = prevSub
      link.prevSub = undefined
    }
    else {
      dep.subsTail = prevSub
    }

    link.dep = link.sub = undefined
    link.nextDep = undefined
    link = nextDep
  }
}
  1. 先保存当前节点的 prevSubnextSubdepnextDep
  2. 如果当前节点前面还有节点,就让前一个节点跳过当前节点
  3. 如果当前节点是头节点,就更新 dep.subs
  4. 如果当前节点后面还有节点,就修正后一个节点的 prevSub
  5. 如果当前节点是尾节点,就更新 dep.subsTail
  6. 最后断开当前 linkdepsubnextDep 的关系

这样 effect 重新执行后,只保留本次仍然访问到的依赖,旧的分支依赖就不会继续触发更新。

我们的测试用例就通过了