Vue 3 设计与实现

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

作者 杨艺韬 · 17,601 字 · 发布于 · 更新于

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

本章要点

  • Alien Signals 的设计动机:为什么 Vue 需要第三代响应式内核
  • 从 WeakMap 到 Link 双向链表:依赖存储结构的彻底重构
  • ReactiveFlags 位标记:Dirty / Pending 两级脏标记如何取代 3.5 的版本计数
  • 混合推拉模型(Push-Pull):computed 的惰性求值革命
  • ReactiveNode 统一接口:Dep / computed / effect / effectScope 共用的 Link 六指针协议
  • propagate() / shallowPropagate() / checkDirty():推标记、拉校验的完整链路
  • 横向对比:Alien Signals vs Vue 3.5 vs Solid.js vs Preact Signals

2023 年 10 月,Vue 核心团队成员 Johnson Chu 在 GitHub 上创建了一个不起眼的仓库——如今的 stackblitz/alien-signals。仓库描述只有一句话:"The lightest signal library"(最轻的信号库)。

2024 年 12 月 2 日,Johnson Chu 的 PR #12349 "perf(reactivity): refactor reactivity core by porting alien-signals" 合并进 vuejs/core,第一个带上它的 tag 是 v3.6.0-alpha.1。3.6 的发布说明只用一句话概括这次改动:"a major refactor of @vue/reactivity based on alien-signals, which significantly improves the reactivity system's performance and memory usage"。这不是一次渐进式优化——3.5 刚刚重写过一遍的响应式内核,在 3.6 又被整体替换了一次。

从 Vue 3.0 到 Vue 3.6,响应式内核经历了三次重写。第一次是 Vue 3.0 用 Proxy 取代 Vue 2 的 Object.defineProperty,解决了"不能检测新增属性"的历史顽疾,并建立了 WeakMap → Map → Set 的依赖存储;此后的两次优化都在这套结构上进行:Vue 3.2 用 w/n 双标记位替代了 Set 的全量清理,Vue 3.4 又把它换成 _trackId + _depsLength 游标、把每个 key 的 Set 换成 Map<ReactiveEffect, number>,并引入 DirtyLevels。第二次重写是 Vue 3.5(PR #10397,2024 年 9 月发布)把集合换成了 Link 双向链表,并用 Dep.version / globalVersion 的版本计数来判断依赖是否变化;第三次就是本章要讲的 3.6 Alien Signals。3.0 到 3.4 都在 WeakMap → Map → 集合的范式内打转;3.5 换掉了集合,但"比较版本号"仍然是判脏的核心手段。

Alien Signals 在 3.5 的链表之上再往前走了一步:链表保留,但把版本号比较换成了 ReactiveFlags 位标记(Mutable / Watching / RecursedCheck / Recursed / Dirty / Pending),把依赖收集、脏检查、传播三件事全部收进一个 366 行的 system.ts,用 propagate()(推标记)+ checkDirty()(拉校验)的混合推拉模型跑通一切。这不是"更快的同一条路",而是"换了一条完全不同的路"。

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

本章在全专栏中的独特位置

这一章是全专栏最"前沿"的一章——它讲的内容是 2024 年才发生的、2026 年还在持续演进的。第 4、5 章从 reactive / ref / effect 这些经典响应式 API 入手读 rc.4 的实现(这些 API 的用法从 3.x 早期至今未变),本章讲的是这些 API 背后的引擎在 3.5 和 3.6 连续两次被整体替换的故事。用户代码不用动,但底下跑的机制已经是另一套。这种"保持 API 兼容、内核彻底重构"的升级方式本身就很难得——很多大型软件的大版本升级做不到这一点。

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

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

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

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

Vue 3.0–3.4 的依赖存储架构

理解 Alien Signals 的收益前、先仔细看看 3.5 之前的依赖存储是什么样。Vue 3.0-3.4 的依赖存储虽然在表面 API 上让 Vue 3 成为当时最快的响应式框架之一,但在内部结构上其实沿用了 Vue 2 的思路——只是换了底层代理技术(defineProperty → Proxy)。真正的架构重构要等到 3.5 的双向链表和 3.6 的 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>   // 3.0–3.3;3.4 换成 Map<ReactiveEffect, number>(值是 trackId)

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

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

但三个问题逐渐浮现:

问题一:内存开销

每一个属性的 Dep 都是一个 Set 对象(3.4 起是 Map)。Set 自带哈希表结构,即使只装一两个元素也有不小的固定开销;组件和响应式属性一多,这部分开销随之线性累积。下面按一个假设的单价粗算一下量级:

// 粗略估算
const emptySetSize = 64  // 假设值(字节),仅作量级示意,实际大小随引擎和版本而变
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 cleanup(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 开销并没有减少。

问题三:传播效率

Vue 3 的 computed 从 3.0 起就是惰性的(_dirty 标志,读取时才重算),但在 3.0–3.3 里 computed 的 scheduler 只做一件事——把自己标脏、然后无条件触发下游:

// Vue 3.0 packages/reactivity/src/computed.ts(简化)
scheduler: () => {
  if (!this._dirty) {
    this._dirty = true
    trigger(toRaw(this), TriggerOpTypes.SET, 'value')   // ← 不管值变没变,先通知下游
  }
}

于是就有了这样的浪费:

const count = ref(1)
const isPositive = computed(() => count.value > 0)
effect(() => console.log(isPositive.value))

count.value = 2  // isPositive 的值仍是 true
// 3.0–3.3:isPositive 标脏 → 无条件 trigger → effect 重跑 → 重算 isPositive → 值没变,白跑一趟

3.4 引入 DirtyLevels(ComputedValueMaybeDirty / ComputedValueDirty / Dirty)第一次把"可能脏"和"确定脏"分开——这次重构是 PR #5912(commit 16e06ca08,git tag --contains 的首个 tag 是 v3.4.0-alpha.1)一次打包完成的:同一个 commit 既删掉了 3.2 引入的 w/n 位标记(PR #4017,首个 tag v3.2.0-beta.1),又引入了 DirtyLevels,还把依赖清理换成了 _depsLength 游标——最后这一项已经是 3.6 startTracking / endTracking 游标法的前身,effect 被调度时会先向上核实 computed 的值到底变没变——这已经是推拉混合的雏形。但它是在 Map + trackId 结构上打的补丁,判定逻辑散落在 effect.ts / computed.ts 各处。

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

信号社区的启发

Alien Signals 不是凭空冒出来的——它站在 Solid Signals、Preact Signals、SolidJS 2017 年开始的细粒度响应式探索、以及 Knockout / MobX 一脉响应式框架的肩膀上。每个前辈都贡献了一块拼图:Solid 证明了编译期 + 细粒度响应式可以彻底干掉 VDOM;Preact Signals 证明了独立的信号库可以跨框架工作;Alien Signals 把这些前辈的共同智慧加上 Johnson Chu 自己的几个关键设计(统一的 ReactiveNode 接口、Dirty / Pending 两级位标记、非递归的 propagate / checkDirty)做成了一个又轻又快的信号库。开源社区的进步就是这样——每一个新项目都不是从零开始、而是整合前人的智慧再加一点自己的创新。

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

特性 Vue 3.0–3.4 Vue 3.5 Solid.js Preact Signals Alien Signals(Vue 3.6)
依赖存储 WeakMap→Map→Set(3.4 为 Map + trackId) Link 双向链表 数组(sources / observers) 双向链表(Node) Link 双向链表
传播模型 推(3.4 起 computed 有拉校验) 推拉混合 推拉混合 推拉混合 推拉混合
脏检查 dirty 标志 / DirtyLevels 版本计数(Dep.version + globalVersion) STALE / PENDING 状态标记 版本计数 + globalVersion Dirty / Pending 位标记 + checkDirty
computed 求值 读取时重算 读取时重算 读取时重算 读取时重算 读取时重算
内存模型 对象密集 链表节点 数组 + 槽位索引 链表节点 链表节点

Johnson Chu 的贡献在于:他不仅吸收了社区的最佳实践,还把"依赖节点"这个抽象推到了极致——Dep、computed、effect、effectScope 在 Alien Signals 里是同一个接口 ReactiveNode,一套 link / unlink / propagate / checkDirty 通吃所有节点类型,传播和校验全部用显式栈而不是递归。在社区常用的 js-reactivity-benchmark 里 Alien Signals 长期名列前茅(具体数字随版本和机器变化,建议自己跑一遍而不是记住某个百分比)。

Link 是 Alien Signals 的原子数据结构——整个响应式系统的性能全部建立在它之上。这个小小的数据结构有六个指针(dep / sub / prevDep / nextDep / prevSub / nextSub),每一个都承担着关键角色。读懂 Link 的六指针设计你就理解了 Alien Signals 为什么能做到 O(1) 的依赖插入/删除——Set 的 add / delete 平均也是 O(1),但要付哈希计算与桶重排的常数开销、还会持续制造短命对象给 GC;链表模型下就是纯粹的指针操作、没有任何隐藏成本。

告别 Set,拥抱链表

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

Alien Signals 的核心革新在于用 Link 节点 组成的双向链表替代了 Set 来存储依赖关系。每个 Link 节点同时参与两条链表——dep 一侧的订阅者链(subs)和 sub 一侧的依赖链(deps)——实现了"一个节点、双向连接"的高内存效率。

// packages/reactivity/src/system.ts:8(rc.4,注释为本专栏所加)

export interface ReactiveNode {
  deps?: Link        // 我依赖谁:依赖链表头
  depsTail?: Link    // 依赖链表尾(同时是依赖收集时的"游标")
  subs?: Link        // 谁依赖我:订阅者链表头
  subsTail?: Link    // 订阅者链表尾
  flags: ReactiveFlags
}


export interface Link {
  version: number    // 只用于 link() 去重:记录这条 Link 最近一次被访问时的 globalVersion
  dep: ReactiveNode | Computed | Effect | EffectScope   // 指向依赖源
  sub: ReactiveNode | Computed | Effect | EffectScope   // 指向订阅者

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

  // sub 维度的链表指针
  prevDep: Link | undefined   // sub 的上一个依赖
  nextDep: Link | undefined   // sub 的下一个依赖
}

注意 dep / sub 的类型:ReactiveNode | Computed | Effect | EffectScope——Dep(reactive 对象某个 key 的依赖节点)、RefImpl、ComputedRefImpl、ReactiveEffect、EffectScope 全部实现 ReactiveNode。这意味着 effectScope 收集内部 effect、computed 收集上游 ref、effect 收集 computed,用的都是同一个 link()。

🔥 深度洞察

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

  1. 内存连续性:Link 是一个只有 7 个字段的纯数据对象(1 个整数 + 6 个引用),V8 会给它一个稳定的 hidden class、几十个字节就能装下。而一个 Set 即便只装 1 个元素,也要带上哈希表的 bucket 数组和控制结构——Set 自身的固定开销就超过了一个 Link 节点。当依赖关系数量为 N 时,链表方案节省的内存随 N 线性增长。

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

  3. 零 GC 压力:同一个 sub 重新执行时,旧的 Link 会按访问顺序被原地复用(link() 的"游标"快速路径,见 6.5),依赖不变就一个对象都不分配;而 Set 的内部 bucket 由引擎管理,删除和重新插入都可能触发 rehash。

双向链表的视觉模型

"一个 Link 节点同时属于两条链表"的设计是本章最需要在脑子里画出来的概念——读下面的视觉模型的时候请把图像记住。这种"多重链接"(multiply linked)的数据结构在 Linux 内核里叫 list_head,在数据库索引里叫 skip list node——都是"一个节点参与多个链表"的变体。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 (RefImpl)"] -->|subs| L1[Link 1]
        quantity["quantity (RefImpl)"] -->|subs| L2[Link 2]
        total_dep["total (ComputedRefImpl) 作为 dep"] -->|subs| L3[Link 3]
    end
    subgraph "sub 维度(我订阅了谁?)"
        L1 -->|nextDep| L2
        L2 -.->|prevDep| L1
        computed_sub["total (ComputedRefImpl) 作为 sub"] -->|deps| L1
        computed_sub -->|depsTail| L2
        L3 -.-> effect_sub["effect (ReactiveEffect)"]
        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)作为 sub,通过 Link 1 和 Link 2 订阅了 price 和 quantity(它的 deps 链)
  • total(computed)同时作为 dep,通过 Link 3 被 effect 订阅(它的 subs 链)——同一个对象、两条链、一个接口
  • 每个 Link 节点同时连接在两条链上——这就是"双向"的含义
  • RefImpl 和 ComputedRefImpl 都不再持有单独的 Dep 对象,它们自己就是节点;get dep() 只是返回 this 的兼容 getter

Dep 类与 track():reactive 对象的依赖节点

ref / computed 自己就是 ReactiveNode,那 reactive({ a: 1 }) 的 a 属性靠什么当节点?——靠 dep.ts 里这个只有十几行的 Dep 类:

// packages/reactivity/src/dep.ts:16(精简,rc.4)

class Dep implements ReactiveNode {
  _subs: Link | undefined = undefined
  subsTail: Link | undefined = undefined
  flags: ReactiveFlags = ReactiveFlags.None    // 普通 Dep 既不 Mutable 也不 Watching

  constructor(private map: KeyToDepMap, private key: unknown) {}

  get subs(): Link | undefined {
    return this._subs
  }

  // 最后一个订阅者被 unlink 掉时,顺手把自己从 depsMap 里删掉——不留空 Dep
  set subs(value: Link | undefined) {
    this._subs = value
    if (value === undefined) {
      this.map.delete(this.key)
    }
  }
}

type KeyToDepMap = Map<any, Dep>
export const targetMap: WeakMap<object, KeyToDepMap> = new WeakMap()

export function track(target: object, type: TrackOpTypes, key: unknown): void {
  if (activeSub !== undefined) {
    let depsMap = targetMap.get(target)
    if (!depsMap) targetMap.set(target, (depsMap = new Map()))
    let dep = depsMap.get(key)
    if (!dep) depsMap.set(key, (dep = new Dep(depsMap, key)))
    link(dep, activeSub!)          // ← 真正建链的活交给 system.ts
  }
}

export function trigger(target: object, type: TriggerOpTypes, key?: unknown, ...): void {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const run = (dep: ReactiveNode | undefined) => {
    if (dep !== undefined && dep.subs !== undefined) {
      propagate(dep.subs)          // 推:沿订阅者链打 Pending 标记、effect 进通知队列
      shallowPropagate(dep.subs)   // 直接订阅者从 Pending 升级为 Dirty
    }
  }
  startBatch()
  // ...按 key / ITERATE_KEY / ARRAY_ITERATE_KEY / 'length' 挑出要 run 的 dep(与 3.5 一致)
  endBatch()                        // batchDepth 归零时 flush() 通知队列里的 effect
}

Dep 没有 track() / trigger() 方法、没有 version、没有 activeLink——3.5 那套东西全没了。WeakMap → Map → Dep 的外层索引结构保留下来(这是按 key 找到节点的必需品),但 Dep 本身瘦成了一个"只有订阅者链表"的节点,建链和通知都委托给 system.ts。

💡 最佳实践

注意 link() 里的三条提前返回路径(完整代码见第 5 章):(1)sub.depsTail.dep === dep——同一个 sub 连续两次读同一个 dep,忽略;(2)游标后面那条旧 Link 恰好就是这个 dep——原地复用,游标前进;(3)dep.subsTail 在本轮(version === globalVersion)已经挂过这个 sub——去重。在 effect 重新执行、依赖和上一次相同的常见场景下,路径(2)会连续命中,依赖收集的开销降到了近乎为零,不创建任何对象。

ReactiveFlags:所有节点共用的状态位

// packages/reactivity/src/system.ts:31(rc.4 原文)

export const enum ReactiveFlags {
  None = 0,
  Mutable = 1 << 0,        // 1  我有"值"、可以变:RefImpl / ComputedRefImpl 带这个位
  Watching = 1 << 1,       // 2  我是副作用、要被通知:ReactiveEffect 带这个位
  RecursedCheck = 1 << 2,  // 4  正在收集依赖(startTracking ~ endTracking 之间)
  Recursed = 1 << 3,       // 8  收集依赖期间被自己的依赖触发了
  Dirty = 1 << 4,          // 16 确认脏——需要重算 / 重跑
  Pending = 1 << 5,        // 32 可能脏——上游有 computed 变了,需要 checkDirty 核实
}

// packages/reactivity/src/effect.ts:51:ReactiveEffect / EffectScope 额外使用的高位
export enum EffectFlags {
  ALLOW_RECURSE = 1 << 7,
  PAUSED = 1 << 8,
  STOP = 1 << 10,
}

各类节点的初始 flags 一眼就能看出它们的角色:

节点 初始 flags 含义
Dep(reactive 的某个 key) None 纯依赖源,不需要被校验也不需要被通知
RefImpl Mutable 依赖源,但有值可以比对(写入时打 Dirty,checkDirty 会调它的 update())
ComputedRefImpl Mutable | Dirty 既是依赖源又是订阅者,初始就是脏的(惰性:首次读取才算)
ReactiveEffect Watching | Dirty 订阅者,需要被通知,初始脏(effect() 创建后立刻 run())
EffectScope 0 只借用 Link 链表管理子节点,不参与传播

注意这里没有 3.4 的 DirtyLevels、3.5 的 EffectFlags.TRACKING / NOTIFIED、也没有 Subscriber 接口和 SubscriberFlags——那些名字在 rc.4 源码里一个都搜不到。

6.3 位标记脏检查:Dirty、Pending 与 checkDirty

"判断一个 computed 是否需要重算"是响应式系统的核心问题。Vue 3.0–3.3 靠一个布尔 _dirty(并且值没变也照样通知下游);3.4 用 DirtyLevels 枚举分出"可能脏 / 确定脏";3.5 引入版本计数——每个 Dep 一个 version、每条 Link 快照一份、读取时比对;3.6 的 Alien Signals 又把版本号扔掉了,回到位标记:Dirty 表示"确定要重算"、Pending 表示"上游可能变了、需要核实"。看起来像倒退,实际上是把"值到底变没变"这个问题从"比对整数"改成了"问上游节点本人"——checkDirty() 沿依赖链向上走,遇到 Mutable | Dirty 的节点就调用它的 update(),用 hasChanged(oldValue, newValue) 直接回答。

为什么不用版本号了?

3.5 的版本号方案有一个隐含代价:每个 Dep 和每条 Link 都多存一个整数,且 Dep.trigger() 无论有没有订阅者都要 version++、globalVersion++。更麻烦的是"版本号变了"只能说明"写过",不能说明"值变了"——3.5 的 RefImpl 在 setter 里已经用 hasChanged 过滤过一次,但 computed 重算后值没变的情形还要靠 dep.version 不递增来表达,两套语义搅在一起。

Alien Signals 的做法更直接:变化与否由节点自己回答。

// packages/reactivity/src/ref.ts:125(精简,rc.4)
// 注意:ref.ts:28 把 ./system 的 ReactiveFlags 别名成 _ReactiveFlags,
// 因为同文件 :11 已从 ./constants 引入了另一个同名的 ReactiveFlags(IS_REF 那套)
class RefImpl<T = any> implements ReactiveNode {
  flags: _ReactiveFlags = _ReactiveFlags.Mutable
  _value: T
  private _oldValue: T
  private _rawValue: T

  set value(newValue) {
    if (hasChanged(newValue, this._rawValue)) {
      this.flags |= _ReactiveFlags.Dirty       // ← 只打一个位,没有 version++
      this._rawValue = newValue
      // useDirectValue = this[ReactiveFlags.IS_SHALLOW] || isShallow(newValue)
      //                 || isReadonly(newValue)(ref.ts:170-173,此处精简)
      this._value =
        !useDirectValue && this._wrap ? this._wrap(newValue) : newValue
      if (this.subs !== undefined) {
        propagate(this.subs)                   // 推:下游全部打 Pending
        if (!batchDepth) flush()               // 通知队列里的 effect
      }
    }
  }

  // checkDirty() 向上核实时调用:清掉 Dirty,回答"和上次被读取时比,值变了吗"
  update(): boolean {
    this.flags &= ~_ReactiveFlags.Dirty
    return hasChanged(this._oldValue, (this._oldValue = this._rawValue))
  }
}

ComputedRefImpl.update() 的合同完全一样:重新执行 getter,返回 hasChanged(oldValue, newValue)。于是 checkDirty() 不需要知道任何版本号——它只需要问每个 Mutable | Dirty 的上游节点:"update() 一下,变了没?"

四代脏检查策略对比

策略 Vue 3.0–3.1 Vue 3.2–3.3 Vue 3.5 Vue 3.6(Alien Signals)
依赖存储 Set Set Link 双向链表 Link 双向链表
判脏依据 _dirty 布尔 同左 link.version !== dep.version Dirty / Pending 位 + update() 回答
每次 effect 执行前 遍历所有 deps,从 Set 中 delete 给所有旧 deps 打 w 标记 prepareDeps:所有 Link 的 version = -1 startTracking:depsTail = undefined(游标归零)
每次依赖收集时 Set.add() 打 n 标记 复用 activeLink,同步 version link():游标命中则复用,否则插入
每次 effect 执行后 无 移除 w=1, n=0 的失效依赖 cleanupDeps:移除 version === -1 的 Link endTracking:unlink 游标之后的 Link
传播 递归 trigger 递归 trigger 递归 notify + batch 显式栈的 propagate + notifyBuffer

表里略去了 3.4 这一过渡版本:它把 Dep 换成 Map<effect, trackId>、去掉 w/n 改用 _depsLength 游标、用 DirtyLevels 判脏(见 6.1)。

位标记的工作流程

const count = ref(0)                          // count.flags = Mutable
const doubled = computed(() => count.value * 2) // doubled.flags = Mutable | Dirty

effect(() => {                                // effect.flags = Watching | Dirty
  console.log(doubled.value)
})

// 首次执行
// 1. effect.run() → startTracking:清 Dirty、打 RecursedCheck
// 2. 读 doubled.value:flags 含 Dirty → doubled.update() → 读 count.value → link(count, doubled)
//    → 重算完成,doubled.flags 里 Dirty 被 startTracking 清掉
// 3. 回到 effect:link(doubled, effect)
// 4. endTracking:清 RecursedCheck

// 修改 count
count.value = 1
// 1. hasChanged → count.flags |= Dirty
// 2. propagate(count.subs):
//    doubled 是 Mutable → 打 Pending,继续沿 doubled.subs 往下走
//    effect  是 Watching → 打 Pending,放进 notifyBuffer
// 3. flush() → effect.notify() → effect.dirty getter:
//    没有 Dirty、有 Pending → checkDirty(effect.deps, effect)
//      → 看到 doubled 是 Mutable | Pending → 压栈,钻进 doubled.deps
//      → 看到 count 是 Mutable | Dirty → count.update() 返回 true
//      → 弹栈:doubled.update() 重算,值 0 → 2 变了,返回 true
//    → checkDirty 返回 true → effect.flags |= Dirty → effect.run()

🔥 深度洞察

位标记方案的精妙之处在于惰性传播 + 就地核实。count 被修改时,Vue 只做两件便宜的事:给 count 打 Dirty、沿订阅者链给下游打 Pending。没有任何 getter 被执行。只有当某人真正需要答案时(effect 被 notify、或有人读 computed.value),checkDirty() 才沿着依赖链向上核实,并且核实的方式是让上游节点自己 update()——如果 doubled 在当前渲染周期里根本没人读(比如它在一个 v-if="false" 的分支里),它的 Pending 标记会一直挂着,直到下次被读到才结算。这就是"不读不算"的哲学——与 3.0–3.3 的"值没变也通知下游"形成了根本性的对立。

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

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

纯推 vs 纯拉 vs 混合推拉

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

纯推模型(Push-based)——Vue 3.0–3.3 的 computed 虽然惰性,但"标脏即无条件通知下游"的传播方式更接近这一端

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

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

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

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

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

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

源数据修改 → "推":propagate 沿订阅者链打 Pending 标记(reactive 属性写入时,直接订阅者再由 shallowPropagate 升为 Dirty)→ 停!
读取 computed → "拉":checkDirty 沿依赖链向上核实,让上游节点 update() → 只在真正脏时重算

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

这三个函数都在 packages/reactivity/src/system.ts:propagate 在 :162(它对每个订阅者做的就是 sub.flags = flags | ReactiveFlags.Pending),checkDirty 在 :275,shallowPropagate 在 :342(把仅有 Pending、尚无 Dirty 的订阅者补上 Dirty)。推阶段只打 Pending、不打 Dirty,是这套模型省下重算的关键。

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 打 Dirty
    Source->>C1: 推:propagate 标记 Pending(ref 写入不调 shallowPropagate)
    C1->>C2: 推:标记 Pending
    C2->>E: 推:标记 Pending,effect 进 notifyBuffer
    Note over E: flush → effect.notify() → dirty getter
    E->>C2: 拉:checkDirty 看到 B 是 Mutable | Pending,钻入 B.deps
    C2->>C1: 拉:A 也是 Mutable | Pending,钻入 A.deps
    C1->>Source: 拉:source 是 Mutable | Dirty → source.update() 返回 true
    Note over C1: 回溯:A.update() 重算,值变了 → 返回 true
    Note over C2: 回溯:B.update() 重算,值变了
    C2-->>E: checkDirty 返回 true → effect 打 Dirty
    Note over E: effect.run() 用新值完成渲染

propagate():推阶段的实现

当 ref 的 setter 或 trigger() 被调用时,propagate() 函数沿 subs 链表"推"通知。它比 3.5 的版本长得多,因为它要同时处理 computed 级联、递归检测和深度优先遍历——而且不用递归:

// packages/reactivity/src/system.ts:162(rc.4 原文,注释为本专栏所加)

export function propagate(link: Link): void {
  let next = link.nextSub
  let stack: Stack<Link | undefined> | undefined   // 显式栈:替代递归

  top: do {
    const sub = link.sub
    let flags = sub.flags

    // 只有 Mutable(computed)或 Watching(effect)的节点才值得通知
    if (flags & (ReactiveFlags.Mutable | ReactiveFlags.Watching)) {
      if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed |
                     ReactiveFlags.Dirty | ReactiveFlags.Pending))) {
        // 干净节点 → 打 Pending;如果此刻正有 effect 在跑(runDepth > 0),顺带打 Recursed
        sub.flags = flags | ReactiveFlags.Pending
        if (runDepth) sub.flags |= ReactiveFlags.Recursed
      } else if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed))) {
        // 已经 Dirty / Pending 了 → 不必再往下走
        flags = ReactiveFlags.None
      } else if (!(flags & ReactiveFlags.RecursedCheck)) {
        // 带 Recursed 但没在收集依赖 → 清 Recursed、打 Pending
        sub.flags = (flags & ~ReactiveFlags.Recursed) | ReactiveFlags.Pending
      } else if (!(flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) &&
                 isValidLink(link, sub)) {
        // 正在收集依赖(RecursedCheck)且这条 Link 确实是它本轮的依赖 → 标记 Recursed | Pending
        sub.flags = flags | ReactiveFlags.Recursed | ReactiveFlags.Pending
        flags &= ReactiveFlags.Mutable
      } else {
        flags = ReactiveFlags.None
      }

      // effect:放进通知队列,稍后 flush() 时调用 notify()——不在这里执行
      if (flags & ReactiveFlags.Watching) {
        notifyBuffer[notifyBufferLength++] = sub as Effect
      }

      // computed:它自己也有订阅者 → 深度优先钻下去(有兄弟节点就先把兄弟压栈)
      if (flags & ReactiveFlags.Mutable) {
        const subSubs = sub.subs
        if (subSubs !== undefined) {
          link = subSubs
          if (subSubs.nextSub !== undefined) {
            stack = { value: next, prev: stack }
            next = link.nextSub
          }
          continue
        }
      }
    }

    // 同一层的下一个订阅者
    if ((link = next!) !== undefined) {
      next = link.nextSub
      continue
    }
    // 这一层走完了 → 出栈回到上一层
    while (stack !== undefined) {
      link = stack.value!
      stack = stack.prev
      if (link !== undefined) {
        next = link.nextSub
        continue top
      }
    }
    break
  } while (true)
}

// 紧跟 propagate 之后调用:把"直接订阅者"从 Pending 升级为 Dirty
export function shallowPropagate(link: Link): void {
  do {
    const sub = link.sub
    const nextSub = link.nextSub
    const subFlags = sub.flags
    if ((subFlags & (ReactiveFlags.Pending | ReactiveFlags.Dirty)) === ReactiveFlags.Pending) {
      sub.flags = subFlags | ReactiveFlags.Dirty
    }
    link = nextSub!
  } while (link !== undefined)
}

关键设计:

  1. computed 不立即重算:只打标记(Pending),不执行 getter 函数
  2. effect 不立即执行:只放进 notifyBuffer,等 flush()(endBatch() 或 ref 写入结束时)再逐个 notify()
  3. 传播是深度优先、非递归的:沿 computed 链向下走,兄弟节点用显式 stack 暂存——再深的 computed 链也不会爆调用栈
  4. propagate 一律只打 Pending:谁是"直接订阅者、肯定脏"由紧随其后的 shallowPropagate() 补一刀升级为 Dirty(trigger() 里两者连着调;ref 的 setter 只调 propagate,因为 ref 自己带 Dirty,直接订阅者在 checkDirty 时会立刻问到它)

checkDirty():拉阶段的实现

当一个带 Pending 标记的节点需要答案时(effect 的 dirty getter、computed 的 get value),checkDirty() 被调用来判断是否真的需要重算。它同样用显式栈代替递归:

// packages/reactivity/src/system.ts:275(rc.4 原文,注释为本专栏所加)

export function checkDirty(link: Link, sub: ReactiveNode): boolean {
  let stack: Stack<Link> | undefined
  let checkDepth = 0

  top: do {
    const dep = link.dep
    const depFlags = dep.flags
    let dirty = false

    if (sub.flags & ReactiveFlags.Dirty) {
      // 自己已经被确认脏了(比如中途被 shallowPropagate 升级)→ 不用再看
      dirty = true
    } else if ((depFlags & (ReactiveFlags.Mutable | ReactiveFlags.Dirty)) ===
               (ReactiveFlags.Mutable | ReactiveFlags.Dirty)) {
      // 上游是"确定脏"的 ref / computed → 让它自己 update(),用返回值回答"变了没"
      const subs = dep.subs!
      if ((dep as Computed).update()) {
        if (subs.nextSub !== undefined) shallowPropagate(subs)   // 它的其他订阅者也升为 Dirty
        dirty = true
      }
    } else if ((depFlags & (ReactiveFlags.Mutable | ReactiveFlags.Pending)) ===
               (ReactiveFlags.Mutable | ReactiveFlags.Pending)) {
      // 上游是"可能脏"的 computed → 压栈,钻进它的依赖链继续核实
      stack = { value: link, prev: stack }
      link = dep.deps!
      sub = dep
      ++checkDepth
      continue
    }

    // 这个依赖没变 → 看下一个依赖
    if (!dirty && link.nextDep !== undefined) {
      link = link.nextDep
      continue
    }

    // 回溯:把刚才钻进去的 computed 逐层结算
    while (checkDepth) {
      --checkDepth
      link = stack!.value
      stack = stack!.prev
      if (dirty) {
        const subs = sub.subs!
        if ((sub as Computed).update()) {          // 上游变了 → 重算这个 computed
          if (subs.nextSub !== undefined) shallowPropagate(subs)
          sub = link.sub
          continue                                  // 它的值也变了 → 上一层继续 dirty
        }
      } else {
        sub.flags &= ~ReactiveFlags.Pending         // 上游没变 → 清掉 Pending
      }
      sub = link.sub
      if (link.nextDep !== undefined) {             // 这一层还有别的依赖没看 → 继续
        link = link.nextDep
        continue top
      }
      dirty = false                                 // 这一层看完且没变 → 上一层视为"没变"
    }

    return dirty && !!sub.flags
  } while (true)
}

调用方拿到结果后的处理是对称的(ReactiveEffect.dirty getter 和 ComputedRefImpl 的兼容 getter _dirty 都是这几行,get value 里也是同样的判断):

if (flags & ReactiveFlags.Dirty) return true
if (flags & ReactiveFlags.Pending) {
  if (checkDirty(this.deps!, this)) {
    this.flags = flags | ReactiveFlags.Dirty      // 核实为真脏
    return true
  } else {
    this.flags = flags & ~ReactiveFlags.Pending   // 虚惊一场,清掉 Pending
  }
}
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 时:

  • 推阶段:a 打 Dirty;b、c、d 沿链打 Pending(a 是 ref,setter 只调 propagate,b 也停在 Pending)
  • 拉阶段:effect 读取 d.value → checkDirty 钻进 c → 钻进 b → 钻进 b 的依赖,a 是 Mutable | Dirty → a.update() 返回 true → 回溯到 b:b.update() 重算,值仍为 true,返回 false → c 清掉 Pending,不重算 → d 清掉 Pending,不重算

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

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

"依赖收集"是响应式系统里最动态的部分——effect 每次执行都可能读到不同的依赖(条件分支、循环等导致的动态访问模式)。管理这种动态关系的难点在于:"要知道哪些依赖是上次有、这次没"(需要移除)、"哪些是上次没、这次有"(需要新增)、"哪些两次都有"(可以复用)。3.0/3.1 是"全部重建"——粗暴有效但开销大;3.5 是"标记-清扫"(link.version = -1 再 cleanupDeps);Alien Signals 更进一步——"顺序游标复用":连标记都不打,旧链表原地待命、访问到就复用、访问不到的留在游标后面一刀砍掉。这一节讲的就是这个精巧的增量维护流程(第 5 章已经贴过 link() 的完整代码,这里从 computed 和 effect 的联动角度再走一遍)。

从 effect 创建到依赖建立

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

const name = ref('Vue')
const version = ref('3.6')
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(flags = Watching | Dirty)→ run()
    E->>G: startTracking(effect):activeSub = effect
    E->>E: 清 Dirty、打 RecursedCheck、depsTail = undefined
    Note over E: 2. 执行 fn()
    E->>T: 读取 title.value
    T->>T: flags 含 Dirty(初始 Mutable | Dirty)→ update()
    Note over T: 3. computed 开始求值
    T->>G: startTracking(title):activeSub = title
    T->>N: 读取 name.value
    N->>N: trackRef → link(name, title)
    T->>V: 读取 version.value
    V->>V: trackRef → link(version, title)
    T->>G: endTracking(title, effect):activeSub 恢复为 effect
    Note over T: 4. computed 求值完成
    T->>T: _value = 'Vue 3.6'
    T->>T: link(title, effect)
    T-->>E: 返回 'Vue 3.6'
    Note over E: 5. effect 执行完成
    E->>E: endTracking(effect):unlink 游标之后的旧 Link
    E->>E: 清除 RecursedCheck

依赖清理:链表的优势

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

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

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

// 步骤:
// 1. startTracking:depsTail = undefined,游标回到链表头之前
// 2. 读取 A → 游标后面是 Link(A),dep 匹配 → 复用,游标前进到 Link(A)
// 3. 读取 B → 游标后面是 Link(B),dep 匹配 → 复用,游标前进到 Link(B)
// 4. 读取 E → 游标后面是 Link(C),dep 不匹配 → 在 Link(B) 和 Link(C) 之间插入新的 Link(E)
// 5. endTracking → 游标(Link(E))之后的节点 Link(C)、Link(D) 被 unlink

// 结果:只创建了 Link(E),Link(A)/Link(B) 原地复用,Link(C)/Link(D) 被移除
// 没有 Set 操作,没有 hash 计算,没有版本号标记
// packages/reactivity/src/system.ts:235(rc.4 原文)

export function startTracking(sub: ReactiveNode): ReactiveNode | undefined {
  ++globalVersion                   // 本轮收集的"批次号",只给 link() 去重用
  sub.depsTail = undefined          // 不做任何清理!旧的依赖链表保留原样,等待被复用或替换
  sub.flags =
    (sub.flags &
      ~(ReactiveFlags.Recursed | ReactiveFlags.Dirty | ReactiveFlags.Pending)) |
    ReactiveFlags.RecursedCheck
  return setActiveSub(sub)          // 返回上一个 activeSub,供 endTracking 恢复
}

export function endTracking(
  sub: ReactiveNode,
  prevSub: ReactiveNode | undefined,
): void {
  activeSub = prevSub
  // 游标(depsTail)之后的节点都是本轮未被访问的旧依赖
  const depsTail = sub.depsTail
  let toRemove = depsTail !== undefined ? depsTail.nextDep : sub.deps
  while (toRemove !== undefined) {
    toRemove = unlink(toRemove, sub)
  }
  sub.flags &= ~ReactiveFlags.RecursedCheck
}

unlink() 还藏着一个和 GC 有关的细节:当某个 dep 的最后一个订阅者被摘掉(dep.subs 变成 undefined)、而这个 dep 自己还有依赖(说明它是 computed),unlink 会顺手把这个 computed 的所有上游 Link 也断开,并给它打上 Dirty。没人订阅的 computed 不会继续占着上游的订阅者链——这是 3.6 里 computed 能被垃圾回收的关键,也是 effectScope.stop() 为什么只需要断开 Link 就能释放 computed 的原因。

💡 最佳实践

链表游标策略的时间复杂度是 O(max(old, new)),而 Set 方案的清理 + 重建是 O(old + new)。虽然大 O 相同,但链表方案有三个实际优势:(1)复用节点时零分配;(2)无 hash 计算;(3)无 Set resize。在"依赖稳定"的常见场景中(effect 重执行时依赖集合大多不变),链表方案每次读取只剩一次指针比对(整体仍是 O(n),但常数极小、零分配),而 3.0 的 Set 方案每次都要全删重加。

6.6 computed 的双重身份

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

既是 dep 又是 sub

computed 在 Alien Signals 中拥有独特的双重身份——它既是下游 effect 的 dep(依赖源),也是上游 ref/computed 的 sub(订阅者)。它的 flags 初始值 Mutable | Dirty 把这两个身份都写在脸上:Mutable 说"我有值,propagate 时请继续往我的下游走",Dirty 说"我还没算过"。这种设计让 computed 成为了依赖图中的"中继节点":

// packages/reactivity/src/computed.ts:51(精简,rc.4)

export class ComputedRefImpl<T = any> implements ReactiveNode {
  _value: T | undefined = undefined

  // === 作为 dep:谁订阅了我 ===
  subs: Link | undefined = undefined
  subsTail: Link | undefined = undefined
  // === 作为 sub:我订阅了谁 ===
  deps: Link | undefined = undefined
  depsTail: Link | undefined = undefined

  // 注意这里的 ReactiveFlags 来自 ./system,computed.ts:10 把它别名成
  // SystemReactiveFlags,以区别于 constants.ts 里那个 `__v_*` 的字符串枚举
  flags: SystemReactiveFlags =
    SystemReactiveFlags.Mutable | SystemReactiveFlags.Dirty

  readonly __v_isRef = true
  readonly __v_isReadonly: boolean

  // 向后兼容:3.4 的 computed 持有独立的 effect,3.5 仍持有独立的 dep,现在都是它自己
  get effect(): this { return this }
  get dep(): ReactiveNode { return this }

  constructor(
    public fn: ComputedGetter<T>,
    private readonly setter: ComputedSetter<T> | undefined,
  ) {
    this[ReactiveFlags.IS_READONLY] = !setter
  }

  get value(): T {
    const flags = this.flags
    if (
      flags & SystemReactiveFlags.Dirty ||
      (flags & SystemReactiveFlags.Pending && checkDirty(this.deps!, this))
    ) {
      // 确定脏,或者核实后确实脏 → 重算
      if (this.update()) {
        // 值变了 → 我的直接订阅者从 Pending 升级为 Dirty
        const subs = this.subs
        if (subs !== undefined) shallowPropagate(subs)
      }
    } else if (flags & SystemReactiveFlags.Pending) {
      // 核实后没脏 → 清掉 Pending
      this.flags = flags & ~SystemReactiveFlags.Pending
    }
    // 作为 dep:把自己 link 到当前正在收集依赖的节点上
    if (activeSub !== undefined) {
      link(this, activeSub)
    } else if (activeEffectScope !== undefined) {
      link(this, activeEffectScope)   // 没有 activeSub 但在 scope 里 → 挂到 scope 上以便回收
    }
    return this._value!
  }

  set value(newValue) {
    if (this.setter) this.setter(newValue)
    else if (__DEV__) warn('Write operation failed: computed value is readonly')
  }

  // 作为 sub:重新收集依赖并重算,返回"值是否变了"
  update(): boolean {
    const prevSub = startTracking(this)
    try {
      const oldValue = this._value
      const newValue = this.fn(oldValue)
      if (hasChanged(oldValue, newValue)) {
        this._value = newValue
        return true
      }
      return false
    } finally {
      endTracking(this, prevSub)
    }
  }
}

注意 update() 的返回值就是整个推拉模型的"拉"信号:checkDirty() 靠它判断上游变没变,get value() 靠它决定要不要 shallowPropagate。没有 dep.version++——"值变了"这个事实不再被编码成整数,而是直接以布尔值沿调用栈返回。

globalVersion 去哪了?

如果你读过 3.5 的源码,会记得 ComputedRefImpl 有一个 globalVersion 快速路径:全局计数器不变就说明没有任何响应式数据被写过,computed 直接返回缓存、连依赖链都不遍历。3.6 的 computed.ts 里这段代码没有了。globalVersion 在 rc.4 依然存在,但角色完全不同:

// packages/reactivity/src/system.ts:56(rc.4)
let globalVersion = 0

// 同文件 :235
export function startTracking(sub: ReactiveNode): ReactiveNode | undefined {
  ++globalVersion        // ← 唯一的递增点:每次 effect / computed 开始收集依赖
  // ...
}

export function link(dep: ReactiveNode, sub: ReactiveNode): void {
  // ...
  const prevSub = dep.subsTail
  if (prevSub !== undefined && prevSub.version === globalVersion && prevSub.sub === sub) {
    return               // ← 本轮已经挂过这个 sub 了:去重
  }
  // ...
}

它不再在 trigger 时递增,也不再参与任何"值变没变"的判断——只是给 link() 提供一个"本轮收集"的批次号,防止同一个 sub 在同一轮里被重复挂到同一个 dep 上。

那快速路径去哪了?答案是:位标记本身就是快速路径。看 get value() 的第一行判断——如果 flags 里既没有 Dirty 也没有 Pending,说明从上次读取到现在没有任何上游通知过它,直接跳到 link() 返回缓存值。这个判断是一次按位与,比比较两个整数还便宜,而且是按节点局部的:3.5 的 globalVersion 只要页面上任何一个无关的 ref 被写过就会失效(3.5 里正被订阅着的 computed 另有一条"TRACKING 且非 DIRTY 就直接返回"的局部快速路径,globalVersion 主要兜底没人订阅的 computed),3.6 的 Pending 只有这个 computed 的上游真的变了才会出现。

const total = computed(() => price.value * quantity.value)
console.log(total.value)          // 首次:Dirty → update()

someUnrelatedRef.value++          // 3.5:globalVersion 变了,下一次读 total 要走依赖链核实
                                  // 3.6:total.flags 没有任何脏位,读 total 直接返回缓存

console.log(total.value)          // flags 干净 → 直接返回
console.log(total.value)          // flags 干净 → 直接返回

🔥 深度洞察

3.5 的 globalVersion 本质上是一个布隆过滤器的极简版本——用一个整数告诉你"是否有任何变化发生",没有就立即返回,有再细查。这种"先粗筛、再细查"是高性能系统的通用模式。3.6 把粗筛做得更精细:不再问"整个应用有没有变化",而是问"我的上游有没有变化"——答案就写在自己的 flags 里。代价是写入路径要多做一次 propagate 把 Pending 推下去;收益是读取路径(尤其是没人订阅、只被直接读取的 computed)的命中率从"全局无写入"提升到"上游无写入"。在真实应用里后者远比前者常见,这笔交换是划算的。

6.7 Dirty vs Pending:两级脏标记

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

为什么需要两级?

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

// Dirty:  依赖源(ref / reactive 的 Dep)直接变了,或者已被核实确实变了
// Pending:上游有节点变了,但传到我这里的那个 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 变了!
// count:      Dirty(setter 里 hasChanged 为真,自己打的)
// isPositive: Pending(ref 的 setter 只调 propagate,不调 shallowPropagate)
// message:    Pending(依赖 isPositive,但 isPositive 的值可能没变)

传播标记的规则:

订阅者与源的关系 标记 谁打的 含义
被修改的 ref / computed 本身 Dirty ref 的 setter / checkDirty 核实后 值确实变了
直接订阅了被修改的 ref Pending propagate checkDirty 会立刻问到那个带 Dirty 的 ref
直接订阅了被修改的 reactive 属性(Dep) Dirty propagate 先打 Pending,trigger() 里的 shallowPropagate 立刻升级 一定需要重算
订阅了一个 Pending / Dirty 的 computed Pending propagate 可能需要重算(取决于那个 computed 重算后值是否变化)
checkDirty 核实后上游确实变了 Pending → Dirty checkDirty 内部的 shallowPropagate 升级为确定脏
checkDirty 核实后上游没变 Pending → 清除 checkDirty 虚惊一场
graph TD
    A[ref count = 1 → 2<br>Dirty] -->|Pending| B[computed isPositive]
    B -->|Pending| C[computed message]
    C -->|Pending| D[effect render]
    B -->|"checkDirty 时 update(): true→true<br>值没变,返回 false"| B_result[isPositive = true ✓]
    B_result -.->|"清 Pending"| C_skip["message 跳过重算 ✓"]
    C_skip -.->|"清 Pending"| 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 值没变时立即短路——update() 返回 false,下游的 Pending 被清掉,什么都不重算。

实际收益的典型场景

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

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

// 当 user 被整体替换、只改了 name 时(user.value = { ...user.value, name: '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 数据的其他字段(比如 name)变化时
// filtered 的 getter 从没读过那个字段 → 根本不会被通知 → formatted 也不动 → 避免 10000 次 formatRow()
// 注意:若 filtered 真的重算,filter() 返回新数组,hasChanged 为真,formatted 仍会重算(见 6.9 的表格示例)

💡 最佳实践

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

6.8 effect 的调度与批处理

effect 和 computed 的调度机制差别很大——computed 走的是惰性路径(被读取才算),effect 必须主动执行(因为没人会来"读"它)。这就让 effect 的调度变成一个独立问题:什么时候触发 effect 的执行?同步?微任务?下一个动画帧?。本节讲两层批处理:system.ts 自己的 notifyBuffer + startBatch / endBatch(同步、只负责"把通知攒到一次写操作结束"),以及 runtime-core 的 Scheduler 把组件渲染 effect 和 watcher 统一到"微任务末尾批处理"——一次 tick 内的多次触发会被合并成一次执行、避免无效重复。

notify():effect 被通知时做什么

propagate() 并不执行 effect,它只把 effect 放进 notifyBuffer。真正的调用发生在 flush():

// packages/reactivity/src/system.ts:265(rc.4)
export function flush(): void {
  while (notifyIndex < notifyBufferLength) {
    const effect = notifyBuffer[notifyIndex]!
    notifyBuffer[notifyIndex++] = undefined
    effect.notify()
  }
  notifyIndex = 0
  notifyBufferLength = 0
}

flush() 由两处触发:endBatch() 在 batchDepth 归零时(trigger() 内部用 startBatch / endBatch 包住一次 reactive 写操作,所以同一次赋值涉及的多个 Dep 只会 flush 一次),以及 RefImpl 的 setter 在 !batchDepth 时直接调用。

notify() 做什么取决于 effect 是谁:

// 裸 effect():同步执行
notify(): void {
  if (!(this.flags & EffectFlags.PAUSED) && this.dirty) {
    this.run()
  }
}

// effect(fn, { scheduler }):覆写 notify,交给 scheduler
e.notify = () => {
  if (!(e.flags & EffectFlags.PAUSED)) scheduler()
}

// 组件渲染 effect(packages/runtime-core/src/renderer.ts 的 SetupRenderEffect):交给调度器
notify(): void {
  if (!(this.flags & EffectFlags.PAUSED)) {
    queueJob(this.job, this.job.i!.uid)
  }
}

也就是说,@vue/reactivity 层的 effect 默认是同步的;"微任务批处理"是 runtime-core 用 queueJob(flush: 'post' 的 watcher 走 queuePostRenderEffect)加上去的一层,作用对象是组件渲染 effect 和 flush: 'pre' | 'post' 的 watcher。

queueJob:延迟调度

// packages/runtime-core/src/scheduler.ts:47(精简,rc.4)

const jobs: SchedulerJob[] = []          // 按 id 有序:父组件(id 小)先更新
let jobsLength = 0
let flushIndex = 0
let currentFlushPromise: Promise<void> | null = null
const resolvedPromise = Promise.resolve()

export function queueJob(job: SchedulerJob, id?: number, isPre = false, order = 0): void {
  // 用 SchedulerJobFlags.QUEUED 位去重:同一个 job 不会被重复加入队列
  // 用二分查找按 id 找到插入位置:保证父组件先于子组件、pre-watcher 先于渲染
  if (queueJobWorker(job, /* 计算出的排序 id */, jobs, jobsLength, flushIndex)) {
    jobsLength++
    queueFlush()
  }
}

function queueFlush() {
  if (!currentFlushPromise) {
    currentFlushPromise = resolvedPromise.then(doFlushJobs)   // ← 微任务
  }
}

function flushJobs(seen?: CountMap) {
  // 精简掉:__DEV__ 下 seen 的初始化与 checkRecursiveUpdates 递归自检、
  // ALLOW_RECURSE 时清 QUEUED 位、以及外层 try/finally(scheduler.ts:281-300)
  while (flushIndex < jobsLength) {
    const job = jobs[flushIndex]
    jobs[flushIndex++] = undefined as any
    if (!(job.flags! & SchedulerJobFlags.DISPOSED)) {
      job()                              // 渲染 job 内部:if (effect.dirty) effect.run()
    }
  }
  // ...随后 flushPostFlushCbs(),并在还有新 job 时递归 flush
}

第 12 章会完整拆解调度器;这里只需要记住:rc.4 的 queueJob 用二分插入 + 位标记去重替代了 3.0 时代的 queue.includes() + 整体 sort()。这两项都不是 3.6 才有的——二分插入的 findInsertionIndex 自 3.0.7 就在(PR #3184,packages/runtime-core/src/scheduler.ts:83),SchedulerJobFlags.QUEUED 位去重是 3.5 引入的(PR #10407,commit 55660b0cf,首个 tag v3.5.0-alpha.1;packages/runtime-core/src/scheduler.ts:6)。

批处理的威力

延迟调度让多次同步修改只触发一次更新——注意这里用的是 vue 包的 watchEffect(默认 flush: 'pre',走 queueJob),而不是 @vue/reactivity 的裸 effect:

import { ref, computed, watchEffect } from 'vue'

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

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

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

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

  1. count.value = 1 → count 打 Dirty,propagate() 给 doubled 和 watcher 打 Pending,watcher 进 notifyBuffer → flush() → watcher 的 notify() 是 queueJob(job),job 进调度队列、注册微任务
  2. count.value = 2 → 同样打标记;doubled 已经是 Pending,propagate 走到它就停了,watcher 不会被重复入队;即使入队,queueJob 也会用 QUEUED 位去重
  3. count.value = 3 → 同上
  4. 微任务执行 → job 运行 → effect.dirty → checkDirty() → doubled.update() 重算一次 → 值为 6 → watcher 执行

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

如果把上面的 watchEffect 换成 @vue/reactivity 的裸 effect(),输出会是 2、4、6——每次 ref 写入结束时 flush() 就同步调用了 notify() → run()。想在 reactivity 层拿到批处理,要自己用 startBatch() / endBatch() 把多次写入包起来(trigger() 内部就是这么做的)。

6.9 性能基准与实际影响

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

官方给出的数字与没给出的数字

先把两次内核重写的官方说法分清楚,因为网上经常把它们混在一起:

版本 改动 官方发布说明中的性能表述
Vue 3.5(2024-09) 版本计数 + Link 双向链表(PR #10397) 内存占用下降 56%,大型深层响应式数组的追踪最多快 10 倍,行为不变
Vue 3.6(2025-07 起预发布,本专栏对照 rc.4) 移植 Alien Signals(PR #12349) "significantly improves the reactivity system's performance and memory usage"——没有给出具体百分比

也就是说,"-56%"这个常被引用的数字属于 3.5 的版本计数重构,不是 Alien Signals 的成绩;3.6 的收益是叠加在 3.5 之上的第二段增量。Alien Signals 自己的仓库用社区的 js-reactivity-benchmark 做对比,长期位于榜首附近,但具体毫秒数随库版本、Node 版本和机器差异很大,本专栏不在这里抄一张会过期的表——想要数字,git clone 那个 benchmark 仓库跑一遍是最可靠的做法。

从结构上看,收益有三条:(1)Dep、ref、computed、effect、scope 都实现同一个 ReactiveNode 接口,传播与判脏走同一套代码——至于能否因此提高 V8 内联缓存命中率,要实测才知道,毕竟它们是五个字段各不相同的类(packages/reactivity/src/dep.ts:16、packages/reactivity/src/ref.ts:125、packages/reactivity/src/computed.ts:51、packages/reactivity/src/effect.ts:60、packages/reactivity/src/effectScope.ts:7);(2)propagate / checkDirty 用显式栈代替递归,深层 computed 链不再受调用栈深度限制、也省掉了函数调用开销;(3)判脏不再需要为每个 Dep 和 Link 维护整数版本号,RefImpl 和 ComputedRefImpl 也不再各自持有一个 Dep 对象——每个响应式值少一个对象。

实际应用中的影响

对于日常的 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 时:
// - propagate 只沿 currentPage.subs 走:paginated 打 Pending → 渲染 effect 打 Pending
// - filtered / sorted 根本不在传播路径上,flags 干净 → 读取时直接返回缓存
// - 渲染 effect 被 notify → checkDirty → paginated.update() 重算 → 但只是 slice 操作
// 总开销:一次 slice(50, 100),而不是重新过滤 + 排序 10,000 行
//
// 再看 filterText 从 '' 变为 'a' 但恰好没过滤掉任何行的情形:
// - filtered 重算,结果是新数组(引用变了)→ hasChanged 为真 → sorted、paginated 依次重算
// - 想让"内容没变"也短路,需要 filtered 自己保证返回同一个引用——computed 的短路只看 hasChanged

🔥 深度洞察

Alien Signals 的性能优势在"依赖图复杂度高但实际变化范围小"的场景中最为显著。这恰恰是现代前端应用的典型特征——应用有大量的状态和衍生计算,但每次用户交互通常只影响其中一小部分。Alien Signals 的位标记 + 混合推拉模型确保了"只有真正受影响的部分才会被重新计算"。这不是微优化——在 computed 链又深又宽的应用里,被短路掉的重算可能占到每次交互 JavaScript 执行时间的大头。

6.10 与其他信号库的对比

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

架构对比

维度 Vue 3.6 (Alien Signals) Solid.js Preact Signals Angular Signals
依赖存储 Link 双向链表(6 指针 + version) 数组(sources / observers + 槽位索引) 双向链表(Node) 数组(producers / consumers)
脏检查 Dirty / Pending 位标记 + checkDirty 拉校验 STALE / PENDING 状态标记 版本计数 + globalVersion 版本计数 + dirty 标记
computed 模型 惰性求值(推标记 + 拉值) 惰性求值 惰性求值 惰性求值
批处理 notifyBuffer + startBatch / endBatch(同步);组件层 queueJob(微任务) 同步批处理 (batch) 同步批处理 (batch) effect 由调度器在微任务 / 变更检测时执行
与框架集成 深度集成(组件 / 模板 / SSR / Vapor) 深度集成 浅集成(preact 适配) 深度集成

Alien Signals 的独特之处

  1. 一个接口通吃:Dep、ref、computed、effect、effectScope 全是 ReactiveNode,link / unlink / propagate / checkDirty 不对节点类型做任何 instanceof 判断,靠 Mutable / Watching 两个位区分"有值的节点"和"要被通知的节点"。Preact Signals 的 Computed 和 Effect 是两套类、Solid 的 Memo 和 Effect 也各有自己的 updateComputation 分支。

  2. 非递归的传播与校验:propagate() 和 checkDirty() 都用显式 Stack 结构做深度优先遍历。Preact Signals 的 _notify 和 _refresh 是递归的,Solid 的 lookUpstream 也是递归——在几十层的 computed 链上,非递归实现省掉了函数调用开销,也不会被调用栈深度限制。

  3. Pending 悬置 + shallowPropagate 精确升级:propagate 只打 Pending,"谁是直接订阅者、肯定脏"由 shallowPropagate 单独补一刀;checkDirty 核实出某个 computed 值真的变了,也是当场 shallowPropagate 把它的其他订阅者升为 Dirty。这让"确定脏"和"可能脏"的边界始终精确到单个 Link,而不是像 3.4 的 DirtyLevels 那样按 effect 整体标级。

6.11 源码中的边界情况处理

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

自引用 computed

// 自引用 computed
const evil = computed(() => evil.value + 1)  // ❌

rc.4 没有专门的循环依赖检测代码(system.ts / computed.ts 里搜不到 cyclic 或 RUNNING),但这段代码也不会无限递归。原因藏在 startTracking 里:update() 一开始就把 Dirty / Pending 清掉、换成 RecursedCheck。于是 getter 内部再读 evil.value 时,flags 里既没有 Dirty 也没有 Pending——走的是"直接返回缓存"的路径,拿到的是上一次的 _value(首次为 undefined),然后 link(evil, evil) 把自己挂到自己身上。结果是 NaN,不是栈溢出。这是"位标记在求值开始就清掉"这个设计带来的免费防护——教学演示代码里通常要专门加一个 RUNNING 标志才能做到。

effect 嵌套

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

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

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

Alien Signals 没有显式的栈结构,而是让 startTracking 返回上一个 activeSub、由 endTracking 恢复——调用栈本身就是那个栈:

// packages/reactivity/src/system.ts:60(rc.4)
export function setActiveSub(sub?: ReactiveNode): ReactiveNode | undefined {
  try {
    return activeSub        // 返回旧值
  } finally {
    activeSub = sub         // 再设新值
  }
}

// ReactiveEffect.run() / ComputedRefImpl.update()
const prevSub = startTracking(this)   // 内部 setActiveSub(this),返回外层 sub
try {
  return this.fn()
} finally {
  endTracking(this, prevSub)          // activeSub = prevSub ← 恢复上一层
}

内层 effect 在外层 effect 执行期间被创建;如果当前有 activeEffectScope,构造函数里的 link(this, activeEffectScope) 会把它挂到那个 scope 上(本例没有 scope,就不挂);内层 run() 结束后 activeSub 回到外层,外层继续收集自己的依赖,两层的 deps 链互不干扰。

递归 trigger 防护

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

这段代码在 rc.4 下不会无限循环,也不会自动跑到 10——它只执行一次,count 停在 1。三个标志位协同完成了这个防护:

// propagate() 中与递归有关的分支(rc.4 原文节选)
if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed |
                     ReactiveFlags.Dirty | ReactiveFlags.Pending))) {
  sub.flags = flags | ReactiveFlags.Pending
  if (runDepth) sub.flags |= ReactiveFlags.Recursed      // 有 effect 正在跑 → 记一笔
} else if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed))) {
  flags = ReactiveFlags.None                              // 已经脏了,跳过
} else if (!(flags & ReactiveFlags.RecursedCheck)) {
  sub.flags = (flags & ~ReactiveFlags.Recursed) | ReactiveFlags.Pending
} else if (!(flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) &&
           isValidLink(link, sub)) {
  // sub 正在收集依赖(RecursedCheck),且触发它的正是它本轮已经读过的依赖
  // → 这是"effect 改了自己刚读过的数据":打 Recursed | Pending,但不放进 notifyBuffer
  sub.flags = flags | ReactiveFlags.Recursed | ReactiveFlags.Pending
  flags &= ReactiveFlags.Mutable
} else {
  flags = ReactiveFlags.None
}

流程是:effect 正在 run()(带 RecursedCheck),读了 count.value(建立 Link),然后 count.value++ → propagate 走到第四个分支——只打标记、不入队。run() 结束时 endTracking 清掉 RecursedCheck,接着检查:

// ReactiveEffect.run() 的 finally 段
if ((flags & (ReactiveFlags.Recursed | EffectFlags.ALLOW_RECURSE)) ===
    (ReactiveFlags.Recursed | EffectFlags.ALLOW_RECURSE)) {
  this.flags = flags & ~ReactiveFlags.Recursed
  this.notify()           // 只有显式允许递归的 effect 才会再跑一次
}

默认没有 ALLOW_RECURSE,Recursed 位会在下一次 startTracking 时被顺手清掉,什么都不发生。带回调的 watch(source, cb) 一律打开 ALLOW_RECURSE(允许在回调里修改被监听的源,issue #1727);组件渲染 effect 默认也打开(setupRenderEffect 里的 toggleRecurse(instance, true),#1801、#2043),只在执行 beforeUpdate 等前置钩子期间由 toggleRecurse 临时关闭;开发模式下调度器还有 RECURSION_LIMIT(100 次)兜底。

6.12 迁移影响与兼容性

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

对开发者的影响

Alien Signals 是一次完全向后兼容的内部重写。按设计目标,所有公开 API(ref、reactive、computed、watch、watchEffect)的行为保持不变。开发者不需要修改一行代码就能享受到性能提升。

但有几处内部形态的变化值得注意——它们影响的不是公开 API 的行为,而是那些"顺着 __v_ 前缀摸进内部"的代码:

// 1. computed 不再持有 effect / dep 对象
const c = computed(() => 1)
c.effect === c   // true:rc.4 里 get effect() 返回 this,类型上已标记 @deprecated
c.dep === c      // true:运行时成立,但 dep 不在 ComputedRef 的公开类型里(TS 下需断言)

// 2. ref 也不再有独立的 Dep
const r = ref(0)
r.dep === r      // true:RefImpl 自己就是 ReactiveNode(同样不在 Ref 的公开类型里)

// 3. 3.5 的内部字段消失:Dep.version、Dep.activeLink、Link.prevActiveLink、
//    ReactiveEffect.deps 的元素形状(现在是 system.ts 的 Link)、EffectFlags.TRACKING / NOTIFIED
//    直接读这些字段的 devtools 插件或测试代码需要适配

// 4. computed 的惰性从 3.0 起就存在,不是 3.6 新增——
//    "count 变了但没人读 doubled,getter 不执行"在 3.0 到 3.6 的任何版本都成立
const count = ref(0)
const doubled = computed(() => {
  console.log('computing...')
  return count.value * 2
})
count.value = 1     // 任何 Vue 3 版本都不会打印——直到有人读 doubled.value

💡 最佳实践

虽然 Alien Signals 完全向后兼容,但如果你的代码依赖 computed 的副作用时机(比如在 computed getter 中做日志记录或性能追踪),需要注意惰性求值会让这些副作用延迟到读取时才执行——而且 3.4 起如果上游 computed 的值没变,下游 getter 可能根本不会执行。最佳实践是 computed 的 getter 应该是纯函数——不要在其中执行副作用。如果需要副作用,请使用 watch 或 watchEffect。

对生态系统的影响

组件/库 影响 说明
Pinia 无影响 store 底层使用 reactive/ref,自动受益
VueUse 无影响 基于公开 API,不依赖内部结构
Vue Router 无影响 路由状态使用 reactive/ref
Vuetify/Element Plus 预计性能提升 复杂组件的 computed 链按机制推断会受益(尚无公开实测)
自定义 Reactivity 插件 / devtools 可能需要适配 如果读取了 3.5 的内部字段(Dep.version、activeLink、effect.deps 的旧形状)

6.13 本章小结

本章完成了对 Vue 3.6 响应式系统第三代内核——Alien Signals——的完整剖析。从旧系统问题(6.1)到新核心数据结构(6.2 Link 双向链表与 ReactiveNode)、位标记脏检查(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 从一个个人实验项目(2023 年 10 月建仓)到被合并进 Vue 核心(2024 年 12 月)只用了一年多。只要技术足够好、社区会推动它以最快速度扩散。

延伸阅读

  • Alien Signals 源码仓库:stackblitz/alien-signals(原始独立实现,核心 system.ts 只有几百行,强烈建议精读;Vue 的 system.ts 文件头注明了它对应上游的 commit)。
  • Vue 3 源码 packages/reactivity/src/system.ts(移植的核心,366 行)、dep.ts、computed.ts、effect.ts、effectScope.ts:本章讨论的 Vue 适配版 Alien Signals 的实现位置。
  • stackblitz/alien-signals 仓库的 README:作者对算法要点、与 Vue / Preact / Solid 等实现的 benchmark 对比的说明。
  • TC39 Signals 提案:JavaScript 语言标准里的 Signals 讨论,Vue、Angular、Solid 团队成员共同参与,未来可能让响应式成为 JS 原生能力。
  • Vue 官方博客 Announcing Vue 3.5(blog.vuejs.org,2024-09):官方对 3.5 响应式重构(版本计数 + 双向链表)的解读,含内存 -56% 等数据;3.6 的 Alien Signals 移植见 CHANGELOG.md 的 3.6.0-alpha.1 一节。
  • Jason Miller Preact Signals blog:Preact Signals 作者的原理介绍,和 Alien Signals 做法极其接近,对比阅读有很强启发。

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

  1. Link 双向链表(3.5 引入、3.6 保留)替代了 Set,实现了 O(1) 的依赖插入/删除和更高的内存效率。每个 Link 节点用 6 个指针同时参与 dep 的订阅者链和 sub 的依赖链;Dep、ref、computed、effect、effectScope 全部实现同一个 ReactiveNode 接口。

  2. 位标记脏检查取代了 3.5 的版本计数。ReactiveFlags 六个位(Mutable / Watching / RecursedCheck / Recursed / Dirty / Pending)描述节点状态,"值变没变"由节点自己的 update() 用 hasChanged 回答,Dep 上的整数版本号随之消失(packages/reactivity/src/dep.ts:16-36 里的 Dep 只剩 _subs / subsTail / flags);Link.version 和 globalVersion 还在,但已经只服务于 link() 的同轮去重、不再参与判脏(见 §6.6「globalVersion 去哪了?」一节,packages/reactivity/src/system.ts:16-17 的 Link.version 与 packages/reactivity/src/system.ts:235-237 的 startTracking)。

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

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

  5. 局部化的快速路径:3.5 对没人订阅的 computed 靠 globalVersion 判断"全局有没有写入",3.6 只看自己 flags 里有没有 Dirty / Pending——"上游没变"比"全局没变"命中率高得多,读取开销同样是一次按位与。

  6. 完全向后兼容——所有公开 API 的行为不变,开发者无需修改代码即可享受性能与内存的改善(3.5 的 -56% 内存是版本计数重构的成绩,3.6 在其上继续叠加,官方未给出具体百分比)。

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


思考题

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

  2. 深入思考:checkDirty() 和 propagate() 都用显式 Stack 结构代替递归。在一个 50 层嵌套的 computed 链中,如果改成递归实现,最坏情况下的调用栈深度是多少?如果每一层的 computed 都有 3 个依赖(其中 2 个是 computed),这个数字会变成多少?显式栈除了避免栈溢出,还省掉了什么开销?

  3. 工程实践:globalVersion 是一个简单的递增整数,rc.4 里它在每次 startTracking(effect / computed 开始收集依赖)时加一。在长期运行的 SPA 中,如果用户连续操作数小时不刷新页面,globalVersion 是否有溢出风险?JavaScript 的 Number.MAX_SAFE_INTEGER(2^53 - 1)能支撑多久?请计算假设每秒 1000 次 effect 重跑的情况。

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

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


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

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

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

三代并非严格"后者取代前者"——低频更新场景脏检查仍然足够,Vue 2 观察者模式在中等规模下也够用,Signal 模型的优势在"高频更新 + 大量 computed"。

延伸阅读:TC39 Signals 提案

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

Vue、Solid、Preact 的底层算法正在相互借鉴,未来有机会共享同一套响应式原语。本章讨论的链表结构、推拉混合、"确定脏 / 可能脏"两级标记这些概念,在 Solid、Preact、Angular 以及未来的原生 Signals 里都是通用的——只是各家给它们起的名字不同(Alien Signals 叫 Dirty / Pending,Solid 叫 STALE / PENDING,Preact 用版本号表达)。