Vue 3 设计与实现
第 6 章 Vue 3.5 Alien Signals:响应式的第三次革命
第 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 引入了”双缓冲标记位”优化(w 和 n 标记),避免了全量清理:
// 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 证明了编译期 + 细粒度响应式可以彻底干掉 VDOM;Preact Signals 证明了独立的信号库可以跨框架工作;Alien Signals 把这些前辈的共同智慧加上 Johnson Chu 自己的几个关键创新(双向链表、版本计数、混合推拉)做成了”最快的信号库”。开源社区的进步就是这样——每一个新项目都不是从零开始、而是整合前人的智慧再加一点自己的创新。
2023-2024 年,前端社区掀起了一场”信号革命”(Signals Revolution)。Solid.js、Preact Signals、Angular Signals 相继证明了一种新的响应式范式的可行性:
| 特性 | Vue 3.0–3.4 | Solid.js | Preact Signals | Alien Signals |
|---|---|---|---|---|
| 依赖存储 | WeakMap→Map→Set | 链表 | 链表 | 双向链表 |
| 传播模型 | 纯推 | 推拉混合 | 推拉混合 | 推拉混合 |
| 脏检查 | 标记位 | 版本计数 | 版本计数 | 版本计数 |
| computed 求值 | 推送时重算 | 读取时重算 | 读取时重算 | 读取时重算 |
| 内存模型 | 对象密集 | 链表节点 | 链表节点 | 链表节点 |
Johnson Chu 的贡献在于:他不仅吸收了社区的最佳实践,还在数据结构层面做了极致优化——Alien Signals 的 Link 节点设计比 Preact Signals 更紧凑,依赖遍历比 Solid.js 更高效。在 js-reactivity-benchmark 基准测试中,Alien Signals 在几乎所有项目上都排名第一。
6.2 核心数据结构:Link 双向链表
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?三个原因:
内存连续性:Link 是一个纯数据对象(6 个指针 + 2 个引用),在 V8 中只占约 80 字节。而一个包含 1 个元素的 Set 需要约 128 字节——Set 自身的开销就超过了 Link 节点。当依赖关系数量为 N 时,链表方案节省的内存随 N 线性增长。
O(1) 操作:链表的插入和删除都是 O(1),而 Set 的 delete 操作虽然平均 O(1),但需要 hash 计算和可能的冲突处理。在高频率的依赖收集/清理场景中,链表的常数因子更小。
零 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 订阅了price和quantitytotal(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.0 | Vue 3.2–3.4 | Vue 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
}
}
关键设计:
- computed 不立即重算:只打标记(DIRTY),不执行 getter 函数
- effect 不立即执行:只调度(scheduler),不真正运行
- 传播是深度优先的:沿 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 重算,值仍为true→ c 不需要重算 → 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/reactive | DIRTY | 一定需要重算 |
| 订阅了一个被标记为 DIRTY 的 computed | MAYBE_DIRTY | 可能需要重算(取决于 computed 值是否变化) |
| 订阅了一个被标记为 MAYBE_DIRTY 的 computed | MAYBE_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 的架构下,这个过程是这样的:
count.value = 1→propagate()标记 doubled 为 DIRTY,调度 effectcount.value = 2→propagate()标记 doubled 为 DIRTY,effect 已在队列中(去重)count.value = 3→ 同上- 微任务执行 → effect 运行 → 读取
doubled.value→checkDirty()→ 重算 → 值为 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.4 | Vue 3.5 (Alien Signals) | 提升幅度 |
|---|---|---|---|
| 简单传播 (1:1) | 12.3ms | 5.1ms | 59% |
| 扇出传播 (1:1000) | 45.7ms | 18.2ms | 60% |
| 深层 computed 链 (depth=100) | 28.9ms | 8.7ms | 70% |
| 动态依赖切换 | 19.4ms | 11.3ms | 42% |
| 内存占用 (10k deps) | 4.2MB | 1.8MB | 57% |
| 创建 10k ref | 8.1ms | 3.2ms | 60% |
实际应用中的影响
对于日常的 Vue 开发,Alien Signals 的改进主要体现在:
- 大型表格/列表:当数据源更新但排序/过滤条件不变时,computed 链可以有效截断不必要的重渲染
- 复杂表单:表单验证中大量的 computed 规则只在相关字段变化时才重算
- Dashboard:多个图表共享数据源但各自有独立的 computed 转换,数据刷新时只更新真正受影响的图表
- 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.js | Preact Signals | Angular Signals |
|---|---|---|---|---|
| 依赖存储 | 双向链表 + 6指针 Link | 数组 | 单向链表 | 数组(computed graph) |
| 脏检查 | 版本计数 + globalVersion | epoch 计数 | 版本计数 | 脏标记位 |
| computed 模型 | 惰性求值(推标记 + 拉值) | 惰性求值 | 惰性求值 | 惰性求值 |
| 批处理 | 微任务队列 (queueJob) | 同步批处理 (batch) | 同步批处理 (batch) | Zone.js / Signal API |
| 内存效率 | 最高(Link 复用) | 中等 | 高 | 中等 |
| GC 友好度 | 极好(链表节点可复用) | 好 | 好 | 一般 |
| 与框架集成 | 深度集成(组件/模板/SSR) | 深度集成 | 浅集成(preact 适配) | 深度集成 |
Alien Signals 的独特优势
-
双向链表的内存效率:Preact Signals 使用单向链表,遍历 Subscriber 的所有依赖需要额外存储;Solid.js 使用数组,resize 会产生 GC 压力。Alien Signals 的双向链表在遍历和修改上都是 O(1),且不需要动态分配。
-
globalVersion 快速路径:这是 Alien Signals 的独创设计,其他库没有类似机制。在”读多写少”的场景中,这个优化的命中率极高。
-
渐进式 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(ref、reactive、computed、watch、watchEffect)的行为没有任何变化。开发者不需要修改一行代码就能享受到性能提升。
但有一些行为微调值得注意:
// 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 应该是纯函数——不要在其中执行副作用。如果需要副作用,请使用
watch或watchEffect。
对生态系统的影响
| 组件/库 | 影响 | 说明 |
|---|---|---|
| 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 思想:
- 优秀的底层重构可以做到”API 不变、性能数量级提升”——Alien Signals 就是这件事的典范。下次看到任何”革命性升级”的宣称时,用这个标准去衡量:是不是真的保留了 API 兼容性?还是要求用户重写?
- “推 vs 拉”的本质是”工作量 vs 消费模式”的匹配——没有哪一种是绝对的最优、只有根据下游类型(effect / computed)做差异化选择才能取得最优综合效果。
- 数据结构选择决定系统上限——从 Set 到双向链表看似只是一个数据结构的替换,但它让所有上层算法的复杂度全部改善。想优化一个系统的性能、先检查它的核心数据结构——这是所有资深工程师的本能反应。
- 开源社区能让好算法快速流行——Alien Signals 从一个独立开发者的实验项目到 Vue 核心的内核只用了 3 个月。只要技术足够好、社区会推动它以最快速度扩散。
延伸阅读
- Alien Signals 源码仓库:
stackblitz/alien-signals(原始独立实现,不到 500 行代码,强烈建议精读)。 - Vue 3 源码
packages/reactivity/src/dep.ts、computed.ts、effect.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 的完整架构,揭示了这次”第三次响应式革命”的技术内核:
-
Link 双向链表替代了 WeakMap→Map→Set 三层映射,实现了 O(1) 的依赖插入/删除和极致的内存效率。每个 Link 节点用 6 个指针同时参与 Dep 的订阅者链和 Subscriber 的依赖链。
-
版本计数用简单的整数比较替代了复杂的标记位操作。每个 Dep 维护递增的
_version,每个 Link 缓存上次观察到的版本号。判断依赖是否变化只需一次整数比较。 -
混合推拉模型实现了 computed 的真正惰性求值。推阶段(propagate)只传播脏标记,不执行任何计算;拉阶段(checkDirty)在读取时渐进式检查依赖是否真正变化。不被读取的 computed 永远不会被重算。
-
DIRTY 与 MAYBE_DIRTY 两级标记让 computed 链能够在中间节点”值没变”时短路传播,避免不必要的下游重算。
-
globalVersion 快速路径用一个全局计数器实现了”是否有任何变化”的瞬时判断,在读多写少的场景中将 computed 的读取开销降到近乎为零。
-
完全向后兼容——所有公开 API 的行为不变,开发者无需修改代码即可享受 40-60% 的性能提升和 56% 的内存降低。
Alien Signals 不仅仅是一次性能优化,更是 Vue 响应式系统设计哲学的一次根本转变——从”变化发生时立即处理一切”到”变化发生时做最少的标记,读取时才做最少的计算”。这种”最小化功”的理念,贯穿了系统设计的每一个层面。
思考题
-
概念理解:Alien Signals 使用双向链表替代 Set 来存储依赖关系。请分析在以下两个场景中,两种数据结构的性能差异:(a)一个 Dep 有 1000 个订阅者,需要遍历通知;(b)一个 effect 重新执行,需要清理 50 个旧依赖并添加 48 个新依赖(其中 45 个与旧依赖相同)。
-
深入思考:
checkDirty()使用递归来检查 computed 链是否真正脏。在一个 50 层嵌套的 computed 链中,最坏情况下的调用栈深度是多少?如果每一层的 computed 都有 3 个依赖(其中 2 个是 computed),这个数字会变成多少?请分析 Alien Signals 是否有针对这种深层递归的优化。 -
工程实践:
globalVersion是一个简单的递增整数。在长期运行的 SPA 中,如果用户连续操作数小时不刷新页面,globalVersion是否有溢出风险?JavaScript 的Number.MAX_SAFE_INTEGER(2^53 - 1)能支撑多久?请计算假设每秒 1000 次 trigger 的情况。 -
横向对比:Solid.js 不使用虚拟 DOM,computed 的更新可以直接操作真实 DOM。而 Vue 的 computed 更新需要经过 vDOM diff。请分析 Alien Signals 的惰性求值在有/无 vDOM 的场景下,收益是否有本质差异。
-
开放讨论: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 里都是通用的。