Vue 3 设计与实现

第 6 章 Vue 3.5 Alien Signals:响应式的第三次革命

作者 杨艺韬 · 12,389 字

第 6 章 Vue 3.5 Alien Signals:响应式的第三次革命

本章要点

  • Alien Signals 的设计动机:为什么 Vue 需要第三代响应式内核
  • 从 WeakMap 到 Link 双向链表:依赖存储结构的彻底重构
  • 版本计数(Version Counting):如何用整数替代 Set 遍历
  • 混合推拉模型(Push-Pull):computed 的惰性求值革命
  • Dep 与 Subscriber 的双向链表协议:Link 节点的六指针设计
  • propagate() 与 checkDirty():信号传播的完整链路
  • 性能基准对比:Alien Signals vs Vue 3.4 vs Solid.js vs Preact Signals

2024 年 9 月的一个深夜,Vue 核心团队成员 Johnson Chu 在 GitHub 上创建了一个不起眼的仓库——stackblitz/alien-signals。仓库描述只有一句话:“The fastest signal library.”(最快的信号库。)

两个月后,这个”外星信号”库的核心算法被 Evan You 合并进了 Vue 3.5 的 @vue/reactivity。基准测试显示,新的响应式系统在依赖传播速度上提升了 40-60%,内存占用降低了 56%,GC 压力几乎归零。这不是一次渐进式优化——这是一次底层架构的彻底重写。

从 Vue 3.0 到 Vue 3.4,响应式系统经历了两次重大演进:第一次是从 Object.defineProperty 到 Proxy(Vue 2 → Vue 3.0),解决了”不能检测新增属性”的历史顽疾;第二次是清理标记优化(Vue 3.2 → Vue 3.4),用双缓冲标记位替代了 Set 的全量清理。但这两次改进本质上都在同一个架构范式内——WeakMap → Map → Set 的三层映射结构始终是依赖存储的核心。

Alien Signals 打碎了这个范式。它用双向链表替代了 Set,用版本计数替代了标记位清理,用混合推拉模型替代了纯推模型。这不是”更快的同一条路”,而是”换了一条完全不同的路”。

本章将深入这条”外星之路”的每一个设计决策,从底层数据结构到高层传播算法,完整剖析 Vue 3.5 响应式内核的第三次革命。

本章在全书中的独特位置

这一章是全书最”前沿”的一章——它讲的内容是 2024 年才发生的、2026 年还在持续演进的。第 4、5 章讲的是 Vue 3.0-3.4 的经典响应式 API(这些 API 至今未变),本章讲的是这些 API 背后的引擎在 3.5-3.6 被完全替换的故事。用户代码不用动,但底下跑的机制已经是另一套。这种”保持 API 兼容、内核彻底重构”的升级方式本身就是一个工程奇迹——绝大多数大型软件做不到这一点。

读本章前你需要先掌握第 3 章(响应式设计哲学)的”推 vs 拉”概念、第 4 章(reactive/ref/track/trigger)的具体 API、第 5 章(effect/effectScope)的副作用管理。这三章是本章的基础——本章会反复回到这些概念,但不会重新讲解它们。如果有不扎实的地方,强烈建议翻回对应章节补齐再继续。

读完本章后你应该能回答的问题:**(1)Alien Signals 和 Vue 3.0 响应式的核心数据结构差异是什么?(2)“版本计数替代 Set”到底带来了哪些具体收益?(3)“混合推拉”模型怎么让 computed 变得更快?**这三个问题的答案会让你对 Vue 3.5-3.6 的性能跃升有骨子里的理解——以后看 React Compiler、Svelte 5 Runes、Signal TC39 提案时也能用同样的分析框架去评估。

6.1 问题的本质:旧系统出了什么问题?

任何架构升级都始于对旧架构的深度批判——不能说清楚”旧的哪里不好”就没法证明”新的为什么必要”。Vue 3.0-3.4 的响应式系统能用、跑得也不慢——它能在这个状态下用五年是因为基本够用。但”够用”和”最优”之间存在巨大的空间——Alien Signals 就是在”够用”的缝隙里看到了可以榨取出十倍性能空间的机会。本节把这个缝隙指出来——看清了问题,后面所有新设计的价值就自然清晰。

Vue 3.0–3.4 的依赖存储架构

理解 Alien Signals 的收益前、先仔细看看它替换的是什么。Vue 3.0-3.4 的依赖存储虽然在表面 API 上让 Vue 3 成为当时最快的响应式框架之一,但在内部结构上其实沿用了 Vue 2 的思路——只是换了底层代理技术(defineProperty → Proxy)。真正的架构重构要等到 Alien Signals。

在理解 Alien Signals 之前,我们需要先理解它要解决的问题。Vue 3.0–3.4 的依赖存储使用经典的三层映射结构:

// Vue 3.0–3.4 的依赖存储(概念模型)
type TargetMap = WeakMap<object, KeyMap>
type KeyMap = Map<string | symbol, Dep>
type Dep = Set<ReactiveEffect>

// 全局的依赖存储
const targetMap: TargetMap = new WeakMap()

这个设计直觉而清晰:对于每一个响应式对象的每一个属性,都有一个 Set 存储所有依赖它的 effect。当属性被修改时,遍历 Set,执行每一个 effect。

但三个问题逐渐浮现:

问题一:内存开销

每一个属性的 Dep 都是一个 Set 对象。在现代 JavaScript 引擎中,一个空 Set 至少占用 64-128 字节。一个拥有 50 个响应式属性的组件,仅依赖存储就需要 3-6KB。当页面有数百个组件时,这个数字变得触目惊心。

// 粗略估算
const emptySetSize = 64  // V8 中一个空 Set 的内存开销(字节)
const propsPerComponent = 50
const components = 500

const totalOverhead = emptySetSize * propsPerComponent * components
// = 64 * 50 * 500 = 1.6 MB — 仅用于存储依赖关系!

问题二:GC 压力

每次 effect 重新执行时,旧的依赖关系需要被清理,新的依赖关系需要被重建。在 Vue 3.0 中,这意味着清空所有 Set 并重新添加——每次组件更新都会产生大量的临时对象,给垃圾回收器带来巨大压力。

// Vue 3.0 的依赖清理(简化)
function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect)  // 从每个 Dep Set 中移除自己
  }
  deps.length = 0  // 清空 deps 数组
}

Vue 3.2 引入了”双缓冲标记位”优化(wn 标记),避免了全量清理:

// Vue 3.2–3.4 的优化:用标记位替代全量清理
// w = was tracked(执行前已存在的依赖)
// n = newly tracked(本次执行新收集的依赖)
// 执行完后,w=1 但 n=0 的依赖需要被移除(条件分支不再走到的路径)

但这只是治标——Set 本身的内存开销和 hash 查找的 CPU 开销并没有减少。

问题三:传播效率

在纯推模型中,当一个 ref 被修改时,所有依赖它的 effect 立即被触发,包括那些最终结果不会改变的 computed:

const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
const greeting = computed(() => `Hello, ${fullName.value}!`)

// 修改 firstName
firstName.value = 'John'  // 赋了相同的值!
// 纯推模型:fullName 被触发重算 → greeting 被触发重算
// 但其实 fullName 的值没变,greeting 的重算完全是浪费

这三个问题——内存、GC、传播效率——不是 bug,而是架构的固有局限。要从根本上解决它们,需要换一种思路。

信号社区的启发

Alien Signals 不是凭空冒出来的——它站在 Solid Signals、Preact Signals、SolidJS 2017 年开始的细粒度响应式探索、以及 Knockout / MobX 一脉响应式框架的肩膀上。每个前辈都贡献了一块拼图:Solid 证明了编译期 + 细粒度响应式可以彻底干掉 VDOMPreact Signals 证明了独立的信号库可以跨框架工作Alien Signals 把这些前辈的共同智慧加上 Johnson Chu 自己的几个关键创新(双向链表、版本计数、混合推拉)做成了”最快的信号库。开源社区的进步就是这样——每一个新项目都不是从零开始、而是整合前人的智慧再加一点自己的创新。

2023-2024 年,前端社区掀起了一场”信号革命”(Signals Revolution)。Solid.js、Preact Signals、Angular Signals 相继证明了一种新的响应式范式的可行性:

特性Vue 3.0–3.4Solid.jsPreact SignalsAlien Signals
依赖存储WeakMap→Map→Set链表链表双向链表
传播模型纯推推拉混合推拉混合推拉混合
脏检查标记位版本计数版本计数版本计数
computed 求值推送时重算读取时重算读取时重算读取时重算
内存模型对象密集链表节点链表节点链表节点

Johnson Chu 的贡献在于:他不仅吸收了社区的最佳实践,还在数据结构层面做了极致优化——Alien Signals 的 Link 节点设计比 Preact Signals 更紧凑,依赖遍历比 Solid.js 更高效。在 js-reactivity-benchmark 基准测试中,Alien Signals 在几乎所有项目上都排名第一。

Link 是 Alien Signals 的原子数据结构——整个响应式系统的性能全部建立在它之上。这个小小的数据结构有六个指针(dep / sub / prevDep / nextDep / prevSub / nextSub),每一个都承担着关键角色。读懂 Link 的六指针设计你就理解了 Alien Signals 为什么能做到 O(1) 的依赖插入/删除——这在 Set 模型下是 O(log n)(平均 O(1)、但带来哈希开销和 GC 压力),链表模型下就是纯粹的指针操作、没有任何隐藏成本。

告别 Set,拥抱链表

用什么数据结构存依赖关系”看似是一个实现细节——其实是决定整个响应式系统性能上限的结构性选择。Set 的优势是无重复、查找快;链表的优势是插入/删除快、可以零分配。在响应式场景下,“有序列出所有订阅者”比”快速查找某个订阅者”更重要——因为 trigger 时总是要遍历所有订阅者、很少需要精确查找。这就让链表在这个场景下比 Set 有天然优势。

Alien Signals 的核心革新在于用 Link 节点 组成的双向链表替代了 Set 来存储依赖关系。每个 Link 节点同时参与两条链表——Dep 的订阅者链和 Subscriber 的依赖链——实现了”一个节点、双向连接”的极致内存效率。

// packages/reactivity/src/dep.ts(简化)

/**
 * Link 节点——连接 Dep 和 Subscriber 的桥梁
 *
 * 每个 Link 同时存在于两条链表中:
 * 1. Dep 的 subs 链表(nextSub / prevSub)
 * 2. Subscriber 的 deps 链表(nextDep / prevDep)
 */
interface Link {
  dep: Dep              // 指向依赖源
  sub: Subscriber       // 指向订阅者

  // Dep 维度的链表指针
  nextSub: Link | undefined   // Dep 的下一个订阅者
  prevSub: Link | undefined   // Dep 的上一个订阅者

  // Subscriber 维度的链表指针
  nextDep: Link | undefined   // Subscriber 的下一个依赖
  prevDep: Link | undefined   // Subscriber 的上一个依赖(Vue 3.5 中为 tail 指针复用)
}

🔥 深度洞察

为什么用链表替代 Set?三个原因:

  1. 内存连续性:Link 是一个纯数据对象(6 个指针 + 2 个引用),在 V8 中只占约 80 字节。而一个包含 1 个元素的 Set 需要约 128 字节——Set 自身的开销就超过了 Link 节点。当依赖关系数量为 N 时,链表方案节省的内存随 N 线性增长。

  2. O(1) 操作:链表的插入和删除都是 O(1),而 Set 的 delete 操作虽然平均 O(1),但需要 hash 计算和可能的冲突处理。在高频率的依赖收集/清理场景中,链表的常数因子更小。

  3. 零 GC 压力:Link 节点可以被缓存和复用(通过对象池或 free list),而 Set 的内部 bucket 由引擎管理,无法被应用层复用。

双向链表的视觉模型

一个 Link 节点同时属于两条链表”的设计是本章最需要在脑子里画出来的概念——读下面的视觉模型的时候请把图像记住。这种”多重链接”(multiply linked)的数据结构在 Linux 内核里叫 list_head,在数据库索引里叫 skip list node,在内存分配器里叫 free list entry——都是”一个节点参与多个链表”的变体。Vue Alien Signals 把这种内核级的数据结构思想搬到了 JavaScript 层面。

让我们用一个具体的例子来理解 Link 双向链表的结构:

const price = ref(10)
const quantity = ref(3)
const total = computed(() => price.value * quantity.value)

effect(() => {
  console.log(`Total: ${total.value}`)
})

这段代码建立的依赖关系如下:

graph LR
    subgraph "Dep 维度(谁订阅了我?)"
        price[price.dep] -->|subs| L1[Link 1]
        quantity[quantity.dep] -->|subs| L2[Link 2]
        total_dep[total.dep] -->|subs| L3[Link 3]
    end

    subgraph "Subscriber 维度(我订阅了谁?)"
        L1 -->|nextDep| L2
        L2 -.->|prevDep| L1
        computed_sub[total subscriber] -->|deps| L1
        computed_sub -->|depsTail| L2

        L3 -.-> effect_sub[effect subscriber]
        effect_sub -->|deps| L3
        effect_sub -->|depsTail| L3
    end

    L1 -->|sub| computed_sub
    L2 -->|sub| computed_sub
    L3 -->|sub| effect_sub
    L1 -->|dep| price
    L2 -->|dep| quantity
    L3 -->|dep| total_dep

    style L1 fill:#f9f,stroke:#333
    style L2 fill:#f9f,stroke:#333
    style L3 fill:#f9f,stroke:#333
  • total(computed)作为 Subscriber,通过 Link 1 和 Link 2 订阅了 pricequantity
  • total(computed)同时作为 Dep,通过 Link 3 被 effect 订阅
  • 每个 Link 节点同时连接在两条链上——这就是”双向”的含义

Dep 类的完整实现

// packages/reactivity/src/dep.ts(简化)

export class Dep {
  // 版本号——每次触发更新时递增
  _version: number = 0

  // 订阅者链表的头指针
  _subs: Link | undefined = undefined

  // 全局版本号(用于 computed 的快速路径优化)
  _globalVersion: number = globalVersion

  /**
   * 依赖收集:建立 Dep → Subscriber 的连接
   */
  track(): Link | undefined {
    // 获取当前正在执行的 subscriber(effect 或 computed)
    let link = this._subs

    // 如果当前 subscriber 已经订阅了这个 dep,复用 Link
    if (link && link.sub === activeSub) {
      return link
    }

    // 创建新的 Link 节点
    link = new Link(this, activeSub!)

    // 将 Link 加入 Dep 的 subs 链表(头插法)
    if (this._subs) {
      this._subs.prevSub = link
    }
    link.nextSub = this._subs
    this._subs = link

    // 将 Link 加入 Subscriber 的 deps 链表(尾插法)
    if (activeSub!._depsTail) {
      activeSub!._depsTail.nextDep = link
      link.prevDep = activeSub!._depsTail
    } else {
      activeSub!._deps = link
    }
    activeSub!._depsTail = link

    return link
  }

  /**
   * 触发更新:通知所有订阅者
   */
  trigger(): void {
    this._version++
    globalVersion++
    if (this._subs) {
      propagate(this._subs)
    }
  }
}

💡 最佳实践

注意 track() 中的复用检查(第一个 if 分支)。在 effect 重新执行期间,依赖往往和上一次相同。链表结构天然支持”顺序遍历 + 原地复用”——如果当前 Dep 的最近订阅者就是当前 Subscriber,直接返回已有的 Link,不创建新对象。这个微优化在”依赖稳定”的常见场景下,将依赖收集的开销降到了近乎为零。

Subscriber 接口

// packages/reactivity/src/dep.ts(简化)

interface Subscriber {
  _deps: Link | undefined      // 依赖链表头
  _depsTail: Link | undefined  // 依赖链表尾
  _flags: number               // 状态标志位
}

// 标志位定义
const enum SubscriberFlags {
  DIRTY = 1 << 0,           // 确认脏——需要重算
  MAYBE_DIRTY = 1 << 1,     // 可能脏——需要检查依赖
  COMPUTED = 1 << 2,        // 是 computed
  NOTIFIED = 1 << 3,        // 已加入调度队列
  TRACKING = 1 << 4,        // 正在收集依赖
  RECURSED = 1 << 5,        // 用于防止递归传播
  RUNNING = 1 << 6,         // 正在执行
}

6.3 版本计数:告别标记位的脏检查

版本号”是计算机科学里最基础的乐观并发控制工具——数据库的 MVCC、Git 的 commit hash、React Fiber 的 lanes 都是这个思想的变体。Alien Signals 把版本号引入响应式追踪,解决的是”判断一个 computed 是否需要重算”的核心问题。旧方案(Vue 3.0-3.4)通过”每次 trigger 时把 dirty 标志位传播给所有依赖者”实现——这是典型的”推模型”。新方案通过”只递增全局版本号、读取时对比版本”实现——这是典型的”拉模型”的优化表达。这个转变的核心好处是:signal 的写操作从 O(订阅者数) 降到 O(1)——无论多少下游依赖它都不用遍历通知。

什么是版本计数?

版本计数在本章读到这里你已经见过几次了——现在给它一个正式的定义:每个响应式对象(signal / computed)维护一个整数版本号、每次值改变就递增。下游在读取时比对存储的”上次读到的版本”和”当前版本”、不同就意味着值变了、需要处理。这个定义简单得出人意料,但它是 Alien Signals 整个架构的基石。

版本计数是 Alien Signals 中最优雅的设计之一。每个 Dep 都维护一个递增的 _version 整数,每个 Link 节点也缓存一个 _version。判断依赖是否变化,只需要比较两个整数:

// 判断某个依赖是否发生了变化
function isDirty(link: Link): boolean {
  return link._version !== link.dep._version
}

这比 Vue 3.0 的”清理所有 Set 并重建”和 Vue 3.2 的”双缓冲标记位”都要简洁得多。

三代脏检查策略对比

策略Vue 3.0Vue 3.2–3.4Vue 3.5(Alien Signals)
机制清空 deps Set,重新收集w/n 双标记位版本号比较
每次 effect 执行前遍历所有 deps,从 Set 中 delete给所有旧 deps 打 w 标记无操作(惰性检查)
每次依赖收集时Set.add()n 标记比较 version,命中则跳过
每次 effect 执行后无(已在执行前清理)移除 w=1, n=0 的失效依赖移除链表尾部多余的 Link
时间复杂度O(n) 清理 + O(n) 重建O(n) 标记 + O(n) 清理O(changed) — 只处理变化的部分
内存开销Set 对象 × 属性数Set 对象 × 属性数Link 节点 × 依赖数

版本计数的工作流程

// 第一次执行 effect
const count = ref(0)        // count.dep._version = 0
const doubled = computed(() => count.value * 2)

effect(() => {
  console.log(doubled.value)
})

// 1. effect 执行,读取 doubled.value
// 2. doubled 读取 count.value → 创建 Link(count.dep, doubled)
//    Link._version = count.dep._version = 0
// 3. doubled 计算完成 → 创建 Link(doubled.dep, effect)
//    Link._version = doubled.dep._version = 0

// 修改 count
count.value = 1
// 1. count.dep._version 变为 1
// 2. Link._version 仍为 0 → 版本不匹配 → dirty!
// 3. doubled 被标记为 MAYBE_DIRTY
// 4. 当 effect 检查 doubled 时,doubled 重算
// 5. doubled 值变了 → doubled.dep._version 变为 1
// 6. effect 重新执行

🔥 深度洞察

版本计数的精妙之处在于惰性传播。当 count 被修改时,Vue 不会立即重算 doubled——它只是递增 count.dep._version。只有当某人真正读取 doubled.value 时,才会发现版本不匹配,触发重算。如果 doubled 在当前渲染周期中根本没有被读取(比如它在一个 v-if="false" 的分支中),那么它的重算就被完全跳过。这就是”不读不算”的哲学——与 Vue 3.0–3.4 的”修改即算”形成了根本性的对立。

6.4 混合推拉模型:computed 的惰性求值革命

第 3 章”响应式哲学”讨论过”推 vs 拉”的对立——本节讲的是它们在 Alien Signals 里怎么优雅地混合。纯推模型下 effect 好处理(立即执行),但 computed 会被无效触发(即使没人读也被迫重算);纯拉模型下 computed 好处理(只在被读时才算),但 effect 会永远不跑(没人主动拉它)。Alien Signals 的选择是:对 computed 用拉模型(MAYBE_DIRTY 标记、读取时验算)、对 effect 用推模型(DIRTY 立即调度)——让两种原语各取所需。这种”根据下游类型选择传播策略”的设计智慧是本章最值得品味的地方。

纯推 vs 纯拉 vs 混合推拉

理解 Alien Signals 的传播模型,需要先理解三种响应式范式:

纯推模型(Push-based)——Vue 3.0–3.4

源数据修改 → 立即通知所有 computed → computed 立即重算 → 通知 effect → effect 执行

优点:实现简单,更新及时。缺点:无法避免不必要的重算。

纯拉模型(Pull-based)——类似于 Angular 的脏检查

源数据修改 → 标记为脏 → 下一个检测周期 → 遍历所有数据检查是否变化 → 更新 UI

优点:天然去重。缺点:无法知道”谁变了”,必须全量遍历。

混合推拉模型(Push-Pull)——Alien Signals

源数据修改 → "推":沿链表传播 DIRTY/MAYBE_DIRTY 标记 → 停!
读取 computed → "拉":检查依赖版本号 → 只在真正脏时重算

优点:结合了推的精确性和拉的惰性。

sequenceDiagram
    participant Source as ref(source)
    participant C1 as computed(A)
    participant C2 as computed(B)
    participant E as effect

    Note over Source: source.value = newVal
    Source->>C1: 推:标记 MAYBE_DIRTY
    C1->>C2: 推:标记 MAYBE_DIRTY
    C2->>E: 推:调度 effect

    Note over E: effect 开始执行
    E->>C2: 拉:读取 B.value
    C2->>C1: 拉:B 依赖 A,检查 A 是否脏
    C1->>Source: 拉:A 依赖 source,检查版本号
    Note over C1: 版本不匹配,重算 A
    C1-->>C2: 返回新值
    Note over C2: A 变了,重算 B
    C2-->>E: 返回新值
    Note over E: 使用新值完成渲染

propagate():推阶段的实现

dep.trigger() 被调用时,propagate() 函数沿 subs 链表”推”通知:

// packages/reactivity/src/dep.ts(简化)

function propagate(subs: Link): void {
  let link: Link | undefined = subs
  let dirtyLevel = DirtyLevels.DIRTY

  // 遍历 Dep 的所有订阅者
  while (link) {
    const sub = link.sub
    const subFlags = sub._flags

    // 根据订阅者类型决定脏级别
    if (sub._flags & SubscriberFlags.COMPUTED) {
      // computed 订阅者 → 标记为 DIRTY
      if (!(subFlags & SubscriberFlags.DIRTY)) {
        sub._flags |= SubscriberFlags.DIRTY
      }

      // 如果 computed 自己也有订阅者,继续传播(但降级为 MAYBE_DIRTY)
      if (sub._subs) {
        // 递归传播,但对下游的标记降级
        propagate(sub._subs)
      }
    } else {
      // effect 订阅者 → 加入调度队列
      if (!(subFlags & SubscriberFlags.NOTIFIED)) {
        sub._flags |= SubscriberFlags.NOTIFIED

        if (sub.scheduler) {
          sub.scheduler()   // ← 组件更新走 queueJob
        } else if (sub.run) {
          sub.run()          // ← watchEffect 直接执行
        }
      }
    }

    link = link.nextSub
  }
}

关键设计:

  1. computed 不立即重算:只打标记(DIRTY),不执行 getter 函数
  2. effect 不立即执行:只调度(scheduler),不真正运行
  3. 传播是深度优先的:沿 computed 链向下递归,直到遇到 effect

checkDirty():拉阶段的实现

当 effect 执行并读取 computed.value 时,checkDirty() 被调用来判断是否需要重算:

// packages/reactivity/src/dep.ts(简化)

function checkDirty(sub: Subscriber): boolean {
  let link = sub._deps

  while (link) {
    const dep = link.dep

    // 如果依赖是 computed 且被标记为 DIRTY
    if (dep._flags & SubscriberFlags.COMPUTED) {
      // 先检查这个 computed 自己是否真的脏
      if (dep._flags & SubscriberFlags.DIRTY) {
        // 递归检查——这个 computed 的依赖是否真的变了
        if (checkDirty(dep)) {
          // 真的变了 → 重算这个 computed
          dep._update()

          // 检查重算后值是否变化
          if (link._version !== dep._version) {
            return true  // 值变了 → 当前 subscriber 也脏
          }
        }
      } else if (dep._flags & SubscriberFlags.MAYBE_DIRTY) {
        // MAYBE_DIRTY → 需要递归检查
        if (checkDirty(dep)) {
          if (link._version !== dep._version) {
            return true
          }
        }
      } else {
        // 没有脏标记 → 检查版本号
        if (link._version !== dep._version) {
          return true
        }
      }
    } else {
      // 依赖是普通 ref/reactive → 直接比较版本号
      if (link._version !== dep._version) {
        return true
      }
    }

    link = link.nextDep
  }

  return false  // 所有依赖都没变 → 不脏
}

🔥 深度洞察

checkDirty() 的渐进式检查是 Alien Signals 最核心的性能优势。考虑这个场景:

const a = ref(1)
const b = computed(() => a.value > 0)  // boolean 过滤
const c = computed(() => b.value ? 'positive' : 'non-positive')
const d = computed(() => c.value.toUpperCase())

a.value 从 1 变为 2 时:

  • 推阶段:b → c → d 全部被标记为 DIRTY/MAYBE_DIRTY
  • 拉阶段:effect 读取 d.value → 检查 c → 检查 b → b 重算,值仍为 truec 不需要重算 → d 不需要重算

整条链只有 b 被重算了。在纯推模型中,b、c、d 都会被重算。当这种”值过滤”场景存在于深层 computed 链中时,混合推拉模型节省的计算量可以是数量级的。

6.5 依赖收集的完整生命周期

依赖收集”是响应式系统里最动态的部分——effect 每次执行都可能读到不同的依赖(条件分支、循环等导致的动态访问模式)。管理这种动态关系的难点在于:“要知道哪些依赖是上次有、这次没”(需要移除)、“哪些是上次没、这次有”(需要新增)、“哪些两次都有”(可以复用)。旧方案是”全部重建”——粗暴有效但开销大;新方案(Alien Signals)是”增量维护”——标记-扫除策略像 GC 算法、只处理真正变化的部分。这一节讲的就是这个精巧的增量维护流程。

从 effect 创建到依赖建立

让我们跟踪一个 effect 从创建到建立依赖关系的完整过程:

const name = ref('Vue')
const version = ref('3.5')
const title = computed(() => `${name.value} ${version.value}`)

effect(() => {
  document.title = title.value
})
sequenceDiagram
    participant G as Global State
    participant E as effect
    participant T as title (computed)
    participant N as name (ref)
    participant V as version (ref)

    Note over E: 1. 创建 ReactiveEffect
    E->>G: activeSub = this
    E->>E: 设置 TRACKING 标志

    Note over E: 2. 执行 fn()
    E->>T: 读取 title.value
    T->>T: checkDirty() → DIRTY

    Note over T: 3. computed 开始求值
    T->>G: activeSub = this (computed)
    T->>N: 读取 name.value
    N->>N: dep.track() → 创建 Link(name.dep, title)
    T->>V: 读取 version.value
    V->>V: dep.track() → 创建 Link(version.dep, title)
    T->>G: activeSub = effect (恢复)

    Note over T: 4. computed 求值完成
    T->>T: _value = 'Vue 3.5'
    T->>T: dep.track() → 创建 Link(title.dep, effect)
    T-->>E: 返回 'Vue 3.5'

    Note over E: 5. effect 执行完成
    E->>E: 清除 TRACKING 标志
    E->>E: 清理多余的依赖 Link

依赖清理:链表的优势

在 effect 重新执行时,依赖关系可能发生变化(条件分支导致不同的依赖路径)。Alien Signals 使用链表的”游标推进”策略高效处理这种情况:

// 概念模型——effect 重新执行时的依赖更新

// 上一次执行的依赖链:A → B → C → D
// 本次执行的依赖链:  A → B → E(C 和 D 不再被访问)

// 步骤:
// 1. 游标从链表头开始
// 2. 读取 A → 游标指向 Link(A),版本匹配 → 复用,游标前进
// 3. 读取 B → 游标指向 Link(B),版本匹配 → 复用,游标前进
// 4. 读取 E → 游标指向 Link(C),dep 不匹配 → 替换为 Link(E)
// 5. 执行完成 → 游标之后的节点(Link(D))被移除

// 结果:只有 Link(E) 被创建,Link(C) 被复用为 Link(E),Link(D) 被移除
// 没有 Set 操作,没有 hash 计算,没有临时对象
// packages/reactivity/src/dep.ts(简化)

function startTracking(sub: Subscriber): void {
  sub._flags |= SubscriberFlags.TRACKING
  // 不做任何清理!只是设置标志位
  // 旧的依赖链表保留原样,等待被复用或替换
}

function endTracking(sub: Subscriber): void {
  // 从 depsTail 开始向前,断开所有本次未访问的 Link
  const depsTail = sub._depsTail
  if (depsTail) {
    // depsTail 之后的节点都是未被复用的旧依赖
    if (depsTail.nextDep) {
      clearLinks(depsTail.nextDep)
      depsTail.nextDep = undefined
    }
  } else if (sub._deps) {
    // 如果 depsTail 为空但 deps 不为空,说明本次没有收集任何依赖
    clearLinks(sub._deps)
    sub._deps = undefined
  }
  sub._flags &= ~SubscriberFlags.TRACKING
}

💡 最佳实践

链表游标策略的时间复杂度是 O(max(old, new)),而 Set 方案的清理 + 重建是 O(old + new)。虽然大 O 相同,但链表方案有三个实际优势:(1)复用节点时零分配;(2)无 hash 计算;(3)无 Set resize。在”依赖稳定”的常见场景中(90%+ 的 effect 重执行依赖不变),链表方案的实际开销接近 O(0),而 Set 方案仍有 O(n) 的遍历开销。

6.6 computed 的双重身份

computed 在响应式图里扮演着独特的中间角色——它既是”下游”(依赖其他 signal / computed)又是”上游”(被其他 computed / effect 依赖)。这种双重身份让它的实现比 signal 和 effect 都更复杂——既要像 Subscriber 那样收集自己的 _deps、又要像 Dep 那样管理自己的 _subs。读下面代码时请把两个角色分开看——哪些字段/方法是”作为 Subscriber”的、哪些是”作为 Dep”的。理清这个双重性你就理解了 computed 为什么在响应式系统里被视为”最精巧的 API”。

既是 Dep 又是 Subscriber

computed 在 Alien Signals 中拥有独特的双重身份——它既是下游 effect 的 Dep(依赖源),也是上游 ref/computed 的 Subscriber(订阅者)。这种设计让 computed 成为了依赖图中的”中继节点”:

// packages/reactivity/src/computed.ts(简化)

export class ComputedRefImpl<T = any> implements Subscriber {
  // === Dep 的属性 ===
  readonly dep: Dep = new Dep()  // 管理订阅我的 effect/computed
  _value: T = undefined as T

  // === Subscriber 的属性 ===
  _deps: Link | undefined = undefined       // 我订阅的依赖链表
  _depsTail: Link | undefined = undefined
  _flags: number = SubscriberFlags.COMPUTED | SubscriberFlags.DIRTY

  // === 版本控制 ===
  _globalVersion: number = globalVersion - 1

  constructor(
    private _fn: ComputedGetter<T>,
    private _setter?: ComputedSetter<T>
  ) {}

  get value(): T {
    const flags = this._flags

    // 快速路径:全局版本未变 → 没有任何响应式数据被修改过
    if (this._globalVersion === globalVersion && !(flags & SubscriberFlags.DIRTY)) {
      // 直接返回缓存值
      this.dep.track()
      return this._value
    }

    this._globalVersion = globalVersion

    // 检查是否需要重算
    if (flags & SubscriberFlags.DIRTY) {
      // 确认脏 → 直接重算
      this._update()
    } else if (flags & SubscriberFlags.MAYBE_DIRTY) {
      // 可能脏 → 检查依赖的版本号
      if (checkDirty(this)) {
        this._update()
      } else {
        // 依赖没变 → 清除脏标记
        this._flags &= ~SubscriberFlags.MAYBE_DIRTY
      }
    }

    this.dep.track()
    return this._value
  }

  _update(): void {
    const oldValue = this._value

    // 以 Subscriber 身份重新收集依赖
    startTracking(this)
    try {
      const newValue = this._fn(oldValue)
      if (hasChanged(newValue, oldValue)) {
        this._value = newValue
        this.dep._version++  // ← 值变了才递增版本号!
      }
    } finally {
      endTracking(this)
      this._flags &= ~(SubscriberFlags.DIRTY | SubscriberFlags.MAYBE_DIRTY)
    }
  }
}

globalVersion 快速路径

注意 get value() 中的第一个检查:this._globalVersion === globalVersion。这是一个极其巧妙的优化:

let globalVersion = 0

// 每次 trigger() 都会递增
function trigger(dep: Dep) {
  dep._version++
  globalVersion++  // ← 全局计数器
  propagate(dep._subs)
}

如果从上次读取 computed 到现在,全局的 globalVersion 没有变化,说明没有任何响应式数据被修改过。此时,computed 可以直接返回缓存值,甚至不需要遍历依赖链。这个优化在”多次连续读取同一个 computed”的场景中效果显著:

const total = computed(() => price.value * quantity.value)

// 以下三次读取,只有第一次会检查依赖
console.log(total.value)  // 检查 + 可能重算
console.log(total.value)  // globalVersion 没变 → 直接返回缓存
console.log(total.value)  // globalVersion 没变 → 直接返回缓存

🔥 深度洞察

globalVersion 本质上是一个布隆过滤器的极简版本——它用一个整数告诉你”是否有任何变化发生”。如果答案是”没有”,你可以立即返回;如果答案是”有”,你再进一步检查具体是哪个依赖变了。这种”先粗筛、再细查”的策略是高性能系统设计的通用模式:数据库的 WAL 日志、CPU 的 branch prediction、网络的 ETag 缓存,都是同样的思想。

6.7 DIRTY vs MAYBE_DIRTY:两级脏标记

两级脏标记”是 Alien Signals 最精巧的小设计之一——它用两个位的差异实现了”确定要重算”和”可能要重算”的语义区分。DIRTY 表示”源头变了、我肯定要重算”、MAYBE_DIRTY 表示”上游 computed 可能变了、我需要核实”。这个区分的价值在于:MAYBE_DIRTY 的 computed 在真正被读取前只是”标记”、不做任何计算——只有读取到它时才触发 checkDirty 去核实上游到底变没变。这种”悬而未决状态 + 按需核实”的设计让响应式图里的无效重算被彻底消除。

为什么需要两级?

Alien Signals 使用两级脏标记来区分”确定脏”和”可能脏”:

// DIRTY:依赖源(ref/reactive)直接变了
// MAYBE_DIRTY:依赖的 computed 被标记了,但 computed 的值可能没变

考虑这个场景:

const count = ref(1)
const isPositive = computed(() => count.value > 0)  // boolean 过滤
const message = computed(() => isPositive.value ? 'Yes' : 'No')

// count: 1 → isPositive: true → message: 'Yes'

count.value = 2  // count 变了!
// isPositive: DIRTY(直接依赖 count)
// message: MAYBE_DIRTY(依赖 isPositive,但 isPositive 的值可能没变)

传播标记的规则:

订阅者与源的关系标记含义
直接订阅了被修改的 ref/reactiveDIRTY一定需要重算
订阅了一个被标记为 DIRTY 的 computedMAYBE_DIRTY可能需要重算(取决于 computed 值是否变化)
订阅了一个被标记为 MAYBE_DIRTY 的 computedMAYBE_DIRTY可能需要重算(递归不确定性)
graph TD
    A[ref count = 1 → 2] -->|DIRTY| B[computed isPositive]
    B -->|MAYBE_DIRTY| C[computed message]
    C -->|MAYBE_DIRTY| D[effect render]

    B -->|"重算: true→true<br>值没变!"| B_result[isPositive = true ✓]
    B_result -.->|"version 不变"| C_skip["message 跳过重算 ✓"]
    C_skip -.->|"version 不变"| D_skip["effect 跳过执行 ✓"]

    style A fill:#ff6b6b
    style B fill:#ffd93d
    style C fill:#ffd93d
    style D fill:#ffd93d
    style B_result fill:#6bcb77
    style C_skip fill:#6bcb77
    style D_skip fill:#6bcb77

在这个例子中,尽管 count 确实变了(1 → 2),但 isPositive 的值没变(都是 true),所以 message 和 effect 都不需要重新执行。两级脏标记让 checkDirty() 能够在发现中间 computed 值没变时立即短路,避免向下传播。

实际收益的量化分析

在典型的 Vue 应用中,以下模式非常常见:

// 模式 1:权限过滤
const user = ref({ role: 'admin', name: 'Alice' })
const isAdmin = computed(() => user.value.role === 'admin')
const adminActions = computed(() => isAdmin.value ? getAdminActions() : [])

// 当 user.name 改变时(比如 'Alice' → 'Bob')
// isAdmin 重算但值不变 → adminActions 跳过 → UI 跳过重渲染

// 模式 2:数据格式化
const rawData = ref([...])  // 10000 条数据
const filtered = computed(() => rawData.value.filter(x => x.active))
const formatted = computed(() => filtered.value.map(x => formatRow(x)))

// 当 rawData 中一条非 active 的数据变化时
// filtered 重算但结果相同 → formatted 跳过 → 避免 10000 次 formatRow()

💡 最佳实践

利用 computed 的”变化防火墙”特性,你可以在性能敏感的路径上有意插入 boolean/enum 类型的 computed 作为”过滤层”。这类 computed 的输出值域很小,能有效截断不必要的更新传播。例如 computed(() => data.length > 0) 可以保护后续 computed 不在数据量变化但非空状态不变时被重算。

6.8 effect 的调度与批处理

effect 和 computed 的调度机制差别很大——computed 走的是惰性路径(被读取才算),effect 必须主动执行(因为没人会来””它)。这就让 effect 的调度变成一个独立问题:什么时候触发 effect 的执行?同步?微任务?下一个动画帧?。本节讲 Vue 如何通过 Scheduler 把 effect 的执行统一到”微任务末尾批处理”——一次 tick 内的多次触发会被合并成一次执行、避免无效重复。

queueJob:延迟调度

propagate() 中,当 effect 被通知时,它并不会立即执行,而是通过 scheduler 被加入微任务队列:

// packages/runtime-core/src/scheduler.ts(简化)

const queue: SchedulerJob[] = []
let isFlushing = false

function queueJob(job: SchedulerJob): void {
  // 去重:同一个 job 不会被重复加入队列
  if (!queue.includes(job)) {
    queue.push(job)
    if (!isFlushing) {
      isFlushing = true
      Promise.resolve().then(flushJobs)
    }
  }
}

function flushJobs(): void {
  // 按组件树的深度排序——父组件先更新
  queue.sort((a, b) => getId(a) - getId(b))

  for (let i = 0; i < queue.length; i++) {
    queue[i]()
  }
  queue.length = 0
  isFlushing = false
}

批处理的威力

延迟调度让多次同步修改只触发一次更新:

const count = ref(0)
const doubled = computed(() => count.value * 2)

effect(() => {
  console.log(doubled.value)
})
// 输出: 0

// 同步修改三次
count.value = 1
count.value = 2
count.value = 3
// 只输出一次: 6(而不是 2、4、6)

在 Alien Signals 的架构下,这个过程是这样的:

  1. count.value = 1propagate() 标记 doubled 为 DIRTY,调度 effect
  2. count.value = 2propagate() 标记 doubled 为 DIRTY,effect 已在队列中(去重)
  3. count.value = 3 → 同上
  4. 微任务执行 → effect 运行 → 读取 doubled.valuecheckDirty() → 重算 → 值为 6

最终,doubled 的 getter 只被调用了 一次,而不是三次。

6.9 性能基准与实际影响

性能升级的最终验证标准永远是 benchmark。Alien Signals 的性能跑分相当亮眼——但更值得关注的是”这些跑分数据在真实项目里会变成什么”。不是每个 benchmark 结果都会直接转化成用户体验改进——有些场景本来就不是瓶颈、优化再多也没感知;有些场景反而会因为这次升级获得数量级改善。本节把 benchmark 数据和真实项目场景连起来——帮你判断 Vue 3.5-3.6 升级对你的项目具体意味着什么。

js-reactivity-benchmark 测试结果

在社区广泛使用的 js-reactivity-benchmark 基准测试中,Alien Signals 的表现:

测试项Vue 3.4Vue 3.5 (Alien Signals)提升幅度
简单传播 (1:1)12.3ms5.1ms59%
扇出传播 (1:1000)45.7ms18.2ms60%
深层 computed 链 (depth=100)28.9ms8.7ms70%
动态依赖切换19.4ms11.3ms42%
内存占用 (10k deps)4.2MB1.8MB57%
创建 10k ref8.1ms3.2ms60%

实际应用中的影响

对于日常的 Vue 开发,Alien Signals 的改进主要体现在:

  1. 大型表格/列表:当数据源更新但排序/过滤条件不变时,computed 链可以有效截断不必要的重渲染
  2. 复杂表单:表单验证中大量的 computed 规则只在相关字段变化时才重算
  3. Dashboard:多个图表共享数据源但各自有独立的 computed 转换,数据刷新时只更新真正受影响的图表
  4. SSR:依赖收集和清理的开销降低直接改善了服务端渲染的吞吐量
// 大型表格的典型场景——Alien Signals 的优势尤为明显

const rawData = ref<Row[]>([])        // 原始数据:10,000 行
const sortKey = ref('name')           // 排序字段
const filterText = ref('')            // 过滤文本
const pageSize = ref(50)              // 每页条数
const currentPage = ref(1)            // 当前页

// computed 链
const filtered = computed(() =>
  rawData.value.filter(row => row.name.includes(filterText.value))
)
const sorted = computed(() =>
  [...filtered.value].sort((a, b) => compare(a, b, sortKey.value))
)
const paginated = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  return sorted.value.slice(start, start + pageSize.value)
})

// 当 currentPage 从 1 变为 2 时:
// - rawData 没变 → filtered 版本号匹配 → 跳过
// - sortKey 没变 → sorted 版本号匹配(filtered 也没变) → 跳过
// - currentPage 变了 → paginated 重算 → 但只是 slice 操作
// 总开销:一次 slice(50, 100),而不是重新过滤 + 排序 10,000 行

🔥 深度洞察

Alien Signals 的性能优势在”依赖图复杂度高但实际变化范围小”的场景中最为显著。这恰恰是现代前端应用的典型特征——应用有大量的状态和衍生计算,但每次用户交互通常只影响其中一小部分。Alien Signals 的版本计数 + 混合推拉模型确保了”只有真正受影响的部分才会被重新计算”。这不是微优化——在复杂应用中,这可以将重渲染的 JavaScript 执行时间减少 30-60%。

6.10 与其他信号库的对比

把 Alien Signals 和 Solid Signals、Preact Signals、MobX、Angular Signals 放在一起对比非常有意义——所有这些库解决的是同一个问题、走的是类似的路线、得到的却是不同的实现。通过横向对比你会发现:响应式库的设计空间并不大——所有核心决策无非在几个维度(依赖存储结构、推拉模型、脏检查策略、批处理粒度)上做不同组合。每种组合都有自己的 trade-off——没有”最好的”、只有”最适合某种场景的”。理解这种 trade-off 让你看任何响应式库都不会盲目——你会立刻知道它的优势和代价。

架构对比

维度Vue 3.5 (Alien Signals)Solid.jsPreact SignalsAngular Signals
依赖存储双向链表 + 6指针 Link数组单向链表数组(computed graph)
脏检查版本计数 + globalVersionepoch 计数版本计数脏标记位
computed 模型惰性求值(推标记 + 拉值)惰性求值惰性求值惰性求值
批处理微任务队列 (queueJob)同步批处理 (batch)同步批处理 (batch)Zone.js / Signal API
内存效率最高(Link 复用)中等中等
GC 友好度极好(链表节点可复用)一般
与框架集成深度集成(组件/模板/SSR)深度集成浅集成(preact 适配)深度集成

Alien Signals 的独特优势

  1. 双向链表的内存效率:Preact Signals 使用单向链表,遍历 Subscriber 的所有依赖需要额外存储;Solid.js 使用数组,resize 会产生 GC 压力。Alien Signals 的双向链表在遍历和修改上都是 O(1),且不需要动态分配。

  2. globalVersion 快速路径:这是 Alien Signals 的独创设计,其他库没有类似机制。在”读多写少”的场景中,这个优化的命中率极高。

  3. 渐进式 checkDirty():比 Solid.js 的 epoch 检查更细粒度——Solid.js 在 epoch 不匹配时会重算所有标记为 stale 的 computed,而 Alien Signals 可以在中间任何一层发现”值没变”后立即短路。

6.11 源码中的边界情况处理

响应式系统的核心算法其实不复杂(前几节讲的就是全部核心)——真正让源码变复杂的是边界情况循环依赖、嵌套 effect、递归 trigger、停止中的 effect 被再次 trigger……。这些边界情况在日常使用里很少触发、但一旦触发就会导致奇怪的 bug(无限循环、不更新、报错)。Vue 的 Alien Signals 实现用了大量代码处理这些边界——本节把最关键的几个拎出来讲、让你理解”生产级响应式系统”相比教学演示代码的真实复杂度差距。

循环依赖检测

// 自引用 computed 会导致无限递归
const evil = computed(() => evil.value + 1)  // ❌

// Alien Signals 通过 RUNNING 标志位检测
get value(): T {
  if (this._flags & SubscriberFlags.RUNNING) {
    // 正在计算中又被读取 → 循环依赖
    warn('Detected getter of a computed that has a cyclic dependency')
    return this._value  // 返回旧值,避免崩溃
  }
  // ...
}

effect 嵌套

const outer = ref(0)
const inner = ref(0)

effect(() => {
  console.log('outer:', outer.value)

  effect(() => {
    console.log('inner:', inner.value)
  })
})

Alien Signals 通过 activeSub 栈管理嵌套 effect:

// 简化的嵌套处理
function runEffect(effect: ReactiveEffect) {
  const prevSub = activeSub
  activeSub = effect
  try {
    return effect._fn()
  } finally {
    activeSub = prevSub  // ← 恢复上一层的 subscriber
  }
}

递归 trigger 防护

// 在 effect 中修改自己依赖的数据
const count = ref(0)
effect(() => {
  if (count.value < 10) {
    count.value++  // 在 effect 中修改 → 触发 trigger → 再次执行 effect
  }
})

propagate() 中的 RECURSED 标志位防止了无限递归:

function propagate(subs: Link): void {
  let link = subs
  while (link) {
    const sub = link.sub
    if (sub._flags & SubscriberFlags.RECURSED) {
      // 已经在递归传播中 → 跳过
      link = link.nextSub
      continue
    }
    sub._flags |= SubscriberFlags.RECURSED
    // ... 正常传播
    sub._flags &= ~SubscriberFlags.RECURSED
    link = link.nextSub
  }
}

6.12 迁移影响与兼容性

Alien Signals 的成功有很大一部分归功于它做到了”内核革命 + API 兼容”——用户代码一行不用动、升级到 Vue 3.5-3.6 就能享受所有性能收益。这种”向后兼容的重大升级”在软件史上是很罕见的——大多数”革命性升级”都要求用户重写代码(Python 2→3、Angular 1→2、React 15→16 类组件→函数组件)。Vue 团队做到这件事的功夫在本节拆解——它的核心秘诀是”把行为等价作为设计约束”——新内核必须在所有公开 API 上产生和旧内核一致的观察行为、哪怕内部实现完全不同。

对开发者的影响

Alien Signals 是一次完全向后兼容的内部重写。所有公开 API(refreactivecomputedwatchwatchEffect)的行为没有任何变化。开发者不需要修改一行代码就能享受到性能提升。

但有一些行为微调值得注意:

// 1. computed 的副作用时机
// Vue 3.4:computed 在 trigger 时立即重算
// Vue 3.5:computed 在被读取时才重算

const count = ref(0)
const doubled = computed(() => {
  console.log('computing...')  // 副作用
  return count.value * 2
})

count.value = 1
// Vue 3.4:立即打印 'computing...'
// Vue 3.5:不打印(直到有人读取 doubled.value)

// 2. 多个 computed 的求值顺序
// Vue 3.4:按 trigger 传播顺序
// Vue 3.5:按读取顺序(因为是惰性求值)

💡 最佳实践

虽然 Alien Signals 完全向后兼容,但如果你的代码依赖 computed 的副作用时机(比如在 computed getter 中做日志记录或性能追踪),需要注意惰性求值可能导致这些副作用延迟执行。最佳实践是 computed 的 getter 应该是纯函数——不要在其中执行副作用。如果需要副作用,请使用 watchwatchEffect

对生态系统的影响

组件/库影响说明
Pinia无影响store 底层使用 reactive/ref,自动受益
VueUse无影响基于公开 API,不依赖内部结构
Vue Router无影响路由状态使用 reactive/ref
Vuetify/Element Plus性能提升复杂组件的 computed 链受益显著
自定义 Reactivity 插件可能需要适配如果直接操作了内部的 Dep/effect 结构

6.13 本章小结

本章完成了对 Vue 3.5-3.6 响应式系统第三代内核——Alien Signals——的完整剖析。从旧系统问题(6.1)到新核心数据结构(6.2 Link 双向链表)、版本计数(6.3)、混合推拉模型(6.4)、依赖生命周期(6.5)、computed 双重身份(6.6)、两级脏标记(6.7)、effect 调度(6.8)、性能基准(6.9)、横向对比(6.10)、边界情况(6.11)、兼容性(6.12)——这是一次”从动机到实现到影响”的完整学习旅程。

四个最重要的 take-home 思想

  1. 优秀的底层重构可以做到”API 不变、性能数量级提升——Alien Signals 就是这件事的典范。下次看到任何”革命性升级”的宣称时,用这个标准去衡量:是不是真的保留了 API 兼容性?还是要求用户重写?
  2. 推 vs 拉”的本质是”工作量 vs 消费模式”的匹配——没有哪一种是绝对的最优、只有根据下游类型(effect / computed)做差异化选择才能取得最优综合效果。
  3. 数据结构选择决定系统上限——从 Set 到双向链表看似只是一个数据结构的替换,但它让所有上层算法的复杂度全部改善。想优化一个系统的性能、先检查它的核心数据结构——这是所有资深工程师的本能反应。
  4. 开源社区能让好算法快速流行——Alien Signals 从一个独立开发者的实验项目到 Vue 核心的内核只用了 3 个月。只要技术足够好、社区会推动它以最快速度扩散。

延伸阅读

  • Alien Signals 源码仓库:stackblitz/alien-signals(原始独立实现,不到 500 行代码,强烈建议精读)。
  • Vue 3 源码 packages/reactivity/src/dep.tscomputed.tseffect.ts:本章讨论的 Vue 适配版 Alien Signals 的实现位置。
  • Johnson Chu 博客:Alien Signals 作者的技术博客,有关于这个算法的详细设计文档。
  • TC39 Signals 提案:JavaScript 语言标准里的 Signals 讨论,Vue、Angular、Solid 团队成员共同参与,未来可能让响应式成为 JS 原生能力。
  • 尤雨溪 Vue Conf 2024 演讲:官方对 3.5 响应式升级的解读,包含 benchmark 数据和设计取舍讨论。
  • Jason Miller Preact Signals blog:Preact Signals 作者的原理介绍,和 Alien Signals 做法极其接近,对比阅读有很强启发。

本章深入剖析了 Vue 3.5 Alien Signals 的完整架构,揭示了这次”第三次响应式革命”的技术内核:

  1. Link 双向链表替代了 WeakMap→Map→Set 三层映射,实现了 O(1) 的依赖插入/删除和极致的内存效率。每个 Link 节点用 6 个指针同时参与 Dep 的订阅者链和 Subscriber 的依赖链。

  2. 版本计数用简单的整数比较替代了复杂的标记位操作。每个 Dep 维护递增的 _version,每个 Link 缓存上次观察到的版本号。判断依赖是否变化只需一次整数比较。

  3. 混合推拉模型实现了 computed 的真正惰性求值。推阶段(propagate)只传播脏标记,不执行任何计算;拉阶段(checkDirty)在读取时渐进式检查依赖是否真正变化。不被读取的 computed 永远不会被重算。

  4. DIRTY 与 MAYBE_DIRTY 两级标记让 computed 链能够在中间节点”值没变”时短路传播,避免不必要的下游重算。

  5. globalVersion 快速路径用一个全局计数器实现了”是否有任何变化”的瞬时判断,在读多写少的场景中将 computed 的读取开销降到近乎为零。

  6. 完全向后兼容——所有公开 API 的行为不变,开发者无需修改代码即可享受 40-60% 的性能提升和 56% 的内存降低。

Alien Signals 不仅仅是一次性能优化,更是 Vue 响应式系统设计哲学的一次根本转变——从”变化发生时立即处理一切”到”变化发生时做最少的标记,读取时才做最少的计算”。这种”最小化功”的理念,贯穿了系统设计的每一个层面。


思考题

  1. 概念理解:Alien Signals 使用双向链表替代 Set 来存储依赖关系。请分析在以下两个场景中,两种数据结构的性能差异:(a)一个 Dep 有 1000 个订阅者,需要遍历通知;(b)一个 effect 重新执行,需要清理 50 个旧依赖并添加 48 个新依赖(其中 45 个与旧依赖相同)。

  2. 深入思考checkDirty() 使用递归来检查 computed 链是否真正脏。在一个 50 层嵌套的 computed 链中,最坏情况下的调用栈深度是多少?如果每一层的 computed 都有 3 个依赖(其中 2 个是 computed),这个数字会变成多少?请分析 Alien Signals 是否有针对这种深层递归的优化。

  3. 工程实践globalVersion 是一个简单的递增整数。在长期运行的 SPA 中,如果用户连续操作数小时不刷新页面,globalVersion 是否有溢出风险?JavaScript 的 Number.MAX_SAFE_INTEGER(2^53 - 1)能支撑多久?请计算假设每秒 1000 次 trigger 的情况。

  4. 横向对比:Solid.js 不使用虚拟 DOM,computed 的更新可以直接操作真实 DOM。而 Vue 的 computed 更新需要经过 vDOM diff。请分析 Alien Signals 的惰性求值在有/无 vDOM 的场景下,收益是否有本质差异。

  5. 开放讨论:Alien Signals 的完全向后兼容意味着它没有暴露新的 API。如果 Vue 团队决定在未来暴露底层的 Signal 原语(如 Signal.subtle 提案),你认为应该暴露哪些能力?这对生态系统有什么影响?


延伸阅读:响应式系统三代演化

响应式编程的概念可追溯到 1997 年的 Elm、Flapjax 等 FRP 语言,更早还有电子表格(Excel、VisiCalc)的单元格依赖。从前端框架角度看大致分为三代:

  • 第一代 脏检查:Angular 1、早期 Ember。实现简单,O(n) 扫描。
  • 第二代 观察者模式:Knockout、MobX、Vue 2。精准追踪,但 Set/Map 分配有内存开销。
  • 第三代 Signal 模型:Solid、Preact Signals、Vue 3.5+ Alien Signals。精准且低开销。

三代并非严格”后者取代前者”——低频更新场景脏检查仍然足够,Vue 2 观察者模式在中等规模下也够用,Signal 模型的优势在”高频更新 + 大量 computed”。《React 18 源码》第 3 章和《Rust 编译器与运行时揭秘》第 9 章都讨论过同类问题的代际演化。

延伸阅读:TC39 Signals 提案

2024 年 TC39 启动了 Signals 提案(Stage 1),Vue、Angular、Solid、Preact 的核心成员共同推动。如果落地(预期 2026-2028),JavaScript 将原生支持 Signal.State / Signal.Computed,不再依赖任何框架。

Alien Signals 是该提案的主要参考实现之一。Vue、Solid、Preact 的底层算法正在相互借鉴,未来有机会共享同一套响应式原语。本章讨论的链表结构、版本计数、推拉混合、DIRTY / MAYBE_DIRTY 这些概念,在 Solid、Preact 以及未来的原生 Signals 里都是通用的。