Vue 3 设计与实现
第 4 章 @vue/reactivity 源码深度剖析(上):reactive / ref / track / trigger / computed
第 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 缓存
)
}
注意两个关键细节:
-
两套 handler:普通对象和集合类型(Map、Set、WeakMap、WeakSet)使用不同的 Proxy handler,因为集合类型的操作方式(
.get()、.set()、.add())与普通对象(.prop、obj[key])完全不同。 -
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 的gettrap 必须返回与目标属性相同的值。冻结对象的属性正是如此,而响应式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)
}
💡 最佳实践
ownKeystrap 追踪的是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——双向链表节点
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 压力。
link() 函数——建立依赖关系
// 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()里还有最后一道闸:dirtygetter 会先看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 的完整实现。关键要点:
-
reactive() 使用 Proxy 的五大拦截陷阱(get/set/deleteProperty/has/ownKeys)实现全面的响应式追踪。惰性代理策略避免了对未访问属性的开销。
-
ref() 用 getter/setter 包装任意值(包括基本类型),
RefImpl自己就是一个ReactiveNode(订阅者链表 + 标志位):读时link()、写时propagate()。 -
track() 通过
targetMap(WeakMap → Map → Dep)的两级映射定位依赖节点,通过Link双向链表建立 Dep 与订阅者的关联;link()用三道去重让上一轮的 Link 原地复用。 -
trigger() 根据操作类型(ADD/SET/DELETE/CLEAR)精确确定需要通知的 Dep 集合,然后
propagate()沿链表打Pending标志、shallowPropagate()把直接订阅者升级成Dirty,effect 进入通知队列,endBatch()时flush()统一执行。 -
computed() 实现了惰性求值——只在被读取时才通过
checkDirty()渐进式检查依赖是否变化。每一层 computed 都是一道"变化防火墙",过滤掉无意义的更新。 -
集合类型(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 的动机与取舍):
.valueAPI 设计的社区讨论原始记录,能让你看到 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 是否真的慢"的直觉。
思考题
-
概念理解:Vue 的
reactive()使用了惰性代理策略——嵌套对象只在被访问时才创建 Proxy。请分析这种策略在"大对象、少量访问"和"小对象、频繁全量访问"两种场景下的性能表现差异。 -
深入思考:
ref的set value()中使用hasChanged(newValue, oldValue)避免无效更新。如果新值是一个对象(ref.value = { name: 'Vue' }),每次赋值即使内容相同,Object.is也会返回false(因为是不同的对象引用)。这会导致不必要的更新吗?Vue 是如何在 computed 层面缓解这个问题的? -
工程实践:为什么
push、pop等数组变异方法要用noTracking清空activeSub?请构造一个不暂停追踪时会导致无限循环的具体代码示例。 -
横向对比:Solid.js 使用函数调用(
count())来触发依赖收集,而 Vue 使用属性访问(count.value)。从 JavaScript 引擎优化(如 V8 的 hidden class 和 inline cache)的角度分析,哪种方式在运行时性能上可能更优?为什么? -
开放讨论:
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由settrap 捕获并触发TriggerOpTypes.ADD(连带length的依赖);class 实例的this绑定需要注意——const m = reactive(new A()).method; m()的this会丢失。