通过 effect 的反向依赖链表复用 Link 节点,避免重新执行时重复收集依赖

2025-09-14 13:17:28

上一篇实现了 scheduler,让 effect 的更新时机可以交给用户控制。

现在继续处理依赖收集里的另一个问题:同一个 effect 重新执行时,会重复创建 Link 节点。

重复收集的问题

先增加一个切换的测试用例:

it('单节点复用', () => {
  const flag = ref(false)
  let runCount = 0

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

  flag.value = !flag.value
  flag.value = !flag.value
  flag.value = !flag.value

  expect(runCount).toBe(4)
})

这个用例里,effect 注册时会先执行一次。

后面连续切换了三次 flag.value,每次都会触发一次更新,所以最终 runCount 应该是 4

当前的执行过程:

流程是:

  1. 初次执行 effect,读取 flag.value
  2. trackRef 调用 link,创建一个 Link 节点
  3. 修改 flag.value,触发 flag 的依赖链表
  4. effect 重新执行,又读取了一次 flag.value
  5. trackRef 再次调用 link,又创建一个新的 Link 节点
  6. 后续每次更新都会重复这个过程

现在的 link 每次都会创建新节点:

export function link(dep: ReactiveNode, sub) {
  const newLink: Link = {
    sub,
    prevSub: undefined,
    nextSub: undefined
  }

  if (dep.subsTail) {
    dep.subsTail.nextSub = newLink
    newLink.prevSub = dep.subsTail
    dep.subsTail = newLink
  }
  else {
    dep.subs = newLink
    dep.subsTail = newLink
  }
}

现在的问题是:link 只知道把当前 effect 追加到 ref 的订阅链表里,但不知道这个 effect 之前是否已经订阅过当前 ref

所以同一个 effect 每次重新执行,都会往 flag.subs 后面追加一个新的 Link

建立反向依赖链表

要避免重复创建 Linkeffect 自己也需要知道它依赖过哪些响应式节点。

也就是,在现有的 Ref -> Link -> Effect 关系上,再增加一条从 Effect 出发的反向依赖链表:

拆开来看,现在有三类角色:

  • dep:被订阅的响应式节点,比如 ref
  • sub:订阅者,也就是 ReactiveEffect
  • Link:连接 depsub 的节点

对应的职责也会变成:

  • dep.subs:记录哪些 effect 订阅了当前响应式节点
  • dep.subsTaildep.subs 链表的尾节点
  • sub.deps:记录当前 effect 依赖了哪些响应式节点
  • sub.depsTailsub.deps 链表的尾节点
  • link.dep:指向被订阅的响应式节点
  • link.sub:指向订阅者 effect

修改类型

先修改 LinkReactiveNode 的类型:

export interface Link {
  sub: ReactiveNode
  prevSub: Link | undefined
  nextSub: Link | undefined

  dep: ReactiveNode
  nextDep: Link | undefined
}

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

  deps?: Link
  depsTail?: Link | undefined
}

新增了两组字段:

  • Link.dep:保存当前 Link 对应的响应式节点
  • Link.nextDep:当前 effect 依赖过的下一个节点
  • ReactiveNode.deps:当前订阅者的依赖链表头节点
  • ReactiveNode.depsTail:当前订阅者的依赖链表尾节点

同时维护两条链表

创建 Link 时,不止要把它挂到 dep.subs 链表上,也要把它挂到 sub.deps 链表上。

export function link(dep: ReactiveNode, sub: ReactiveNode) {
  const newLink: Link = {
    sub,
    prevSub: undefined,
    nextSub: undefined,

    dep,
    nextDep: undefined
  }

  if (dep.subsTail) {
    dep.subsTail.nextSub = newLink
    newLink.prevSub = dep.subsTail
    dep.subsTail = newLink
  }
  else {
    dep.subs = newLink
    dep.subsTail = newLink
  }

  if (sub.depsTail) {
    sub.depsTail.nextDep = newLink
    sub.depsTail = newLink
  }
  else {
    sub.deps = newLink
    sub.depsTail = newLink
  }
}

前半段还是原来的逻辑:维护 depsub 的订阅链表。

后半段新增了反向链表:维护 subdep 的依赖链表。

同一个 Link 同时存在于两条链表中:

  • dep.subs 这条链用来触发更新
  • sub.deps 这条链用来在重新执行时复用节点

标记重新执行状态

之后要区分两种情况:

  • 第一次执行 effect:没有旧节点可以复用,需要创建新的 Link
  • 重新执行 effect:之前已经收集过依赖,可以尝试复用旧的 Link

通过 depsdepsTail 表示三种状态:

  1. 初始状态:depsdepsTail 都是 undefined
  2. 重新执行中:保留 deps,但临时把 depsTail 设置为 undefined
  3. 执行完成后:depsdepsTail 都指向有效的 Link 节点

所以在 ReactiveEffect.run 开始时,把 depsTail 重置为 undefined

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

  constructor(public fn) { }

  run() {
    const prevSub = activeSub
    activeSub = this

    this.depsTail = undefined

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

这并不是清空依赖链表。

deps 头节点还在,表示之前收集过的依赖仍然可以用来复用。

depsTail 被重置为 undefined,表示要重新复用节点

有了这个状态后,link 里就可以先判断是否能够复用旧节点。

export function link(dep: ReactiveNode, sub: ReactiveNode) {
  const currentDep = sub.depsTail

  if (currentDep === undefined && sub.deps) {
    if (sub.deps.dep === dep) {
      sub.depsTail = sub.deps
      return
    }
  }

  const newLink: Link = {
    sub,
    prevSub: undefined,
    nextSub: undefined,

    dep,
    nextDep: undefined
  }

  // 继续执行创建新 Link 的逻辑
}
  • currentDep === undefined:说明这次重新执行还没有确认过任何依赖
  • sub.deps 存在:说明之前已经收集过依赖
  • sub.deps.dep === dep:说明之前第一个依赖就是当前这个 dep

满足这三个条件时,就不需要创建新的 Link

只要把 sub.depsTail 指回已经存在的 sub.deps,就表示当前依赖已经被确认并复用了。

多节点复用的问题

再增加一个依赖项:

it('多节点复用', () => {
  const flag = ref(false)
  const count = ref(0)
  let runCount = 0

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

  flag.value = !flag.value
  flag.value = !flag.value
  flag.value = !flag.value

  count.value++
  count.value++

  expect(runCount).toBe(6)
})

当测试用例增加一个新的依赖项时,会发现问题还没有彻底解决:

多节点为什么失败

初始化时,会建立 flagcount 两个依赖:

初始化流程是:

  1. 执行 effect
  2. 读取 flag.value,收集 flag 依赖
  3. 读取 count.value,收集 count 依赖
  4. effect 通过 deps 反向记录这两个依赖节点

第一次触发更新时,会先进入重新执行状态:

此时的状态是:

  1. depsTail 被设置为 undefined
  2. deps 仍然指向第一个旧节点,也就是 flag 对应的 Link
  3. 第一次读取 flag.value 时,当前单节点复用逻辑可以命中

当前判断是:

if (currentDep === undefined && sub.deps) {
  if (sub.deps.dep === dep) {
    sub.depsTail = sub.deps
  }
}

所以 flag 节点复用成功,depsTail 会移动到 flag 对应的 Link 上:

接着继续执行 effect,读取 count.value

这时问题出现了:

  1. 读取 count.value 时,会再次进入 link
  2. sub.depsTail === undefined 已经不成立了
  3. 当前判断只处理从 sub.deps 头节点开始复用的情况
  4. 所以 count 对应的旧节点没有被复用,反而重新创建了一个新的 Link

effect 有多个依赖时,复用完第一个节点后,还需要继续往后检查下一个旧节点。

把 depsTail 当成进度指针

之前把 depsTail = undefined 当成是否需要复用的标记。

但在多节点场景里,depsTail 更适合被当成一个进度指针:它表示当前复用检查已经进行到了旧链表的哪个位置。

规则可以改成:

  • depsTail === undefined:从 sub.deps 开始检查
  • depsTail 存在:从 depsTail.nextDep 继续检查
const currentDep = sub.depsTail

if (currentDep === undefined && sub.deps) {
  if (sub.deps.dep === dep) {
    sub.depsTail = sub.deps
  }
}
else if (currentDep) {
  if (currentDep.nextDep?.dep === dep) {
    sub.depsTail = currentDep.nextDep
  }
}

这样第一个依赖会从 sub.deps 开始检查,后续依赖会从 currentDep.nextDep 继续检查。

简化复用逻辑

虽然上面的代码已经能跑通,但这段逻辑的核心其实只有一个:找到下一个待检查的旧节点。

所以可以把它简化成:

const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep

if (nextDep && nextDep.dep === dep) {
  sub.depsTail = nextDep
}

这里统一了两种情况:

  • 第一个依赖从 sub.deps 开始检查
  • 后续依赖从 currentDep.nextDep 继续检查

这样单节点和多节点复用都走同一套判断逻辑。