从零到一打造 Vue3 响应式系统 Day 11 - Effect:Link 节点的复用实现

昨天我们发现了 Effect 的问题:当 effect 被重复触发时,它会不断地重新收集依赖,导致依赖链表指数级增长。

要让 effect 记住它"订阅过谁",最直接的方法就是让它自己也持有一个引用列表。因此,我们分为两大步来解决:

  • 建立反向依赖链表 :创建一个新的链表,让 effect 知道自己已经订阅过哪些 ref。只要 effect 知道自己订阅过哪些依赖,就可以避免新增多余的链表节点,从而形成一个双向的追踪关系。
  • 实现节点复用机制:下次再次触发更新时,就可以借由查找已订阅的依赖来进行判断。如果第一次执行时已经收集过依赖,就复用之前的链表节点,不再创建新的节点;如果之前没有收集过,就创建一个全新的链表节点。

关键要素就是:

  1. 需要创建一个新的链表让 effect 记录曾经收集过的依赖,这个链表我们称之为 deps
  2. 需要一个方法来判断 effect 是否正在重新执行依赖收集。

初始化页面

在之前的步骤中,刚进入页面时,effect 收集依赖,ref 的头节点 subs 以及尾节点 subsTail 指向 linklinksub 指向 effect

步骤一:建立反向依赖链表

我们现在要做的是,在我们现有的 Ref -> Link -> Effect 关系上,新增一条从 Effect 出发的反向依赖链接。

之前提到过一个链表的必要元素分别是:

  • 头节点
  • 尾节点
  • 彼此建立的关联

如上图,目前页面上只有一个依赖 flag.value。我们可以让 effect 上的链表的头节点 deps 和尾节点 depsTail 指向 link,同时 link 内部的 dep 指向该依赖 (ref)。这样,我们就可以通过关系链找到 effect 订阅过的所有依赖。

因此我们可以明确三个关键的角色。

三个关键角色

Effect

  • effect.deps 链表:通过 link,记录该 effect 依赖了哪些 ref
  • effect.depsTail:记录链表尾部,以便可以快速增加新的链表节点。

Ref(flag)

  • flag.subs 链表:通过 link,记录有哪些 effect 订阅了此 ref
  • flag.subsTail:记录链表尾部,以便可以快速增加新的链表节点。

Link:双向桥梁节点

Link 是连接 EffectRef 的桥梁,它同时存在于两个链表中

核心属性:

  • link.sub:指向发起的订阅者 (effect)。
  • link.dep:指向被订阅的 ref

在 Effect 链表中的位置:

  • link.nextDep/prevDep:指向 effect.deps 链表的下/上一个节点。

在 Ref 链表中的位置:

  • link.nextSub/prevSub:指向 ref.subs 链表的下/上一个节点。

通过上面的方法,我们可以知道三件事:

  1. 双向查询 :通过 Link 可以同时找到 effectref

  2. 双链表成员Link 同时是两个链表的成员。

    • effect.deps 链表的一个节点。
    • ref.subs 链表的一个节点。
  3. 关系管理 :一个 Link 代表一个订阅关系。

首先我们更新 effect.tssystem.ts 来实现这个新的数据结构。

定义类型

effect.ts

TypeScript

arduino 复制代码
export class ReactiveEffect { 
  // 依赖项链表的头节点,指向 link
  deps: Link
  // 依赖项链表的尾节点,指向 link
  depsTail: Link
  
  //...
}

system.ts

TypeScript

typescript 复制代码
//system.ts

/**
 * 依赖项 (如 ref)
 */
interface Dep {
  // 订阅者链表头节点
  subs: Link | undefined
  // 订阅者链表尾节点
  subsTail: Link | undefined
}
/**
 * 订阅者 (如 effect)
 */
interface Sub {
  // 依赖项链表头节点
  deps: Link | undefined
  // 依赖项链表尾节点
  depsTail: Link | undefined
}

export interface Link {
  // 订阅者
  sub: Sub
  // 下一个订阅者节点
  nextSub: Link
  // 上一个订阅者节点
  prevSub: Link
  // 依赖项
  dep: Dep

  // 下一个依赖项节点
  nextDep: Link | undefined
}

接着,修改 link 函数,在创建节点时,也将其加入 subdeps 链表。

TypeScript

perl 复制代码
//system.ts
export function link(dep, sub){
    const newLink: Link = {
      sub,
      dep, // 加上依赖项
      nextDep: undefined,
      nextSub: undefined,
      prevSub: undefined
    }
    // ... (加入 dep.subs 链表的逻辑保持不变)
    
    /**
     * 将链表节点跟 sub (effect) 建立关联关系
     * 1. 如果存在尾节点,表示链表中已有节点,在链表尾部新增。
     * 2. 如果不存在尾节点,表示这是第一次关联链表,第一个节点既是头节点也是尾节点。
     */
    if(sub.depsTail){
      sub.depsTail.nextDep = newLink
      sub.depsTail = newLink
    } else {
      sub.deps = newLink
      sub.depsTail = newLink
    }
}

步骤二:实现节点复用机制

每次 effect 重新执行时,如何判断是"第一次执行"还是"重新执行"?

我们可以利用 effect 上的头节点 deps 与尾节点 depsTail 来设定三种状态:

  • 初始状态(从未执行过依赖收集)effectdeps 链表是空的,depsdepsTail 都是 undefined
  • 重新执行中(需要复用节点) :我们将 depsTail 临时设为 undefined,但保留 deps 头节点。
  • 执行完成(链表更新完成)depsdepsTail 都有值(指向 Link 节点)。

effect 开始重新执行时,我们将 depsTail 设为 undefined,但保留 deps 头节点。这样做的目的是:

  1. 标记状态 :标记 effect 正处于"重新收集中"的状态,让 link 函数知道需要复用节点。
  2. 保留旧依赖deps 链表仍然包含之前收集的所有依赖,方便我们遍历复用。
  3. 移动指针depsTail 会在复用过程中,从 undefined 开始,随着遍历逐步向后移动。

所以往后我们判断是否需要复用节点的依据就是:只要 effect 存在头节点 deps,但是尾节点 depsTailundefined,就表示它正在重新执行,需要复用节点。

实现 effect.ts

TypeScript

kotlin 复制代码
  run() {
    const prevSub = activeSub
    activeSub = this

    // 开始执行,将尾节点设为 undefined,进入"重新收集"状态
    this.depsTail = undefined
    
    try {
      return this.fn()
    } finally {
      // ...
    }
  }

实现 system.ts

TypeScript

javascript 复制代码
export function link(dep, sub) {

/**
 * 复用节点
 * 如果 sub.depsTail 是 undefined,并且存在 sub.deps 头节点,表示需要复用
 */
  if (sub.depsTail === undefined && sub.deps) {
    let currentDep = sub.deps
    // 遍历 effect 的旧依赖链表
    while(currentDep){
      // 如果当前遍历到的旧依赖 link 所连接的 ref,与当前要连接的 ref 相等
      if (currentDep.dep === dep) {
        // 表示之前已经收集过此依赖,直接复用
        sub.depsTail = currentDep // 移动尾节点指针,指向刚刚复用的节点
        return  // 直接返回,不再新增节点
      }
      currentDep = currentDep.nextDep
    }
  }
  
  // ... (创建新节点的逻辑)
}

完整执行流程

第一次执行

  1. effect 初始化deps = undefined, depsTail = undefined
  2. 执行 run() :进入 run 方法,depsTail 保持 undefined
  3. 读取 ref.value ,调用 link()
  4. link() 判断 :因为 depsundefined,不满足复用条件 → 创建新的 Link1 节点。
  5. 执行结束deps 指向 Link1, depsTail 也指向 Link1

第二次执行(点击按钮)

  1. 执行前deps = Link1, depsTail = Link1

  2. 执行 run() :进入 run 方法,depsTail 被设为 undefined,进入"重新收集"状态。

  3. 读取 ref.value ,调用 link()

  4. link() 判断

    • 条件满足:depsTail === undefineddeps 存在。
    • 开始遍历 deps 链表,发现 deps (即 Link1) 的 .dep 属性就是当前的 ref
    • 复用成功!将 depsTail 重新指向 Link1,然后 return不再创建新节点
  5. 执行结束deps 依然是 Link1, depsTail 也恢复为 Link1

通过这个执行顺序,我们很好地解决了问题。修正代码之后,就没有指数级触发的现象了。


想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

相关推荐
TeamDev2 小时前
用一个 prompt 搭建带 React 界面的 Java 桌面应用
java·前端·后端
北辰alk2 小时前
React 组件状态更新机制详解:从原理到实践
前端
Mintopia4 小时前
在 Next.js 项目中驯服代码仓库猛兽:Husky + Lint-staged 预提交钩子全攻略
前端·javascript·next.js
Mintopia4 小时前
AIGC API 接口的性能优化:并发控制与缓存策略
前端·javascript·aigc
IT_陈寒4 小时前
SpringBoot 3.2新特性实战:这5个隐藏技巧让你的启动速度提升50%
前端·人工智能·后端
星哥说事4 小时前
国产开源文档神器:5 分钟搭建 AI 驱动 Wiki 系统,重新定义知识库管理
前端
degree5204 小时前
前端单元测试入门:使用 Vitest + Vue 测试组件逻辑与交互
前端
3Katrina4 小时前
一文解决面试中的跨域问题
前端
阿白19554 小时前
JS基础知识——创建角色扮演游戏
前端