2025.09.03
链式追踪:优化多依赖监听的解决方案
问题背景:❓ 问题背景:一个数据,多个观察者
在实际应用中,一个响应式数据(如 count)经常需要被多个副作用函数effect 监听。当数据源变化时,所有依赖于它的 effect
都必须被通知并重新执行。
示例代码:
const count = ref(0)
// effect 1:监听 count
effect(() => {
console.log('💚 count1 ==> ', count.value)
})
// effect 2:也监听 count
effect(() => {
console.log('💙 count2 ==> ', count.value)
})
// 1秒后更新数据:预期两个 effect 都被触发
setTimeout(() => {
count.value = 1
}, 1000)
预期行为: 两次 effect 的输出都应该在 1 秒后更新为 1。
❌ 基础实现的缺陷:致命的依赖覆盖
在我们最初的简易实现中,我们仅使用了一个变量 this.subs 来存储依赖函数,这导致了严重的 依赖覆盖 问题。
🚨 问题代码分析
让我们看看问题出在哪里:
get value() {
// 收集依赖 - 问题所在:直接赋值导致依赖被覆盖
if (activeSub) {
this.subs = activeSub // 这里直接覆盖了之前的依赖
}
return this._value
}
执行流程分析
| 阶段 | 触发操作 | activeSub(当前值) | ref.subs(存储结果) | 问题点 |
|---|---|---|---|---|
| 初始化 | undefined | undefined | ||
收集 fn1 | getter | fn1 | fn1 | 正常 |
| 收集 `fn2 | getter | fn2 | fn2 | ❌ 依赖覆盖! fn1 被丢弃。 |
| 数据更新 | count.value = 1 | undefined | fn2 | setter 触发 |
| 执行更新 | 执行 subs?.() | `fn12 | 只执行fn2 fn1 失败 |
实际结果: 只有 count2 被触发并打印,count1 永远收不到更新通知。
链表解决方案:构建多依赖集合 (Dep)
为了存储和管理多个 effect,我们需要一个 集合类数据结构。我们选择 双向链表,因为它能实现 O(1) 时间复杂度
的插入和删除操作,这是响应式系统必需的高效特性。
- 数据结构设计:双向链表节点 我们定义 Link 接口来代表链表中的一个节点,它存储一个 effect,并维护前后引用。
// 链表节点接口定义
interface Link {
sub: Function // 存储当前的 effect 函数
nextSub: Link | undefined // 指向下一个依赖 (后继)
prevSub: Link | undefined // 指向上一个依赖 (前驱)
}
class RefImpl {
// ...
subs: Link // 订阅者链表的头节点
subsTail: Link | undefined // 订阅者链表的尾节点 (用于 O(1) 尾部追加)
// ...
}
- 依赖收集:O(1) 尾部追加
在
getter中,我们不再覆盖,而是将新的effect作为一个节点追加到链表的尾部。利用subsTail指针,确保追加操作是 O( 1)。
get value() {
if (activeSub) {
// 1. 创建新的链表节点
const newLink: Link = { /* sub: activeSub, ... */}
if (this.subsTail) {
// 2. 链表不为空:追加到尾部,并维护双向链接
this.subsTail.nextSub = newLink
newLink.prevSub = this.subsTail
this.subsTail = newLink // 更新尾指针
} else {
// 3. 链表为空:初始化头尾节点
this.subs = newLink
this.subsTail = newLink
}
}
return this._value
}
- 依赖触发:遍历链表 在 setter 中,我们遍历整个链表,收集所有 effect 并逐一执行。
set value(newValue) {
this._value = newValue
// 1. 遍历链表,收集所有 effect
let link = this.subs
let queuedEffect: Function[] = []
while (link) {
queuedEffect.push(link.sub)
link = link.nextSub // 沿着 nextSub 引用前进
}
// 2. 批量执行所有 effect,确保所有依赖都被触发
queuedEffect.forEach(effect => effect())
}
📈 解决后的流程分析
使用链表管理依赖后,执行流程变得完整和正确:
| 阶段 | 关键操作 | 触发点 | activeSub 状态 | RefImpl链表状态(subs/subsTail) | 结果 |
|---|---|---|---|---|---|
| 收集 1 | 调用 effect fn1 | fn1 getter | fn1 | lin1 / link1 | 输出 0 |
| 收集 2 | 调用 effect fn2 | fn2 getter | fn2 | link1 / link2 (尾部追加,link1 ↔ link2) | 输出 0 |
| 数据更新 | count.value = 1 | RefImpl setter | undefined | 不变化 | |
| 触发更新 | 遍历 | setter | undefined | 遍历 link1 到 link2,收集所有 sub。 | |
| 批量执行 | 遍历结束 | undefined | fn1 fn2 全部执行 |

| 状态 | 操作 | subs(头) | subsTail(尾) | 链表结构 |
|---|---|---|---|---|
| 初始 | undefined | undefined | null | |
收集 fn1 | getter | link1(fn1) | link1(fn1) | link1 |
收集 fn2 | getter | link1(fn1) | link2(fn2) | link1 ↔ link2 |
| 触发更新 | setter | 从 link1 开始遍历到 link2 |
通过引入双向链表,我们不仅解决了依赖覆盖问题,还为后续的依赖清理(cleanup)和高级优化打下了高性能的 O(1) 基础。