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(存储结果)问题点
初始化undefinedundefined
收集 fn1getterfn1fn1正常
收集 `fn2getterfn2fn2❌ 依赖覆盖! fn1 被丢弃。
数据更新count.value = 1undefinedfn2setter 触发
执行更新执行 subs?.()`fn12只执行fn2 fn1 失败

实际结果: 只有 count2 被触发并打印,count1 永远收不到更新通知。

链表解决方案:构建多依赖集合 (Dep)

为了存储和管理多个 effect,我们需要一个 集合类数据结构。我们选择 双向链表,因为它能实现 O(1) 时间复杂度 的插入和删除操作,这是响应式系统必需的高效特性。

  1. 数据结构设计:双向链表节点 我们定义 Link 接口来代表链表中的一个节点,它存储一个 effect,并维护前后引用。
// 链表节点接口定义
interface Link {
  sub: Function // 存储当前的 effect 函数
  nextSub: Link | undefined // 指向下一个依赖 (后继)
  prevSub: Link | undefined // 指向上一个依赖 (前驱)
}

class RefImpl {
  // ...
  subs: Link // 订阅者链表的头节点
  subsTail: Link | undefined // 订阅者链表的尾节点 (用于 O(1) 尾部追加)
  // ...
}
  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
}
  1. 依赖触发:遍历链表 在 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 fn1fn1 getterfn1lin1 / link1输出 0
收集 2调用 effect fn2fn2 getterfn2link1 / link2 (尾部追加,link1 ↔ link2)输出 0
数据更新count.value = 1 RefImpl setterundefined不变化
触发更新遍历 setterundefined遍历 link1link2,收集所有 sub
批量执行遍历结束undefinedfn1 fn2 全部执行

状态操作subs(头)subsTail(尾)链表结构
初始undefinedundefinednull
收集 fn1getterlink1(fn1)link1(fn1)link1
收集 fn2getterlink1(fn1)link2(fn2)link1 ↔ link2
触发更新setterlink1 开始遍历到 link2

通过引入双向链表,我们不仅解决了依赖覆盖问题,还为后续的依赖清理(cleanup)和高级优化打下了高性能的 O(1) 基础。