在 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.value 是 true,所以会收集 flag 和 name。

当 flag.value = false 后,effect 会重新执行,这次会收集 flag 和 age。

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

为什么会多执行一次

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

流程是:

  1. 初始化时读取 flag.value,收集 flag 依赖,建立 link1
  2. 因为 flag.value 是 true,继续读取 name.value,收集 name 依赖,建立 link2
  3. 修改 flag.value 后,effect 重新执行
  4. 重新执行时走上一章的节点复用逻辑,先把 depsTail 设置为 undefined
  5. 读取 flag.value 时,复用 flag 对应的 link1
  6. 因为此时 flag.value 是 false,继续读取 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 要基于当前这个 effect 的 deps 和 depsTail 做清理,清理完成后再回到外层 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. 先保存当前节点的 prevSub、nextSub、dep 和 nextDep
  2. 如果当前节点前面还有节点,就让前一个节点跳过当前节点
  3. 如果当前节点是头节点,就更新 dep.subs
  4. 如果当前节点后面还有节点,就修正后一个节点的 prevSub
  5. 如果当前节点是尾节点,就更新 dep.subsTail
  6. 最后断开当前 link 和 dep、sub、nextDep 的关系

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

我们的测试用例就通过了