Vue 3 设计与实现

第 4 章 @vue/reactivity 源码深度剖析(上):reactive / ref / track / trigger / computed

作者 杨艺韬 · 14,393 字 · 发布于 · 更新于

第 4 章 @vue/reactivity 源码深度剖析(上):reactive / ref / track / trigger / computed

本章要点

  • reactive() 的完整实现:Proxy handler 的五大拦截陷阱
  • ref() 的设计取舍:为什么基本类型需要 .value 包装
  • track() 与 trigger():依赖收集与触发更新的核心机制
  • computed() 的惰性求值:如何用 Dirty / Pending 两个标志位实现"不读不算"
  • 从 WeakMap 到 Link 链表:依赖存储结构的演进

深夜的代码审查室里,一位资深工程师正在排查一个诡异的 Bug:用户修改了购物车中某件商品的数量,价格却没有联动更新。他打开 Vue DevTools,发现 cartTotal 这个 computed 属性的依赖列表中居然没有 quantity——但它的计算函数里明明写着 item.price * item.quantity。

"依赖是什么时候被收集的?"他自言自语,然后打开了 packages/reactivity/src/reactive.ts。

两个小时后,他不仅修复了 Bug(一个在条件分支中遗漏的响应式解包),还彻底理解了 Vue 响应式系统的依赖追踪机制。他后来告诉我:"那两个小时比我看十篇博文都值。因为源码回答的不是'这个 API 怎么用',而是'这个系统怎么想'。"

本章,我们就来做同样的事——打开 @vue/reactivity 的源码,逐行解析 reactive()、ref()、track()、trigger() 和 computed() 的完整实现。

这一章在整个专栏中的角色

前面三章做了一个完整的"认知铺垫":第 1 章讲 Vue 为什么在 2026 年重新值得学;第 2 章画出了 Vue 源码的全景地图;第 3 章分析了响应式系统的设计哲学。从本章开始,我们进入代码级的深度剖析——以后所有章节都会是"一边读源码、一边理解设计"的形式。

读本章前你应该有的预期:这一章的内容密度会比前面高。你不再是在读"故事"——你是在和 Vue 作者们并肩坐下,一起读他们写的每一行关键代码。如果前三章读得像小说一样顺畅,本章开始你可能需要偶尔停下来、打开 Vue 源码对照看、在自己电脑上跑一下某个例子来验证理解。这是正常的——源码阅读本来就不是线性吸收、而是反复消化的过程。

本章会和第 3 章的"不多不少"、"推拉模型"、"精确传播"这些哲学概念反复呼应——如果第 3 章读得不扎实,你读本章时会感到"这些术语我认识但不能串起来"。遇到这种情况,不要硬扛——翻回第 3 章复习十分钟比在本章纠结一小时效率高得多。这也是前面第 1-3 章反复强调"知识之间的关系比知识本身重要"的原因。

4.1 reactive():Proxy 的五大拦截陷阱

响应式代理是 Vue 响应式系统最显眼的部分——用户的每一次数据修改、每一次状态访问,都要通过这个代理层。正是因为它在"热路径"上,它的每一个细节都必须慎重设计:性能要快、语义要准、边界要全。Proxy handler 里的每一个 trap 都承担着一部分"拦截 + 追踪/触发"的职责——本节把这五大 trap 一一拆开,你会看到 Vue 作者如何在每一个 trap 里为正确性和性能做精妙取舍。

从使用到实现

读源码时有一个非常好的习惯:先写使用代码、再找实现入口。这种"从使用反推到实现"的路径比"直接打开源码第一个文件从头读"高效得多——你的大脑在读实现时会自然地问"我写 reactive({ x: 1 }) 时,这一行代码在哪里执行",有具体的场景引导能让源码阅读变得非常有方向性。下面这段示例代码就是我们后面所有源码剖析的"驱动例子"——每一行 API 使用都对应源码里具体的位置。

reactive() 是 Vue 3 中最基础的响应式 API。它接收一个普通对象,返回一个响应式代理:

import { reactive } from 'vue'

const state = reactive({
  user: { name: 'Alice', age: 25 },
  items: [1, 2, 3]
})

state.user.name = 'Bob'  // 触发更新
state.items.push(4)       // 触发更新
delete state.user.age     // 触发更新
'name' in state.user      // 被追踪

表面看,reactive() 只是用 Proxy 包了一层。但当你打开源码时,会发现 Proxy handler 中的每一个拦截器(trap)都充满了精心设计的细节。

这正是大型开源项目最值得学的地方:表面 API 越简单,往往背后工程越深厚。reactive() 的表面 API 简单到一眼就懂——一个对象进,一个对象出,用法和普通对象一模一样。但这背后藏着:深嵌套对象的惰性代理(不要一口气全代理完、按需展开)、Ref 和 Reactive 的自动解包(用户不用关心两种原语的边界)、数组变异方法的特殊封装(避免无限循环)、集合类型的独立 handler(Map/Set 的 API 和普通对象差异太大必须单独处理)、frozen/sealed 对象的跳过(避免 Proxy 规范冲突)……每一个细节都是"用户察觉不到但框架必须处理"的隐形工作。读完本节你会对"好 API 的背后是默默做掉的几十件脏活"有非常具体的体会。

reactive() 的入口

"入口函数"是读源码最值得首先读的位置——它集中体现了模块对外的契约和内部的调度。读 reactive() 的入口你能立刻看到几件事:(1)输入参数的预处理(判断是否已是 readonly);(2)核心工厂的调用(createReactiveObject);(3)必要的补充参数(handler 和 map 缓存)。这种"入口薄、核心厚"的模式是 Vue 源码的通用风格——薄入口便于测试和扩展、厚核心集中封装复杂逻辑。下面我们就从这个薄入口开始,层层深入。

// packages/reactivity/src/reactive.ts:85(简化:合并了 :85 的重载签名与 :87 的实现;:89 是函数体里的 isReadonly 分支)

export function reactive<T extends object>(target: T): Reactive<T> {
  // 如果已经是 readonly,直接返回
  if (isReadonly(target)) {
    return target
  }
  return createReactiveObject(
    target,
    false,           // isReadonly
    mutableHandlers, // 对象的 Proxy handler
    mutableCollectionHandlers, // Map/Set 的 Proxy handler
    reactiveMap      // WeakMap 缓存
  )
}

注意两个关键细节:

  1. 两套 handler:普通对象和集合类型(Map、Set、WeakMap、WeakSet)使用不同的 Proxy handler,因为集合类型的操作方式(.get()、.set()、.add())与普通对象(.prop、obj[key])完全不同。

  2. WeakMap 缓存:同一个对象只会被代理一次。重复调用 reactive(obj) 返回同一个代理实例。

// packages/reactivity/src/reactive.ts:262(省略了 DEV 警告)

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>,
) {
  // 1. 非对象类型直接返回
  if (!isObject(target)) {
    return target
  }
  // 2. 已经是代理了,直接返回(除非要对 reactive 对象做 readonly)
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  // 3. 标记了 __v_skip(markRaw)或不可扩展(被冻结/密封)的对象不代理
  if (target[ReactiveFlags.SKIP] || !Object.isExtensible(target)) {
    return target
  }
  // 4. 检查缓存
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  // 5. 只有 Object/Array(COMMON)和 Map/Set/WeakMap/WeakSet(COLLECTION)可以被代理
  const targetType = targetTypeMap(toRawType(target))
  if (targetType === TargetType.INVALID) {
    return target
  }
  // 6. 创建代理
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
  )
  // 7. 存入缓存
  proxyMap.set(target, proxy)
  return proxy
}

🔥 深度洞察

第 3 步的 Object.isExtensible() 检查值得注意:被冻结(Object.freeze())或被密封(Object.seal())的对象不会被代理。这不是一个任意的限制——Proxy 规范要求代理的行为必须与目标对象的不变量(invariant)一致。如果目标对象的某个属性既不可配置又不可写,Proxy 的 get trap 必须返回与目标属性相同的值。冻结对象的属性正是如此,而响应式 get 会把嵌套对象换成它的代理返回——一旦这样做就违反了不变量,引擎会抛出 TypeError。Vue 选择在入口处就避免这种情况。

mutableHandlers:五大拦截陷阱

mutableHandlers 是普通对象的 Proxy handler,包含五个陷阱函数:

// packages/reactivity/src/baseHandlers.ts:49(结构示意)

class BaseReactiveHandler implements ProxyHandler<Target> {
  get(target, key, receiver) { /* 拦截属性读取 → 依赖收集 */ }
}

class MutableReactiveHandler extends BaseReactiveHandler {
  set(target, key, value, receiver) { /* 拦截属性赋值 → 触发更新 */ }
  deleteProperty(target, key) { /* 拦截 delete → 触发更新 */ }
  has(target, key) { /* 拦截 in 操作符 → 依赖收集 */ }
  ownKeys(target) { /* 拦截 Object.keys() / for...in → 依赖收集 */ }
}

export const mutableHandlers: ProxyHandler<object> =
  /*@__PURE__*/ new MutableReactiveHandler()

get 之所以单独放在基类 BaseReactiveHandler 里,是因为 readonlyHandlers 也要复用它——只读代理照样要惰性代理嵌套对象、解包 ref,只是不追踪依赖、不允许写。

Trap 1: get — 属性读取与依赖收集

// packages/reactivity/src/baseHandlers.ts:55(简化:源码里 get 是
// class BaseReactiveHandler(:49)的方法,标志位分支在 :60-66;
// :87 是同一方法体中段的 const targetIsArray = isArray(target))

function get(target: object, key: string | symbol, receiver: object) {
  // 1. 内部标志位处理
  if (key === ReactiveFlags.IS_REACTIVE) return true
  if (key === ReactiveFlags.IS_READONLY) return false
  if (key === ReactiveFlags.RAW) {
    if (receiver === reactiveMap.get(target)) {
      return target  // ← toRaw() 的实现基础
    }
  }

  const targetIsArray = isArray(target)

  // 2. 数组方法的特殊处理(见 arrayInstrumentations.ts)
  let fn: Function | undefined
  if (targetIsArray && (fn = arrayInstrumentations[key])) {
    return fn
  }

  // 3. 正常的属性读取
  const res = Reflect.get(target, key, receiver)

  // 4. Symbol 和不可追踪的 key 不收集依赖
  if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
    return res
  }

  // 5. 依赖收集 ← 核心!
  track(target, TrackOpTypes.GET, key)

  // 6. 如果值是 ref,自动解包
  if (isRef(res)) {
    return targetIsArray && isIntegerKey(key) ? res : res.value
  }

  // 7. 如果值是对象,递归代理(惰性代理)
  if (isObject(res)) {
    return reactive(res)  // ← 懒代理:只有被访问的属性才会被代理
  }

  return res
}

这个 get trap 中有几个精妙的设计值得深入讨论:

惰性代理(Lazy Proxy)

当你执行 reactive({ a: { b: { c: 1 } } }) 时,Vue 并不会递归地对所有嵌套对象创建 Proxy。只有当 state.a 被访问时,{ b: { c: 1 } } 才会被代理;只有当 state.a.b 被访问时,{ c: 1 } 才会被代理。

这是一个关键的性能优化——如果一个对象有 100 个嵌套属性,但用户只使用了其中 3 个,其余 97 个属性的 Proxy 创建开销就被完全避免了。

惰性代理这个设计还有一个你意识不到的好处:避免无限递归。想象如果 Vue 采取"一次性递归代理所有嵌套对象"的策略,当你把一个循环引用的对象(比如 obj.self = obj)传给 reactive() 时会发生什么?如果不额外记录"哪些对象已经处理过",递归代理会无限递归下去、栈溢出。但惰性代理天然避免了这个问题——只有真正被访问的属性才会代理,循环引用就算写在对象里,只要不被遍历访问、就不会触发死循环。这是"惰性"这个模式的一个附带礼物——很多时候"按需计算"不只是为了性能,更是为了在某些边界场景下让代码能正确工作。Haskell 等惰性求值语言能优雅处理无限列表(repeat 1 生成无限的 1),也是同一个原理。

自动 ref 解包

如果 reactive 对象的某个属性是一个 ref,读取时会自动解包:

const count = ref(0)
const state = reactive({ count })

console.log(state.count)  // 0(而不是 ref 对象)
// 等价于 state.count.value,但不需要写 .value

但注意第 6 步的条件判断:数组中的 ref 元素不会自动解包。这是因为数组索引操作(arr[0])在语义上不应该"穿透"包装对象——你期望 arr[0] 返回数组中实际存储的元素,而不是被悄悄解包后的值。

这是一个"看似不一致、实则贴合人类直觉"的设计决策。对象属性访问(obj.count)在 JS 中通常伴随"值语义"——用户觉得"我读 obj.count 拿到的就是 count 的值",所以自动解包 ref 很自然。数组索引访问(arr[0])在 JS 中更贴近"集合语义"——用户觉得"这是数组的第 0 个元素本身",自动解包反而违反直觉(拿到的不是存进去的东西)。Vue 团队在这个细节上体现的不是"追求 API 一致性"、而是"追求用户心智一致性"——两者有微妙但本质的区别。API 一致性是机械的对称,心智一致性是贴合不同语境的使用者期待。

Trap 2: set — 属性赋值与触发更新

// packages/reactivity/src/baseHandlers.ts:147(简化:源码里 set 是
// class MutableReactiveHandler(:142)的方法,签名折成多行,:149 是第二个形参)

function set(
  target: object,
  key: string | symbol,
  value: unknown,
  receiver: object
): boolean {
  let oldValue = target[key]
  const isArrayWithIntegerKey = isArray(target) && isIntegerKey(key)

  // 1. 深层模式下比较的是原始值;旧值是 ref 而新值不是 → 直接写进 ref 的 .value
  //    (数组整数索引除外,和 get 里"数组元素不解包"对称)
  if (!isShallow(value) && !isReadonly(value)) {
    oldValue = toRaw(oldValue)
    value = toRaw(value)
  }
  if (!isArrayWithIntegerKey && isRef(oldValue) && !isRef(value)) {
    oldValue.value = value
    return true
  }

  // 2. 判断是新增还是修改
  const hadKey = isArrayWithIntegerKey
    ? Number(key) < target.length
    : hasOwn(target, key)

  // 3. 执行真正的赋值
  const result = Reflect.set(target, key, value, receiver)

  // 4. 只有代理自身(非原型链)的操作才触发更新
  if (target === toRaw(receiver) && result) {
    if (!hadKey) {
      trigger(target, TriggerOpTypes.ADD, key, value)
    } else if (hasChanged(value, oldValue)) {
      trigger(target, TriggerOpTypes.SET, key, value, oldValue)
    }
  }

  return result
}

关键细节:

  • 区分 ADD 和 SET:新增属性和修改属性触发不同类型的更新。这对于 watch 的深度监听和数组的 length 响应非常重要。
  • hasChanged 检查:如果新旧值相同(使用 Object.is 比较),不触发更新。这避免了无意义的重渲染。
  • 原型链保护:只有对代理自身的操作才触发更新,继承链上的操作被忽略。

原型链保护这个细节很值得细品。JavaScript 的对象属性访问会沿原型链向上查找——如果 child = Object.create(parent),在 child 上设置 child.x = 1 可能触发 parent 的 setter(取决于是否有同名属性)。这种"子对象设置波及父对象"的行为在 JS 里是合法但容易搞晕人的。Vue 的 set trap 通过 target === toRaw(receiver) 检查"当前操作是否作用于我代理的这个对象本身"——如果只是原型链上的波及,就不主动触发更新。这种保护在原型链上挂着响应式对象的场景(如 Object.create(reactiveObj))里尤其重要:对子对象赋值时,只触发子对象自己的更新,不会连带把原型对象的依赖也错误触发一遍。这类边界情况的细致处理是判断一个库"生产级"还是"玩具级"的关键指标——能处理正常流程的库一抓一大把,能正确处理这些边界情况的库就少得多。

Trap 3–5: deleteProperty / has / ownKeys

// deleteProperty — delete obj.key
function deleteProperty(target: object, key: string | symbol): boolean {
  const hadKey = hasOwn(target, key)
  const oldValue = target[key]
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)  // ← 删除也能触发更新
  }
  return result
}

// has — 'key' in obj
function has(target: object, key: string | symbol): boolean {
  const result = Reflect.has(target, key)
  if (!isSymbol(key) || !builtInSymbols.has(key)) {
    track(target, TrackOpTypes.HAS, key)  // ← in 操作符也能追踪
  }
  return result
}

// ownKeys — Object.keys(obj) / for...in
function ownKeys(target: object): (string | symbol)[] {
  track(
    target,
    TrackOpTypes.ITERATE,
    isArray(target) ? 'length' : ITERATE_KEY  // ← 追踪迭代操作
  )
  return Reflect.ownKeys(target)
}

💡 最佳实践

ownKeys trap 追踪的是 ITERATE_KEY,而非具体的属性名。这意味着当你使用 Object.keys(state) 或 for...in 遍历对象时,新增或删除属性都会触发依赖更新。这就是为什么 v-for 遍历响应式对象时,新增属性能够自动触发重渲染——不需要像 Vue 2 那样使用 Vue.set()。

数组方法的特殊处理

数组的响应式处理比普通对象复杂得多。Vue 对几类数组方法做了特殊拦截:

// packages/reactivity/src/arrayInstrumentations.ts:42(简化)

export const arrayInstrumentations: Record<string | symbol, Function> = {
  // 查找方法:includes、indexOf、lastIndexOf
  includes(...args: unknown[]) { return searchProxy(this, 'includes', args) },
  indexOf(...args: unknown[]) { return searchProxy(this, 'indexOf', args) },
  lastIndexOf(...args: unknown[]) { return searchProxy(this, 'lastIndexOf', args) },

  // 变异方法:push、pop、shift、unshift、splice
  push(...args: unknown[]) { return noTracking(this, 'push', args) },
  pop() { return noTracking(this, 'pop') },
  shift() { return noTracking(this, 'shift') },
  unshift(...args: unknown[]) { return noTracking(this, 'unshift', args) },
  splice(...args: unknown[]) { return noTracking(this, 'splice', args) },

  // 此外 forEach / map / filter / find / reduce / join / concat / values 等
  // "会读到全部元素"的方法也都被包装,统一只追踪一个 ARRAY_ITERATE_KEY
}

// 身份敏感的查找方法:参数可能是 reactive 代理,用原参数找不到就换 raw 再找一次
function searchProxy(self: unknown[], method: keyof Array<any>, args: unknown[]) {
  const arr = toRaw(self) as any
  track(arr, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)  // ← 整体追踪一次,而不是逐个索引
  const res = arr[method](...args)
  if ((res === -1 || res === false) && isProxy(args[0])) {
    args[0] = toRaw(args[0])
    return arr[method](...args)
  }
  return res
}

// 会改 length 的变异方法:执行期间清空 activeSub,避免 length 被追踪(#2137)
function noTracking(self: unknown[], method: keyof Array<any>, args: unknown[] = []) {
  startBatch()
  const prevSub = setActiveSub()   // ← 暂停依赖收集!
  const res = (toRaw(self) as any)[method].apply(self, args)
  setActiveSub(prevSub)            // ← 恢复依赖收集
  endBatch()
  return res
}

🔥 深度洞察

为什么 push 等变异方法需要暂停依赖收集?考虑这个场景:

const arr = reactive([])
effect(() => arr.push(1))  // effect 1
effect(() => arr.push(2))  // effect 2

arr.push(1) 内部会读取 arr.length(确定插入位置),然后设置 arr[0] = 1 和 arr.length = 1。如果不暂停追踪,effect 1 会收集到对 length 的依赖。当 effect 2 执行 push(2) 修改 length 时,effect 1 被触发重新执行,再次 push,导致无限循环。noTracking 优雅地解决了这个问题——setActiveSub() 把 activeSub 清空,在变异方法执行期间不收集任何新的依赖(对外暴露的 pauseTracking() / resetTracking() 做的是同一件事)。外面再套一层 startBatch() / endBatch(),是让 push、splice 等方法内部连续触发的多次 trigger(逐个写索引、改 length)合并成一轮 flush。

4.2 ref():为什么基本类型需要 .value

reactive 能代理对象,但代理不了 number、string、boolean 这些基本类型——这是 JavaScript Proxy 的硬约束。Vue 给出的答案是 ref:用对象包装一个值,在对象上挂 getter/setter 做响应式追踪。这个看似朴素的"一层包装"解决方案背后,藏着 Vue 响应式系统里最反复被讨论的一个设计决策——.value 访问的代价究竟值不值。本节我们不只看实现,还要讨论这个取舍的设计原理。

ref 的存在理由

如果让我用一句话概括 ref 的存在理由,那就是:"Proxy 不能拦截基本类型的读写,所以必须包一层对象"。这听起来像是"没办法的办法",但它同时也开辟了 ref 独有的能力:可以整体替换(ref.value = wholeNew)、可以在解构后保持响应式(因为 ref 本身是对象,不会像 reactive 解构那样把值剥离出来)、可以统一承载基本类型和对象。有时候"约束"反而会带来意外的表达力提升——ref 就是这样一个例子。

JavaScript 的 Proxy 只能代理对象,不能代理基本类型(number、string、boolean)。但我们经常需要让基本类型也具有响应式:

// 这不行 — Proxy 无法代理 number
const count = reactive(0)  // ❌ 返回 0,不是代理

// 这可以 — ref 用对象包装基本类型
const count = ref(0)       // ✅ 返回 { value: 0 }

ref 的解决方案是:用一个对象包装值,通过 getter/setter 拦截 .value 的读写。

.value 这个 API 是 Vue 3 引入 Composition API 时最被吐槽的一个设计——"为什么我写一个计数器要每次敲 .value?"、"Solid 用 count()、Svelte 直接用 count,Vue 凭什么要 .value?"。这些吐槽不无道理,但也误解了 Vue 面临的约束:Vue 必须是一个纯 JavaScript 库、不依赖编译器也能工作(第 3 章讲 Svelte 时提过这个 trade-off)。在这个约束下,用对象包装基本类型、用 .value 访问是唯一不违反语言规则的方案——你无法让一个 number 自己变成响应式,JavaScript 语言不支持;你也无法让 count 这个标识符在某些上下文触发追踪、其他上下文不触发(除非编译器介入)。.value 是 Vue 对"生态包容 × 响应式表达力"这两个约束的最优解——不完美,但已经是能拿到的最好方案。

ref 的完整实现

读下面的 RefImpl 类的时候,注意它和 Alien Signals 引擎(system.ts)之间的协作——RefImpl 自己就是依赖图上的一个节点(实现了 ReactiveNode 接口:subs 订阅者链表 + flags 标志位),读取时调 link() 把当前 effect 链上来、写入时调 propagate() 沿链表打标志。这种"ref 作为壳、system.ts 作为引擎"的分层让 ref 的实现非常清爽——业务逻辑(值的存取、toRaw/toReactive 转换)和依赖追踪(链表和标志位)被干净地隔离开来。你在自己的项目里写类似"带响应式能力的包装对象"时可以借鉴这个模式:外壳类负责业务语义、内部依赖引擎负责响应式机制。

// packages/reactivity/src/ref.ts:125(省略了 DEV 调试事件和向后兼容的 get dep();
// 末尾的 ref() / createRef() 在源码里位于类定义之前,:75 / :115)
// 注意:这个文件同时用到两个 ReactiveFlags——constants.ts 里的字符串标记
// (IS_SHALLOW = '__v_isShallow' 等)和 system.ts 里的位标志(导入时别名 _ReactiveFlags)

class RefImpl<T = any> implements ReactiveNode {
  // 作为依赖节点的字段:订阅者链表头 / 尾 + 标志位
  subs: Link | undefined = undefined
  subsTail: Link | undefined = undefined
  flags: _ReactiveFlags = _ReactiveFlags.Mutable

  _value: T
  _wrap?: <T>(v: T) => T   // ref() 传入 toReactive;shallowRef() 不传
  private _oldValue: T
  private _rawValue: T

  readonly __v_isRef = true
  readonly __v_isShallow: boolean = false

  constructor(value: T, wrap: (<T>(v: T) => T) | undefined) {
    this._oldValue = this._rawValue = wrap ? toRaw(value) : value
    this._value = wrap ? wrap(value) : value
    this._wrap = wrap
    this[ReactiveFlags.IS_SHALLOW] = !wrap
  }

  get value(): T {
    trackRef(this)   // 依赖收集:activeSub 存在时 link(this, activeSub)
    if (this.flags & _ReactiveFlags.Dirty && this.update()) {
      const subs = this.subs
      if (subs !== undefined) {
        shallowPropagate(subs)
      }
    }
    return this._value
  }

  set value(newValue) {
    const oldValue = this._rawValue
    // 是否使用原始值比较
    const useDirectValue =
      this[ReactiveFlags.IS_SHALLOW] ||
      isShallow(newValue) ||
      isReadonly(newValue)
    newValue = useDirectValue ? newValue : toRaw(newValue)
    if (hasChanged(newValue, oldValue)) {
      this.flags |= _ReactiveFlags.Dirty
      this._rawValue = newValue
      this._value =
        !useDirectValue && this._wrap ? this._wrap(newValue) : newValue
      const subs = this.subs
      if (subs !== undefined) {
        propagate(subs)            // 触发更新:沿订阅者链表打标志
        if (!batchDepth) {
          flush()                  // 不在批处理中就立刻冲刷 effect 通知队列
        }
      }
    }
  }

  // 被下游的 checkDirty() 回问"你真的变了吗"时调用
  update(): boolean {
    this.flags &= ~_ReactiveFlags.Dirty
    return hasChanged(this._oldValue, (this._oldValue = this._rawValue))
  }
}

export function ref(value?: unknown) {
  return createRef(value, toReactive)
}

function createRef(rawValue: unknown, wrap?: <T>(v: T) => T) {
  if (isRef(rawValue)) {
    return rawValue  // ← 已经是 ref,直接返回
  }
  return new RefImpl(rawValue, wrap)
}

几个关键设计决策:

1. _rawValue 与 _value 的分离

_rawValue 存储未经处理的原始值(用于比较是否发生变化),_value 存储用户实际读取到的值。当值是对象时,_value 是 reactive(value)——这意味着 ref({ name: 'Vue' }).value 返回的是一个 reactive 代理。

const obj = { name: 'Vue' }
const r = ref(obj)

r.value === obj           // false — r.value 是 reactive 代理
r.value.name              // 'Vue' — 可以直接访问属性
isReactive(r.value)       // true — 嵌套对象自动变为 reactive

2. hasChanged 防止无效更新

// packages/shared/src/general.ts:169
export const hasChanged = (value: any, oldValue: any): boolean =>
  !Object.is(value, oldValue)

Object.is 比 === 更精确——它能正确处理 NaN === NaN(返回 true)和 +0 === -0(返回 false)这两个 === 的边界情况。

选 Object.is 而不是 === 这类细节决定是 Vue 作为"工业级框架"的标志之一。NaN 比较这个坑在前端项目里的具体表现很隐蔽:假设一个表单的数字字段因为用户输入了非法值变成了 NaN,旧值也是 NaN,如果用 === 判断"没变"——实际上每次都会触发 change,下游 effect 无脑反复执行,可能触发死循环。用 Object.is 就能识别"NaN 和 NaN 是相同的",避免这类伪更新。这是一个不写就永远想不到、写了 bug 消失的那种细节。类似的还有 +0 vs -0——大多数场景下无关紧要,但在数值计算密集的场景(图形、动画、物理模拟)可能出现 bug。Vue 把这两种情况都覆盖了,不需要用户自己操心。

3. ReactiveNode——Alien Signals 的统一节点接口

在 Vue 3.6 中,Dep 不再是一个 Set<ReactiveEffect>;ref 内部也没有一个独立的 dep 对象——RefImpl、ComputedRefImpl、ReactiveEffect、EffectScope 以及 reactive() 属性对应的 Dep,全部实现同一个接口 ReactiveNode,由 system.ts 用同一套 link / propagate / checkDirty 处理:

// packages/reactivity/src/system.ts:8

export interface ReactiveNode {
  deps?: Link       // 依赖链表头(只有 computed / effect 这种"会读别人"的节点才有)
  depsTail?: Link
  subs?: Link       // 订阅者链表头
  subsTail?: Link
  flags: ReactiveFlags
}

export const enum ReactiveFlags {
  None = 0,
  Mutable = 1 << 0,        // 会产出值的节点:ref / computed
  Watching = 1 << 1,       // 会执行副作用的节点:effect
  RecursedCheck = 1 << 2,  // 正在追踪依赖(startTracking ~ endTracking 之间)
  Recursed = 1 << 3,       // 在自己追踪依赖期间被再次触发,或被标记时有 effect 正在 run(runDepth 非零)
  Dirty = 1 << 4,          // 确定脏了
  Pending = 1 << 5,        // 上游可能脏了,读取时需要 checkDirty 校验
}

reactive() 对象的每个属性对应的 Dep,是这个接口最轻量的实现——它只有订阅者链表,没有依赖链表(一个属性不会"读"别人):

// packages/reactivity/src/dep.ts:16

class Dep implements ReactiveNode {
  _subs: Link | undefined = undefined
  subsTail: Link | undefined = undefined
  flags: ReactiveFlags = ReactiveFlags.None

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

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

  set subs(value: Link | undefined) {
    this._subs = value
    if (value === undefined) {
      this.map.delete(this.key)   // ← 最后一个订阅者断开时,从 depsMap 里把自己删掉,防内存泄漏
    }
  }
}

(RefImpl 和 ComputedRefImpl 上还留着一个 get dep() { return this }——这是给老版本生态兼容用的,rc.4 里它就返回节点自身。)

ref vs reactive 的选择

维度 ref reactive
适用类型 任何类型(基本类型 + 对象) 仅对象
访问方式 .value 直接属性访问
解构 本身就是独立变量,可直接传递(解构 .value 会得到普通值) 解构后丢失响应式(需先用 toRefs 转成一组 ref)
模板中 自动解包(不需要 .value) 直接使用
替换整个值 ✅(ref.value = newObj) ❌(state = newObj 不触发更新)
底层实现 getter/setter(class property) Proxy

🔥 深度洞察

Vue 社区常见的争论——"该用 ref 还是 reactive"——其实有一个简单的指导原则:ref 更安全。 reactive 有两个容易踩的坑:(1)解构丢失响应性:const { name } = reactive({ name: 'Vue' }) 中 name 只是一个普通字符串;(2)整体替换无效:state = reactive({ name: 'React' }) 只是让局部变量 state 指向一个新对象,原来的响应式代理没有任何变化。ref 通过 .value 的间接访问,天然避免了这两个问题。Vue 核心团队(包括尤雨溪本人)也推荐默认使用 ref。

"默认用 ref"这条推荐值得再多说一句——它不只是"避免踩坑"那么简单,还涉及到心智一致性。Vue 3 里状态有多种原语:ref、reactive、shallowRef、shallowReactive、readonly、computed、toRefs ……如果团队里每个人按自己喜好混用,同一个项目的代码风格会是"有人 ref 有人 reactive 还有人混用",新同事阅读时要反复切换心智模式。统一"默认 ref"的团队规约让所有状态都有同样的访问模式(.value)、同样的解构行为(安全的 toRefs)、同样的可替换性(ref.value = newObj)——认知负担立刻降一半。好的团队约定的价值不在于它选了哪种方案,而在于它"统一"了方案——统一本身就是价值。

4.3 track():依赖收集的完整流程

依赖收集是响应式系统的左半部分——当响应式数据被读取时,系统需要记录"谁在读取"。

"依赖收集"这个词其实有点误导——它听起来像是系统主动去搜集"谁依赖我"的过程,实际上依赖收集是被动的:读取的时候顺手把当前运行的 effect 记下来。整个流程只需要两个前提:(a)当前有一个"正在执行的 effect"(activeSub);(b)当前的属性读取被某个 getter 拦截触发了 track。这两个前提同时满足时,track 就会建立"被读属性 → 正在执行的 effect"这条依赖。整个过程零"搜索"、零"遍历"——是 getter 主动记录,而不是系统主动扫描。这种"访问即订阅"的模型是细粒度响应式系统的核心精髓,也是它比"每次全量 diff"模型快的根本原因。

Vue 3.6 中的 track 实现

先请你对比着看下面的 track 实现,然后我们再逐行拆解。这段代码去掉 DEV 分支只有十几行、读起来也很好懂。你如果读过 3.0 的源码会注意到差别:那时的 track 要自己 new Set()、自己把 dep 反向 push 进 activeEffect.deps;到了 3.6,这些簿记全部交给了 system.ts 的 link(),track 只剩"找到 Dep、调 link()"两步。好的分层让每一层的代码都更短——复杂度没有消失,而是集中到了 Alien Signals 引擎里。

在 Alien Signals 架构下,track 的实现已经大幅简化:

// packages/reactivity/src/dep.ts:66

// activeSub 定义在 system.ts:当前正在执行的 effect / computed(ReactiveNode)
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)))
    }
    if (__DEV__) {
      onTrack(activeSub!, { target, type, key })
    }
    // 将当前 subscriber 链接到这个 dep
    link(dep, activeSub!)
  }
}

依赖存储的数据结构是一个两级 Map:

targetMap: WeakMap<target, Map<key, Dep>>

例如:
targetMap = WeakMap {
  { name: 'Vue', version: 3.6 } => Map {
    'name'    => Dep { flags: 0, _subs: Link → ... },
    'version' => Dep { flags: 0, _subs: Link → ... }
  }
}

Link 节点是 Alien Signals 的原子数据结构——Vue 3.6 的整个依赖图都建立在它之上。理解 Link 不仅有助于读懂响应式源码,还有助于你思考"如何设计高效的图结构"这类更通用的数据结构问题。Link 同时属于两个链表的设计(一个 Link 既是"Dep 的订阅者列表"的节点、又是"订阅者的依赖列表"的节点)——这种"一个节点参与多个链表"的模式在 Linux 内核里大量使用(任务调度器、文件系统 inode、网络 buffer),Vue 把它搬到了 JavaScript 层面。

在 Vue 3.6 中,Dep 和订阅者(effect/computed,都实现 ReactiveNode)之间的关系通过 Link 节点维护,形成双向链表:

// packages/reactivity/src/system.ts:16

export interface Link {
  // 建链时的 globalVersion 快照——只用于 link() 去重,不参与脏检查
  version: number
  dep: ReactiveNode | Computed | Effect | EffectScope   // 指向依赖源
  sub: ReactiveNode | Computed | Effect | EffectScope   // 指向订阅者

  // dep 维度的链表(同一个 dep 的所有 subscriber)
  prevSub: Link | undefined
  nextSub: Link | undefined

  // sub 维度的链表(同一个 subscriber 的所有 dep)
  prevDep: Link | undefined
  nextDep: Link | undefined
}

这个双向链表结构可以用下图理解:

graph LR
    subgraph "Dep A(price)"
        A_subs["_subs"]
    end
    subgraph "Dep B(quantity)"
        B_subs["_subs"]
    end
    subgraph "Effect E(render)"
        E_deps["deps"]
    end
    subgraph "Computed C(total)"
        C_deps["deps"]
        C_subs["subs"]
    end
    A_subs -->|"subs"| L1["Link 1<br/>dep:A, sub:C"]
    C_deps -->|"deps"| L1
    L1 -->|"nextDep"| L2["Link 2<br/>dep:B, sub:C"]
    B_subs -->|"subs"| L2
    C_subs -->|"subs"| L3["Link 3<br/>dep:C, sub:E"]
    E_deps -->|"deps"| L3
    style L1 fill:#ffd93d,stroke:#333
    style L2 fill:#ffd93d,stroke:#333
    style L3 fill:#ffd93d,stroke:#333

每个 Link 节点同时属于两个链表:

  • 沿 dep 维度(prevSub/nextSub):链接同一个 Dep 的所有订阅者
  • 沿 sub 维度(prevDep/nextDep):链接同一个订阅者的所有 Dep

🔥 深度洞察

双向链表的设计看似比 Set 更复杂,但它解决了 Set-based 方案的根本性能问题——cleanup 开销。在 Vue 3.0 / 3.1 中,每次 effect 重新执行时,它必须从所有 Dep 的 Set 中删除自己(n 次 Set.delete),然后重新收集依赖(n 次 Set.add)。在 Link 链表中,依赖没变的那部分只是沿链表逐个比对、原地复用——每条依赖一次 O(1) 的指针比较,取代了"一删一加"两次 Set 操作。更重要的是,Link 节点可以被复用(不需要创建新对象),大幅减轻了 GC 压力。

// packages/reactivity/src/system.ts:78

export function link(dep: ReactiveNode, sub: ReactiveNode): void {
  // 去重 1:sub 上一次刚刚链过的就是这个 dep(同一次运行里连续读同一属性)
  const prevDep = sub.depsTail
  if (prevDep !== undefined && prevDep.dep === dep) {
    return
  }
  // 去重 2:复用上一轮运行留下的旧 Link——依赖顺序没变时,整条链表原地复用,零分配
  const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps
  if (nextDep !== undefined && nextDep.dep === dep) {
    nextDep.version = globalVersion
    sub.depsTail = nextDep
    return
  }
  // 去重 3:dep 这一侧最后一个订阅者就是本轮(同一 globalVersion)的 sub
  const prevSub = dep.subsTail
  if (
    prevSub !== undefined &&
    prevSub.version === globalVersion &&
    prevSub.sub === sub
  ) {
    return
  }
  // 三道去重都没命中,才真正创建新的 Link 节点
  const newLink =
    (sub.depsTail =
    dep.subsTail =
      {
        version: globalVersion,
        dep,
        sub,
        prevDep,
        nextDep,
        prevSub,
        nextSub: undefined,
      })
  // 链入 sub 的 dep 链表
  if (nextDep !== undefined) {
    nextDep.prevDep = newLink
  }
  if (prevDep !== undefined) {
    prevDep.nextDep = newLink
  } else {
    sub.deps = newLink
  }
  // 链入 dep 的 sub 链表
  if (prevSub !== undefined) {
    prevSub.nextSub = newLink
  } else {
    dep.subs = newLink
  }
}

注意 globalVersion 在 rc.4 里的角色:它只在 startTracking()(每次 effect / computed 开始运行)时 ++,作用是给"这一轮运行"打一个戳,让 link() 能判断某条 Link 是不是本轮刚建的——它不参与脏检查,这一点和 3.5 的版本计数模型完全不同。运行结束时 endTracking() 会把 depsTail 之后没被复用到的旧 Link 逐个 unlink()——这就是 3.6 的"cleanup":只清没用到的,不像 3.0 / 3.1 那样全删再全加。

4.4 trigger():触发更新的完整流程

当响应式数据被修改时,trigger 函数启动更新传播:

trigger 是 track 的对偶——track 记录依赖、trigger 使用依赖;track 被 getter 调用、trigger 被 setter / deleteProperty 调用。两者在结构上严格对称,但 trigger 要处理比 track 多一层复杂度:不同的操作类型需要触发不同的依赖集合。比如 ADD 操作(新增属性)不仅要触发这个具体 key 的依赖,还要触发迭代依赖(ITERATE_KEY)——因为之前写过 Object.keys(state) 的 effect 需要重新运行。这种"一个操作触发多个相关依赖"的逻辑在 trigger 里占了大半代码。仔细读下面的代码、对照 ADD / SET / DELETE / CLEAR 这四类不同的触发逻辑,你会对"响应式系统为什么这么复杂"有一个立体的理解——不是因为它做得多,而是因为 JS 对象操作本身就有太多种语义,每一种都要单独处理。

// packages/reactivity/src/dep.ts:95(省略了 DEV 调试事件)

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>,
): void {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    return  // 这个对象从未被追踪
  }

  // 对一个 Dep 做"通知":先 propagate 打 Pending 标志并压入 effect 通知队列,
  // 再 shallowPropagate 把直接订阅者从 Pending 升级成 Dirty
  const run = (dep: ReactiveNode | undefined) => {
    if (dep !== undefined && dep.subs !== undefined) {
      propagate(dep.subs)
      shallowPropagate(dep.subs)
    }
  }

  startBatch()   // 下面可能通知多个 Dep,effect 统一在 endBatch() 时 flush

  if (type === TriggerOpTypes.CLEAR) {
    // Map/Set.clear() — 触发这个对象的所有依赖
    depsMap.forEach(run)
  } else {
    const targetIsArray = isArray(target)
    const isArrayIndex = targetIsArray && isIntegerKey(key)

    if (targetIsArray && key === 'length') {
      // 数组 length 变化 — 触发 length、ARRAY_ITERATE_KEY 和被截掉的索引
      const newLength = Number(newValue)
      depsMap.forEach((dep, key) => {
        if (
          key === 'length' ||
          key === ARRAY_ITERATE_KEY ||
          (!isSymbol(key) && key >= newLength)
        ) {
          run(dep)
        }
      })
    } else {
      // SET | ADD | DELETE:先触发这个 key 自己的 Dep
      if (key !== void 0 || depsMap.has(void 0)) {
        run(depsMap.get(key))
      }

      // 任何数组索引的变化都要通知 ARRAY_ITERATE_KEY(forEach/map/includes 等)
      if (isArrayIndex) {
        run(depsMap.get(ARRAY_ITERATE_KEY))
      }

      // ADD / DELETE / Map.SET 还需要触发迭代相关的依赖
      switch (type) {
        case TriggerOpTypes.ADD:
          if (!targetIsArray) {
            run(depsMap.get(ITERATE_KEY))
            if (isMap(target)) {
              run(depsMap.get(MAP_KEY_ITERATE_KEY))   // map.keys() 的依赖
            }
          } else if (isArrayIndex) {
            // 数组新增索引 → length 变了
            run(depsMap.get('length'))
          }
          break
        case TriggerOpTypes.DELETE:
          if (!targetIsArray) {
            run(depsMap.get(ITERATE_KEY))
            if (isMap(target)) {
              run(depsMap.get(MAP_KEY_ITERATE_KEY))
            }
          }
          break
        case TriggerOpTypes.SET:
          if (isMap(target)) {
            run(depsMap.get(ITERATE_KEY))   // Map 改值也影响 entries/forEach
          }
          break
      }
    }
  }

  endBatch()   // batchDepth 归零且通知队列非空 → flush(),effect 在这里才真正执行
}

触发更新的类型矩阵

下面这张表我建议你打印出来贴在显示器边——它是"Vue 响应式触发逻辑"的一张快速查阅表。当你排查"为什么这个操作没触发更新"或"为什么这个操作触发了多次更新"时,这张表比任何文档都快——它直接告诉你每种 JS 操作会触发哪些 Dep。理解了这张表,你能快速推理出任何响应式行为的因果链——再也不会因为"感觉 Vue 的更新时机有些魔幻"而困惑。

操作 触发的 Dep 示例
obj.key = val(已存在) key 对应的 Dep state.name = 'React'
obj.key = val(新属性) key 的 Dep + ITERATE_KEY 的 Dep state.newProp = 1
delete obj.key key 的 Dep + ITERATE_KEY 的 Dep delete state.name
arr[i] = val(已存在) i 的 Dep + ARRAY_ITERATE_KEY 的 Dep arr[0] = 'x'
arr[i] = val(越界) i 的 Dep + ARRAY_ITERATE_KEY 的 Dep + length 的 Dep arr[10] = 'x'(length ≤ 10)
arr.length = n length 的 Dep + ARRAY_ITERATE_KEY 的 Dep + 所有 ≥ n 的索引 Dep arr.length = 0(清空数组)
map.set(k, v) k 的 Dep + ITERATE_KEY 的 Dep(新 key 还会触发 MAP_KEY_ITERATE_KEY) map.set('a', 1)
set.clear() 所有 Dep set.clear()

propagate()——Alien Signals 的传播算法

propagate 是 trigger 的"末端"——它真正把变化通知到每一个订阅者。但这个"通知"不是简单的"遍历 + 执行":它要对 computed 和 effect 做差异化处理(第 3 章讨论的混合推拉模型在这里落地)、要正确处理 computed 的下游级联通知、要避免重复激活同一个订阅者。几十行代码承载了大量语义——读的时候别一扫而过、每一行都值得理解为什么这么写。

上面 trigger() 里的 run() 调用 propagate() 沿链表通知所有订阅者(RefImpl 的 setter 也直接调它):

// packages/reactivity/src/system.ts:162(精简:折叠了 RecursedCheck / Recursed 的递归保护分支)

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

    if (flags & (ReactiveFlags.Mutable | ReactiveFlags.Watching)) {
      if (
        !(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed |
                   ReactiveFlags.Dirty | ReactiveFlags.Pending))
      ) {
        // 第一次被通知到:只打一个 Pending 标志,不重算任何东西
        sub.flags = flags | ReactiveFlags.Pending
      } else if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed))) {
        // 已经是 Dirty / Pending:这一轮不必再往它下游走
        flags = ReactiveFlags.None
      } else {
        // …正在运行中的节点被再次触发:递归保护分支(略,见源码)
      }

      if (flags & ReactiveFlags.Watching) {
        // 订阅者是 effect:放进通知队列,等 flush() 时再决定要不要执行
        notifyBuffer[notifyBufferLength++] = sub as Effect
      }

      if (flags & ReactiveFlags.Mutable) {
        // 订阅者是 computed:继续沿它自己的订阅者链表往下推(显式栈代替递归)
        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)
}

// endBatch() 时 batchDepth 归零 → flush():把通知队列里的 effect 逐个唤醒
export function flush(): void {
  while (notifyIndex < notifyBufferLength) {
    const effect = notifyBuffer[notifyIndex]!
    notifyBuffer[notifyIndex++] = undefined
    effect.notify()   // ReactiveEffect.notify():未 PAUSED 且 dirty 为真时才 run()
  }
  notifyIndex = 0
  notifyBufferLength = 0
}

🔥 深度洞察

propagate 对 computed(Mutable)和 effect(Watching)的不同处理方式,正是 Alien Signals "混合推拉模型"的具体体现。对于 computed,只做标记(Pending;trigger() 随后用 shallowPropagate() 把直接订阅者升级成 Dirty),不做实际计算——这是"拉"的部分,等待消费者主动读取时再由 checkDirty() 校验。对于 effect,压进 notifyBuffer,批处理结束 flush() 时调用 effect.notify()——这是"推"的部分,因为 effect 没有消费者来"拉"它,必须主动推送执行;不过 notify() 里还有最后一道闸:dirty getter 会先看 Dirty 标志、再对 Pending 走一遍 checkDirty(),上游 computed 的值没变就不 run()。这种差异化处理带来的核心收益是:上游变了、但 computed 重算后值没变,变化就在这一层被拦下,下游 effect 不会白跑。("computed 没人读就不重算"的惰性从 3.0 起就有,并不是 Alien Signals 带来的,见第 6 章。)

回到第 3 章讨论过的"推 vs 拉"对比——现在你终于在具体实现里看到了两者是怎么组合的。它不是一个简单的 if/else,而是节点标志位决定传播策略:遇到 Mutable(computed)下游,就"标记 Pending、继续下推、等读取";遇到 Watching(effect)下游,就"进队列、批处理结束就执行"。这种"按下游类型分支处理"的模型让 Vue 响应式既能享受惰性求值的性能优势(对 computed)、又能保证副作用的及时执行(对 effect)——鱼与熊掌兼得的工程智慧。

4.5 computed():惰性求值的精妙实现

computed 是我个人最喜欢的 Vue 响应式 API——它小巧、优雅、威力巨大。表面上它只是"缓存计算结果",本质上它是响应式图里的灵魂:连接 signal(源)和 effect(汇)的中间节点、用惰性求值过滤无意义变化、用 Dirty / Pending 两个标志位决定要不要脏检查。一个中型应用可能有几百个 computed,每一个都是响应式图上的一道"过滤闸门"。理解了 computed,你就理解了响应式系统的"精确"到底怎么实现的——4.5 节是本章最重要的部分。

computed 的核心特性

computed 的几个核心特性值得在读源码前先记住,它们是后面源码剖析的导航标:惰性求值(被读取时才计算)、自动追踪(getter 函数里读到的每个响应式都成为依赖)、缓存(依赖不变就不重算)、可作为依赖(其他 computed 或 effect 可以依赖它)。这四个特性看似独立、其实环环相扣——惰性求值意味着要能检测"依赖是否变了",自动追踪意味着要在 getter 执行时建立依赖图,缓存意味着要有一个"已经计算过的值"状态管理,可作为依赖意味着要有自己的订阅者列表。读下面源码时可以随时回头对照这四个特性在代码里对应哪些字段。

computed 是响应式系统中最精巧的部分。它同时是一个消费者(依赖其他信号/计算)和一个生产者(被 effect 或其他 computed 依赖)。它的核心行为是惰性求值——只在被读取时才检查是否需要重新计算。

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

// 此时 total 还没有被计算——getter 函数没有执行
console.log(total.value)  // 30 — 此时才首次执行 getter

price.value = 20          // total 只被标记为 Pending(可能脏),不重算
console.log(total.value)  // 60 — 此时才重新计算

computed 的完整实现

// packages/reactivity/src/computed.ts:51(省略了 DEV 调试事件与向后兼容的 getter)
// 这里的 SystemReactiveFlags 就是 system.ts 的 ReactiveFlags 位标志(导入别名)

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

  // 既是生产者(subs:谁在读我)也是消费者(deps:我在读谁)
  subs: Link | undefined = undefined
  subsTail: Link | undefined = undefined
  deps: Link | undefined = undefined
  depsTail: Link | undefined = undefined
  flags: SystemReactiveFlags =
    SystemReactiveFlags.Mutable | SystemReactiveFlags.Dirty   // ← 一出生就是脏的:首次读取必算

  readonly __v_isRef = true
  readonly __v_isReadonly: boolean

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

  get value(): T {
    const flags = this.flags
    // 1. Dirty → 直接重算;Pending → 先 checkDirty 向上校验,确认脏了才重算
    if (
      flags & SystemReactiveFlags.Dirty ||
      (flags & SystemReactiveFlags.Pending && checkDirty(this.deps!, this))
    ) {
      // 2. update() 返回 true 表示值真的变了 → 把直接订阅者从 Pending 升级成 Dirty
      if (this.update()) {
        const subs = this.subs
        if (subs !== undefined) {
          shallowPropagate(subs)
        }
      }
    } else if (flags & SystemReactiveFlags.Pending) {
      // 3. 校验下来没脏:清掉 Pending,值照旧
      this.flags = flags & ~SystemReactiveFlags.Pending
    }
    // 4. 依赖收集(让消费者追踪这个 computed)
    if (activeSub !== undefined) {
      link(this, activeSub)
    } else if (activeEffectScope !== undefined) {
      link(this, activeEffectScope)
    }
    return this._value!
  }

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

  update(): boolean {
    // startTracking:globalVersion++、清掉 Dirty/Pending、打上 RecursedCheck,
    // 并把自己设为 activeSub(返回之前的 activeSub 以便恢复)
    const prevSub = startTracking(this)
    try {
      const oldValue = this._value
      const newValue = this.fn(oldValue)   // ← 执行 getter(getter 能拿到上一次的值)
      if (hasChanged(oldValue, newValue)) {
        this._value = newValue
        return true                        // ← 值变了
      }
      return false                         // ← 值没变:下游不必重算
    } finally {
      // endTracking:恢复 activeSub,把本轮没复用到的旧 Link 全部 unlink
      endTracking(this, prevSub)
    }
  }
}

checkDirty:渐进式脏检查

checkDirty 是本章(乃至本专栏)我最想让你仔细读的一段源码。它七十行左右,但它是 Alien Signals 最核心的算法——整个"惰性求值 + 变化过滤"模型的灵魂就在这里。理解了它,你就理解了 Vue 3.6 响应式系统相对 Vue 3.4 的本质升级(3.5 的版本计数模型里与之对应的是 isDirty)。读下面这段代码时请不要急——每一行都值得慢慢品。

checkDirty 是 Alien Signals 最精巧的算法。它实现了渐进式脏检查——不是简单地回答"你脏了吗",而是沿着依赖链逐层检查"你的哪个依赖变了":

// packages/reactivity/src/system.ts:275
// link 是 sub 的依赖链表头;返回 true 表示 sub 确实脏了、需要重算

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) {
      dirty = true
    } else if (
      (depFlags & (ReactiveFlags.Mutable | ReactiveFlags.Dirty)) ===
      (ReactiveFlags.Mutable | ReactiveFlags.Dirty)
    ) {
      // 依赖确定脏了(ref 被写过,或 computed 已被判定为脏):让它先更新自己
      const subs = dep.subs!
      if ((dep as Computed).update()) {
        if (subs.nextSub !== undefined) {
          shallowPropagate(subs)
        }
        dirty = true   // ← 更新后值真的变了,我也脏了
      }
    } else if (
      (depFlags & (ReactiveFlags.Mutable | ReactiveFlags.Pending)) ===
      (ReactiveFlags.Mutable | ReactiveFlags.Pending)
    ) {
      // 依赖只是"可能脏":把当前位置压栈,先下钻去检查它的依赖(显式栈代替递归)
      stack = { value: link, prev: stack }
      link = dep.deps!
      sub = dep
      ++checkDepth
      continue
    }

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

    // 回溯:把下钻时压栈的层逐层弹出
    while (checkDepth) {
      --checkDepth
      link = stack!.value
      stack = stack!.prev
      if (dirty) {
        const subs = sub.subs!
        if ((sub as Computed).update()) {
          if (subs.nextSub !== undefined) {
            shallowPropagate(subs)
          }
          sub = link.sub
          continue        // ← 中间层值变了,上一层也脏
        }
      } 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)
}

让我们用一个例子来理解这个算法:

const a = ref(1)
const b = computed(() => a.value > 0 ? 'positive' : 'non-positive')
const c = computed(() => `Result: ${b.value}`)
const e = effect(() => console.log(c.value))

// 初始状态(首轮执行完之后):a、b、c、e 的 flags 里都没有 Dirty / Pending
// b._value = 'positive', c._value = 'Result: positive'

a.value = 2
// RefImpl 的 setter:a.flags |= Dirty,然后 propagate(a.subs):
//   b 被标 Pending → 继续沿 b.subs 下推 → c 被标 Pending → e 被标 Pending 并进入 notifyBuffer
// 不在批处理中 → flush() → e.notify() → 读 e.dirty:
// 1. e 只有 Pending → checkDirty(e.deps, e)
// 2. e 的依赖 c 是 Mutable|Pending → 压栈,转去检查 c 的依赖
// 3. c 的依赖 b 是 Mutable|Pending → 再压栈,转去检查 b 的依赖
// 4. b 的依赖 a 是 Mutable|Dirty → a.update() 确认值确实变了 → dirty = true
// 5. 回溯到 b:b.update() 重新执行 getter → 'positive' → hasChanged 为 false → 返回 false
// 6. dirty 归零;回溯到 c:c 的 Pending 被清掉,c 不重算
// 7. checkDirty 返回 false → e 的 Pending 被清掉 → e 不重新执行!

这就是 Alien Signals 的"变化过滤"能力:虽然 a 从 1 变成了 2,但 b 的结果没变(仍然是 'positive'),所以 c 不需要重算,e 也不需要重新执行。每一层 computed 都是一道"变化防火墙"。

这个"变化防火墙"的能力在真实项目里的价值有多大?——想象一个股票行情面板:后端每秒 push 一次股价更新,但用户界面上显示的不是原始价格,而是"涨/跌/平"这三种状态的标签。如果没有 computed 的变化过滤:每次股价变化(即使涨幅只有 0.01%、显示状态仍然是"涨")都会触发 DOM 更新、重绘这个标签。有了 computed 的变化过滤:只有"涨/跌/平"这个派生值真的切换时,DOM 才会更新——中间几百次微小的价格波动全部被 computed 层过滤掉、不会传到 DOM。这种"源头抖、中间静、末端不动"的效果是大型实时应用性能的基石。下次你看到一个 Vue 应用在高频数据场景下依然流畅,你就知道背后是这种 computed 过滤在默默工作。

graph TD
    A["ref(a) = 1 → 2<br/>标 Dirty"] -->|依赖| B["computed(b)<br/>a > 0 ? 'positive' : 'non-positive'"]
    B -->|依赖| C["computed(c)<br/>'Result: ' + b"]
    C -->|依赖| D["effect: 更新 DOM"]
    A -.->|"✅ 值变了<br/>1 → 2"| B
    B -.->|"❌ 结果没变<br/>'positive' → 'positive'"| C
    C -.->|"🛑 不需要重算"| D
    style A fill:#ff6b6b,stroke:#333,color:#333
    style B fill:#ffd93d,stroke:#333
    style C fill:#4ecdc4,stroke:#333
    style D fill:#4ecdc4,stroke:#333

💡 最佳实践

利用 computed 的"变化防火墙"特性来优化性能。当你有一个复杂的数据转换链时,将其拆分为多层 computed。每一层都可能过滤掉无意义的变化,避免不必要的下游更新:

// 不好 — 一层计算,任何输入变化都触发 DOM 更新
const display = computed(() => {
  const items = filterItems(rawItems.value, filter.value)
  const sorted = sortItems(items, sortKey.value)
  return formatForDisplay(sorted)
})

// 好 — 三层计算,每层都是一道过滤器
const filtered = computed(() => filterItems(rawItems.value, filter.value))
const sorted = computed(() => sortItems(filtered.value, sortKey.value))
const display = computed(() => formatForDisplay(sorted.value))
// 如果 sortKey 变了但 sorted 的结果不变,display 不会重算——注意 computed 用 Object.is 比较,
// sortItems 必须在结果不变时返回同一个数组引用,每次都返回新数组就起不到过滤作用

4.6 集合类型的响应式处理

Map 和 Set 这些集合类型看似是 JavaScript 里的"主力容器"——但 Vue 对它们的响应式处理却要比普通对象复杂得多。原因藏在 Proxy 的限制里:Proxy 的 get trap 只能拦截属性访问(map.size、map.get)——它能知道"用户在读这个方法",但在"用户调用这个方法并传入什么参数"这层就无能为力了。Vue 的办法是:当用户通过 get trap 读 map.get 这个方法时,不返回原方法,而是返回一个"包装过的方法"——这个包装方法在真正执行 target.get(key) 之前先做 track、之后做其他处理。下面的代码就是这种"把方法换掉"技巧的完整实现——这是 Proxy 拦截能力受限时的一个标准解法,值得作为模式记下来。

Vue 对 Map、Set、WeakMap、WeakSet 使用了完全独立的 Proxy handler,因为这些集合类型的操作方式与普通对象截然不同。

为什么需要特殊处理

const map = reactive(new Map())

// Map 的操作通过方法调用,不是属性访问
map.set('key', 'value')  // 不能用 set trap(那是属性赋值的 trap)
map.get('key')            // 不能用 get trap(那是属性读取的 trap)
map.has('key')            // 不能用 has trap(那是 in 操作符的 trap)

Vue 的解决方案是拦截方法的获取,返回修改过的方法实现:

// packages/reactivity/src/collectionHandlers.ts(简化:mutableCollectionHandlers 在 :296,
// createInstrumentationGetter 在 :270,createInstrumentations 在 :96)

// 集合类型的 handler 只有一个 get trap——所有拦截都靠"替换方法"完成
export const mutableCollectionHandlers: ProxyHandler<CollectionTypes> = {
  get: /*@__PURE__*/ createInstrumentationGetter(false, false),
}

function createInstrumentationGetter(isReadonly: boolean, shallow: boolean) {
  const instrumentations = createInstrumentations(isReadonly, shallow)

  return (target, key, receiver) => {
    if (key === ReactiveFlags.IS_REACTIVE) return !isReadonly
    if (key === ReactiveFlags.IS_READONLY) return isReadonly
    if (key === ReactiveFlags.RAW) return target
    // 当用户访问 map.get / map.set / map.size 等成员时,
    // 从 instrumentations 上取我们包装过的版本;其余成员原样透传
    return Reflect.get(
      hasOwn(instrumentations, key) && key in target ? instrumentations : target,
      key,
      receiver,
    )
  }
}

// createInstrumentations(false, false) 生成的几个方法(简化)
const instrumentations = {
  get(this: MapTypes, key: unknown) {
    const target = this[ReactiveFlags.RAW]
    const rawTarget = toRaw(target)
    const rawKey = toRaw(key)
    if (hasChanged(key, rawKey)) {
      track(rawTarget, TrackOpTypes.GET, key)   // ← key 本身是代理时,代理版和原始版都追踪
    }
    track(rawTarget, TrackOpTypes.GET, rawKey)  // ← 追踪键的访问
    const { has } = getProto(rawTarget)
    if (has.call(rawTarget, key)) {
      return toReactive(target.get(key))        // ← 嵌套对象惰性代理
    } else if (has.call(rawTarget, rawKey)) {
      return toReactive(target.get(rawKey))
    }
  },

  get size() {
    const target = this[ReactiveFlags.RAW]
    track(toRaw(target), TrackOpTypes.ITERATE, ITERATE_KEY)
    return target.size
  },

  set(this: MapTypes, key: unknown, value: unknown) {
    value = toRaw(value)
    const target = toRaw(this)
    const { has, get } = getProto(target)
    let hadKey = has.call(target, key)
    if (!hadKey) {
      key = toRaw(key)
      hadKey = has.call(target, key)
    }
    const oldValue = get.call(target, key)
    target.set(key, value)
    if (!hadKey) {
      trigger(target, TriggerOpTypes.ADD, key, value)
    } else if (hasChanged(value, oldValue)) {
      trigger(target, TriggerOpTypes.SET, key, value, oldValue)
    }
    return this
  },

  // has / add / delete / clear / forEach,以及 keys / values / entries / [Symbol.iterator]
  // 四个迭代器方法(createIterableMethod),都用同样的套路包装
}

集合操作的追踪矩阵

下面这张表是集合类型响应式处理的"完整对照表"——和前面"触发更新的类型矩阵"一样适合当成参考卡片使用。集合类型不像普通对象那么直观、每一种操作触发哪些 Dep 容易记混,这张表帮你在实际排查问题时快速定位"我调用了 map.set,它触发了哪些依赖"。把它和 4.4 节的触发矩阵放在一起就是 Vue 响应式触发行为的完整"cheatsheet"——值得保存到自己的笔记里。

操作 track/trigger 追踪的 key
map.get(k) track k
map.set(k, v) trigger(ADD 或 SET) k + ITERATE_KEY(新 key 还会触发 MAP_KEY_ITERATE_KEY)
map.has(k) track k
map.delete(k) trigger(DELETE) k + ITERATE_KEY + MAP_KEY_ITERATE_KEY
map.clear() trigger(CLEAR) 所有 key
map.size track ITERATE_KEY
map.forEach() / map.values() / map.entries() track ITERATE_KEY
map.keys() track MAP_KEY_ITERATE_KEY
set.add(v) trigger(ADD) v + ITERATE_KEY
set.delete(v) trigger(DELETE) v + ITERATE_KEY
set.has(v) track v

4.7 本章小结

本章深入 @vue/reactivity 的源码,逐一解析了五个核心 API 的完整实现。关键要点:

  1. reactive() 使用 Proxy 的五大拦截陷阱(get/set/deleteProperty/has/ownKeys)实现全面的响应式追踪。惰性代理策略避免了对未访问属性的开销。

  2. ref() 用 getter/setter 包装任意值(包括基本类型),RefImpl 自己就是一个 ReactiveNode(订阅者链表 + 标志位):读时 link()、写时 propagate()。

  3. track() 通过 targetMap(WeakMap → Map → Dep)的两级映射定位依赖节点,通过 Link 双向链表建立 Dep 与订阅者的关联;link() 用三道去重让上一轮的 Link 原地复用。

  4. trigger() 根据操作类型(ADD/SET/DELETE/CLEAR)精确确定需要通知的 Dep 集合,然后 propagate() 沿链表打 Pending 标志、shallowPropagate() 把直接订阅者升级成 Dirty,effect 进入通知队列,endBatch() 时 flush() 统一执行。

  5. computed() 实现了惰性求值——只在被读取时才通过 checkDirty() 渐进式检查依赖是否变化。每一层 computed 都是一道"变化防火墙",过滤掉无意义的更新。

  6. 集合类型(Map/Set)使用独立的 handler,通过拦截方法获取返回修改过的方法实现来追踪和触发更新。

下一章继续拆 effect、effectScope、shallowReactive、readonly。

延伸阅读

  • Vue 3 源码 packages/reactivity/src/reactive.ts、baseHandlers.ts、arrayInstrumentations.ts、collectionHandlers.ts、ref.ts、computed.ts、dep.ts、system.ts:本章讨论的所有代码的原始位置,建议全部 clone 到本地配合阅读。
  • MDN Proxy 文档:每一个 trap 的完整行为规范——读 Vue 的 baseHandlers 前先把这个文档过一遍会省下大量迷惑时间。
  • Vue RFC #0013(Composition API,含 ref / reactive 的动机与取舍):.value API 设计的社区讨论原始记录,能让你看到 Vue 团队是怎么权衡取舍到最后方案的。
  • Alien Signals GitHub 仓库 stackblitz/alien-signals(作者 Johnson Chu):Alien Signals 算法的完整独立实现,核心 system.ts 只有几百行,Vue 的 packages/reactivity/src/system.ts 文件头就写着它是从这里移植的,非常值得对照精读。
  • V8 文档 Maps (Hidden Classes) in V8(v8.dev/docs/hidden-classes)与博客 Fast properties in V8:理解 .value 这种 getter 访问在 V8 上的运行时开销——会改变你对".value 是否真的慢"的直觉。

思考题

  1. 概念理解:Vue 的 reactive() 使用了惰性代理策略——嵌套对象只在被访问时才创建 Proxy。请分析这种策略在"大对象、少量访问"和"小对象、频繁全量访问"两种场景下的性能表现差异。

  2. 深入思考:ref 的 set value() 中使用 hasChanged(newValue, oldValue) 避免无效更新。如果新值是一个对象(ref.value = { name: 'Vue' }),每次赋值即使内容相同,Object.is 也会返回 false(因为是不同的对象引用)。这会导致不必要的更新吗?Vue 是如何在 computed 层面缓解这个问题的?

  3. 工程实践:为什么 push、pop 等数组变异方法要用 noTracking 清空 activeSub?请构造一个不暂停追踪时会导致无限循环的具体代码示例。

  4. 横向对比:Solid.js 使用函数调用(count())来触发依赖收集,而 Vue 使用属性访问(count.value)。从 JavaScript 引擎优化(如 V8 的 hidden class 和 inline cache)的角度分析,哪种方式在运行时性能上可能更优?为什么?

  5. 开放讨论:checkDirty() 和 propagate() 都用显式栈(Stack<Link>)代替了递归。如果改成朴素递归实现,在极端情况下(如 100 层嵌套的 computed)会有什么问题?显式栈方案付出了什么代价?


延伸阅读:关键设计点速览

  • reactive vs ref:前者代理对象,访问属性天然响应式,但解构会丢失响应(const { x } = reactive(obj) 中 x 不再响应);后者包装值,模板里自动解包,可作为引用传递。
  • Proxy 的性能:读写都比普通对象慢(读的差距小于写),V8 的 inline caching 将实际影响压在可接受范围。体量很大、嵌套很深且不需要深层响应的对象,应考虑 shallowReactive。
  • 异步批处理:多次 state 修改通过 resolvedPromise.then(doFlushJobs) → flushJobs() 延迟到微任务合并(resolvedPromise 见 packages/runtime-core/src/scheduler.ts:56,doFlushJobs 见 packages/runtime-core/src/scheduler.ts:158,排入微任务的 resolvedPromise.then(doFlushJobs) 见 packages/runtime-core/src/scheduler.ts:169,flushJobs 见 packages/runtime-core/src/scheduler.ts:281),一次刷新 DOM,对应 React 18 的 automatic batching、Solid 的 batch。
  • WeakMap:targetMap 用 WeakMap<Target, Map<Key, Dep>>,原始对象被 GC 时依赖元数据也被自动释放。
  • 调试钩子:effect 的 onTrack / onTrigger 选项在 DEV 模式给出依赖捕获与触发回调,配合 Vue DevTools 的响应式依赖图。
  • Proxy 行为边界:Map/Set 走独立 handler(collectionHandlers.ts);数组越界赋值 arr[10] = x 由 set trap 捕获并触发 TriggerOpTypes.ADD(连带 length 的依赖);class 实例的 this 绑定需要注意——const m = reactive(new A()).method; m() 的 this 会丢失。