在 effect 重新执行后清理失效依赖,避免旧依赖继续触发更新
上一篇解决了 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。

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

流程是:
- 初始化时读取
flag.value,收集flag依赖,建立link1 - 因为
flag.value是true,继续读取name.value,收集name依赖,建立link2 - 修改
flag.value后,effect重新执行 - 重新执行时走上一章的节点复用逻辑,先把
depsTail设置为undefined - 读取
flag.value时,复用flag对应的link1 - 因为此时
flag.value是false,继续读取age.value,创建age对应的link3 - 但旧的
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
}
}
- 先保存当前节点的
prevSub、nextSub、dep和nextDep - 如果当前节点前面还有节点,就让前一个节点跳过当前节点
- 如果当前节点是头节点,就更新
dep.subs - 如果当前节点后面还有节点,就修正后一个节点的
prevSub - 如果当前节点是尾节点,就更新
dep.subsTail - 最后断开当前
link和dep、sub、nextDep的关系
这样 effect 重新执行后,只保留本次仍然访问到的依赖,旧的分支依赖就不会继续触发更新。
我们的测试用例就通过了