通过 effect 的反向依赖链表复用 Link 节点,避免重新执行时重复收集依赖
上一篇实现了 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。

为什么会重复创建 Link
当前的执行过程:

流程是:
- 初次执行
effect,读取flag.value trackRef调用link,创建一个Link节点- 修改
flag.value,触发flag的依赖链表 effect重新执行,又读取了一次flag.valuetrackRef再次调用link,又创建一个新的Link节点- 后续每次更新都会重复这个过程
现在的 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。
建立反向依赖链表
要避免重复创建 Link,effect 自己也需要知道它依赖过哪些响应式节点。
也就是,在现有的 Ref -> Link -> Effect 关系上,再增加一条从 Effect 出发的反向依赖链表:

拆开来看,现在有三类角色:
dep:被订阅的响应式节点,比如refsub:订阅者,也就是ReactiveEffectLink:连接dep和sub的节点
对应的职责也会变成:
dep.subs:记录哪些effect订阅了当前响应式节点dep.subsTail:dep.subs链表的尾节点sub.deps:记录当前effect依赖了哪些响应式节点sub.depsTail:sub.deps链表的尾节点link.dep:指向被订阅的响应式节点link.sub:指向订阅者effect
修改类型
先修改 Link 和 ReactiveNode 的类型:
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
}
}
前半段还是原来的逻辑:维护 dep 到 sub 的订阅链表。
后半段新增了反向链表:维护 sub 到 dep 的依赖链表。
同一个 Link 同时存在于两条链表中:
dep.subs这条链用来触发更新sub.deps这条链用来在重新执行时复用节点
标记重新执行状态
之后要区分两种情况:
- 第一次执行
effect:没有旧节点可以复用,需要创建新的Link - 重新执行
effect:之前已经收集过依赖,可以尝试复用旧的Link
通过 deps 和 depsTail 表示三种状态:
- 初始状态:
deps和depsTail都是undefined - 重新执行中:保留
deps,但临时把depsTail设置为undefined - 执行完成后:
deps和depsTail都指向有效的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 节点
有了这个状态后,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)
})
当测试用例增加一个新的依赖项时,会发现问题还没有彻底解决:

多节点为什么失败
初始化时,会建立 flag 和 count 两个依赖:

初始化流程是:
- 执行
effect - 读取
flag.value,收集flag依赖 - 读取
count.value,收集count依赖 effect通过deps反向记录这两个依赖节点
第一次触发更新时,会先进入重新执行状态:

此时的状态是:
depsTail被设置为undefineddeps仍然指向第一个旧节点,也就是flag对应的Link- 第一次读取
flag.value时,当前单节点复用逻辑可以命中
当前判断是:
if (currentDep === undefined && sub.deps) {
if (sub.deps.dep === dep) {
sub.depsTail = sub.deps
}
}
所以 flag 节点复用成功,depsTail 会移动到 flag 对应的 Link 上:

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

这时问题出现了:
- 读取
count.value时,会再次进入link - 但
sub.depsTail === undefined已经不成立了 - 当前判断只处理从
sub.deps头节点开始复用的情况 - 所以
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继续检查
这样单节点和多节点复用都走同一套判断逻辑。