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() 的惰性求值:如何用版本号实现”不读不算”
- 从 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
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
function createReactiveObject(
target: object,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>,
proxyMap: WeakMap<object, any>
) {
// 1. 非对象类型直接返回
if (!isObject(target)) {
return target
}
// 2. 已经是代理了,直接返回(除非要对 reactive 对象做 readonly)
if (target[ReactiveFlags.RAW] && !(isReadonly && target[ReactiveFlags.IS_REACTIVE])) {
return target
}
// 3. 检查缓存
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
// 4. 检查目标类型是否可以被代理
const targetType = getTargetType(target)
if (targetType === TargetType.INVALID) {
return target // ← 标记了 __v_skip 或被冻结的对象不代理
}
// 5. 创建代理
const proxy = new Proxy(
target,
targetType === TargetType.COLLECTION
? collectionHandlers // Map/Set
: baseHandlers // 普通对象/数组
)
// 6. 存入缓存
proxyMap.set(target, proxy)
return proxy
}
🔥 深度洞察
getTargetType()函数会检查对象的Object.isExtensible()状态。被冻结(Object.freeze())或被密封(Object.seal())的对象不会被代理。这不是一个任意的限制——Proxy规范要求代理的行为必须与目标对象的不变量(invariant)一致。如果目标对象的属性是不可配置的,Proxy 的gettrap 必须返回与目标属性相同的值。对冻结对象创建响应式代理会导致 Proxy 内部抛出 TypeError——Vue 选择在入口处就避免这种情况。
mutableHandlers:五大拦截陷阱
mutableHandlers 是普通对象的 Proxy handler,包含五个陷阱函数:
// packages/reactivity/src/baseHandlers.ts
export const mutableHandlers: ProxyHandler<object> = {
get, // 拦截属性读取 → 依赖收集
set, // 拦截属性赋值 → 触发更新
deleteProperty, // 拦截 delete → 触发更新
has, // 拦截 in 操作符 → 依赖收集
ownKeys // 拦截 Object.keys() / for...in → 依赖收集
}
Trap 1: get — 属性读取与依赖收集
// packages/reactivity/src/baseHandlers.ts(简化)
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. 数组方法的特殊处理
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
// 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(简化)
function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object
): boolean {
let oldValue = (target as any)[key]
// 1. 如果旧值是 ref 而新值不是,更新 ref 的 .value
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
// 2. 判断是新增还是修改
const hadKey = isArray(target)
? Number(key) < target.length
: hasOwn(target, key)
// 3. 执行真正的赋值
const result = Reflect.set(target, key, value, receiver)
// 4. 只有代理自身(非原型链)的操作才触发更新
if (target === toRaw(receiver)) {
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) 检查”当前操作是否作用于我代理的这个对象本身”——如果只是原型链上的波及,就不主动触发更新。这种保护在 class 继承的场景里尤其重要:子类的响应式实例不会因为父类的某个字段被操作而错误触发更新。这类边界情况的细致处理是判断一个库”生产级”还是”玩具级”的关键指标——能处理正常流程的库一抓一大把,能正确处理这些边界情况的库就少得多。
Trap 3–5: deleteProperty / has / ownKeys
// deleteProperty — delete obj.key
function deleteProperty(target: object, key: string | symbol): boolean {
const hadKey = hasOwn(target, key)
const result = Reflect.deleteProperty(target, key)
if (result && hadKey) {
trigger(target, TriggerOpTypes.DELETE, key) // ← 删除也能触发更新
}
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/baseHandlers.ts(简化)
const arrayInstrumentations: Record<string, Function> = {}
// 查找方法:includes、indexOf、lastIndexOf
;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
arrayInstrumentations[key] = function(this: unknown[], ...args: unknown[]) {
const arr = toRaw(this)
// 追踪数组每个元素的访问
for (let i = 0, l = this.length; i < l; i++) {
track(arr, TrackOpTypes.GET, i + '')
}
// 先用原始参数查找
const res = arr[key](...args)
if (res === -1 || res === false) {
// 如果没找到,用 toRaw 后的参数再试一次
return arr[key](...args.map(toRaw))
}
return res
}
})
// 变异方法:push、pop、shift、unshift、splice
;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
arrayInstrumentations[key] = function(this: unknown[], ...args: unknown[]) {
pauseTracking() // ← 暂停依赖收集!
const res = (toRaw(this) as any)[key].apply(this, args)
resetTracking() // ← 恢复依赖收集
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,导致无限循环。pauseTracking()优雅地解决了这个问题——在变异方法执行期间,不收集任何新的依赖。
4.2 ref():为什么基本类型需要 .value
reactive 能代理对象,但代理不了 number、string、boolean 这些基本类型——这是 JavaScript Proxy 的硬约束。Vue 给出的答案是 ref:用对象包装一个值,在对象上挂 getter/setter 做响应式追踪。这个看似朴素的”一层包装”解决方案背后,藏着 Vue 响应式系统里最反复被讨论的一个设计决策——.value 访问的代价究竟值不值。本节我们不只看实现,还要回到 4.2.1 小节讨论这个取舍的设计原理。
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 的 Dep 之间的协作——每个 ref 内部都有一个 dep: Dep,读取时调 dep.track()、写入时调 dep.trigger()。这种”ref 作为壳、Dep 作为引擎”的分层让 ref 的实现非常清爽——业务逻辑和依赖追踪被干净地隔离开来。你在自己的项目里写类似”带响应式能力的包装对象”时可以借鉴这个模式:外壳类负责业务语义、内部依赖引擎负责响应式机制。
// packages/reactivity/src/ref.ts(简化)
class RefImpl<T = any> {
_value: T
_rawValue: T
readonly [ReactiveFlags.IS_REF] = true
// dep 是 Alien Signals 的依赖节点
dep: Dep
constructor(value: T, isShallow: boolean) {
this._rawValue = isShallow ? value : toRaw(value)
this._value = isShallow ? value : toReactive(value)
this.dep = new Dep()
}
get value(): T {
// 依赖收集
this.dep.track()
return this._value
}
set value(newValue: T) {
const oldValue = this._rawValue
// 是否使用原始值比较
const useDirectValue = this.__v_isShallow || isShallow(newValue) || isReadonly(newValue)
newValue = useDirectValue ? newValue : toRaw(newValue)
if (hasChanged(newValue, oldValue)) {
this._rawValue = newValue
this._value = useDirectValue ? newValue : toReactive(newValue)
// 触发更新
this.dep.trigger()
}
}
}
export function ref<T>(value: T): Ref<T> {
if (isRef(value)) {
return value // ← 已经是 ref,直接返回
}
return new RefImpl(value, false)
}
几个关键设计决策:
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
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. Dep 类——Alien Signals 的依赖节点
在 Vue 3.6 中,Dep 不再是一个 Set<ReactiveEffect>,而是一个 Alien Signals 的依赖节点:
// packages/reactivity/src/dep.ts(简化)
export class Dep {
// 版本号——每次值变化时递增
_version = 0
// 订阅者链表头
_subs: Link | undefined = undefined
// 全局版本号
_globalVersion = globalVersion
track(): Link | undefined {
if (activeEffect) {
// 将当前 effect 添加到订阅者链表
return link(this, activeEffect)
}
}
trigger(): void {
this._version++
globalVersion++
// 通知所有订阅者
propagate(this._subs)
}
}
ref vs reactive 的选择
| 维度 | ref | reactive |
|---|---|---|
| 适用类型 | 任何类型(基本类型 + 对象) | 仅对象 |
| 访问方式 | .value | 直接属性访问 |
| 解构 | 解构后保持响应式(toRefs) | 解构后丢失响应式 |
| 模板中 | 自动解包(不需要 .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 实现,然后我们再逐行拆解。这段代码只有 20 行左右、读起来也很好懂——这正是 Vue 3.6 相比 3.0-3.4 版本的巨大简化,你如果之前读过旧版源码会立刻感受到差别。旧版的 track 充满了 cleanup 逻辑、Set 分配、反向引用维护……Vue 3.6 的 track 因为有 Alien Signals 的双向链表支撑,代码瘦身了一半不止。好的重构不仅让性能变好,还让代码变短——这是 Vue 3.6 响应式升级在可维护性维度的附加红利。
在 Alien Signals 架构下,track 的实现已经大幅简化:
// packages/reactivity/src/dep.ts(简化)
// 全局变量:当前正在执行的 effect
export let activeSub: Subscriber | undefined
export function track(target: object, type: TrackOpTypes, key: unknown): void {
if (shouldTrack && activeSub) {
// 获取或创建目标对象的依赖映射
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()))
}
// 将当前 subscriber 链接到这个 dep
dep.track()
}
}
依赖存储的数据结构是一个两级 Map:
targetMap: WeakMap<target, Map<key, Dep>>
例如:
targetMap = WeakMap {
{ name: 'Vue', version: 3.6 } => Map {
'name' => Dep { _version: 2, _subs: Link → ... },
'version' => Dep { _version: 0, _subs: Link → ... }
}
}
Link——双向链表节点
Link 节点是 Alien Signals 的原子数据结构——Vue 3.6 的整个依赖图都建立在它之上。理解 Link 不仅有助于读懂响应式源码,还有助于你思考”如何设计高效的图结构”这类更通用的数据结构问题。Link 同时属于两个链表的设计(一个 Link 既是”Dep 的订阅者列表”的节点、又是”Subscriber 的依赖列表”的节点)——这种”一个节点参与多个链表”的模式在 Linux 内核里大量使用(任务调度器、文件系统 inode、网络 buffer),Vue 把它搬到了 JavaScript 层面。
在 Vue 3.6 中,Dep 和 Subscriber(effect/computed)之间的关系通过 Link 节点维护,形成双向链表:
// packages/reactivity/src/dep.ts(简化)
interface Link {
dep: Dep // 指向依赖源
sub: Subscriber // 指向订阅者
// dep 维度的链表(同一个 dep 的所有 subscriber)
prevSub: Link | undefined
nextSub: Link | undefined
// sub 维度的链表(同一个 subscriber 的所有 dep)
prevDep: Link | undefined
nextDep: Link | undefined
// 版本快照——建立链接时 dep 的版本号
version: number
}
这个双向链表结构可以用下图理解:
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 -->|"nextSub"| L1["Link 1<br/>dep:A, sub:C"]
L1 -->|"nextDep"| L2["Link 2<br/>dep:B, sub:C"]
B_subs -->|"nextSub"| L2
L2 -->|"nextDep"| L3
C_subs -->|"nextSub"| L3["Link 3<br/>dep:C, sub:E"]
E_deps -->|"nextDep"| L3
style L1 fill:#ffd93d,stroke:#333
style L2 fill:#ffd93d,stroke:#333
style L3 fill:#ffd93d,stroke:#333
每个 Link 节点同时属于两个链表:
- 沿 dep 维度(
prevSub/nextSub):链接同一个 Dep 的所有 Subscriber - 沿 sub 维度(
prevDep/nextDep):链接同一个 Subscriber 的所有 Dep
🔥 深度洞察
双向链表的设计看似比
Set更复杂,但它解决了 Set-based 方案的根本性能问题——cleanup 开销。在旧版 Vue 中,每次 effect 重新执行时,它必须从所有 Dep 的 Set 中删除自己(O(n) 次Set.delete),然后重新收集依赖(O(n) 次Set.add)。在 Link 链表中,这个过程变成了链表指针的重新排列——O(1) 的指针操作取代了 O(n) 的 Set 操作。更重要的是,Link 节点可以被复用(不需要创建新对象),消除了 GC 压力。
link() 函数——建立依赖关系
// packages/reactivity/src/dep.ts(简化)
export function link(dep: Dep, sub: Subscriber): Link {
// 检查是否已有这个链接(通过遍历 sub 的 dep 链表)
const currentDep = sub._depsTail
if (currentDep !== undefined && currentDep.dep === dep) {
// 已存在,复用
return currentDep
}
// 创建新的 Link 节点
const newLink: Link = {
dep,
sub,
version: dep._version, // ← 快照当前版本号
prevDep: currentDep,
nextDep: undefined,
prevSub: undefined,
nextSub: undefined,
}
// 链入 sub 的 dep 链表
if (currentDep) {
currentDep.nextDep = newLink
} else {
sub._deps = newLink
}
sub._depsTail = newLink
// 链入 dep 的 sub 链表
if (dep._subs) {
const oldTail = dep._subsTail!
newLink.prevSub = oldTail
oldTail.nextSub = newLink
} else {
dep._subs = newLink
}
dep._subsTail = newLink
return newLink
}
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(简化)
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown
): void {
const depsMap = targetMap.get(target)
if (!depsMap) return // 这个对象没有被追踪
let deps: Dep[] = []
if (type === TriggerOpTypes.CLEAR) {
// Map/Set.clear() — 触发所有依赖
deps = [...depsMap.values()]
} else if (key === 'length' && isArray(target)) {
// 数组 length 变化 — 触发 length 和受影响的索引
depsMap.forEach((dep, key) => {
if (key === 'length' || (isIntegerKey(key) && Number(key) >= (newValue as number))) {
deps.push(dep)
}
})
} else {
// 普通属性变化
if (key !== void 0) {
const dep = depsMap.get(key)
if (dep) deps.push(dep)
}
// ADD/DELETE 还需要触发迭代相关的依赖
switch (type) {
case TriggerOpTypes.ADD:
if (!isArray(target)) {
const iterateDep = depsMap.get(ITERATE_KEY)
if (iterateDep) deps.push(iterateDep)
} else if (isIntegerKey(key)) {
// 数组新增元素 → length 变了
const lengthDep = depsMap.get('length')
if (lengthDep) deps.push(lengthDep)
}
break
case TriggerOpTypes.DELETE:
if (!isArray(target)) {
const iterateDep = depsMap.get(ITERATE_KEY)
if (iterateDep) deps.push(iterateDep)
}
break
case TriggerOpTypes.SET:
if (isMap(target)) {
const iterateDep = depsMap.get(ITERATE_KEY)
if (iterateDep) deps.push(iterateDep)
}
break
}
}
// 触发所有收集到的 Dep
for (const dep of deps) {
dep.trigger()
}
}
触发更新的类型矩阵
下面这张表我建议你打印出来贴在显示器边——它是”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 + length 的 Dep | arr[10] = 'x'(length < 10) |
arr.length = n | length 的 Dep + 所有 ≥ n 的索引 Dep | arr.length = 0(清空数组) |
map.set(k, v) | k 的 Dep + ITERATE_KEY 的 Dep | map.set('a', 1) |
set.clear() | 所有 Dep | set.clear() |
propagate()——Alien Signals 的传播算法
propagate 是 trigger 的”末端”——它真正把变化通知到每一个订阅者。但这个”通知”不是简单的”遍历 + 执行”:它要对 computed 和 effect 做差异化处理(第 3 章讨论的混合推拉模型在这里落地)、要正确处理 computed 的下游级联通知、要避免重复激活同一个订阅者。短短十几行代码承载了大量语义——读的时候别一扫而过、每一行都值得理解为什么这么写。
当 dep.trigger() 被调用时,它最终调用 propagate() 沿链表通知所有订阅者:
// packages/reactivity/src/dep.ts(简化)
function propagate(subs: Link | undefined): void {
let link = subs
while (link) {
const sub = link.sub
const subFlags = sub._flags
if (sub._flags & SubscriberFlags.COMPUTED) {
// 如果订阅者是 computed,标记为可能脏(MAYBE_DIRTY)
// 不立即重算——等到被读取时才惰性求值
sub._flags |= SubscriberFlags.DIRTY
// 继续向下传播(computed 的订阅者也需要知道)
if (sub._subs) {
propagate(sub._subs)
}
} else {
// 如果订阅者是 effect,加入调度队列
if (sub.scheduler) {
sub.scheduler() // ← 组件更新走 scheduler
} else {
sub.run() // ← watchEffect 直接执行
}
}
link = link.nextSub
}
}
🔥 深度洞察
propagate对 computed 和 effect 的不同处理方式,正是 Alien Signals “混合推拉模型”的具体体现。对于 computed,只做标记(DIRTY),不做实际计算——这是”拉”的部分,等待消费者主动读取。对于 effect,立即调度执行——这是”推”的部分,因为 effect 没有消费者来”拉”它,必须主动推送执行。这种差异化处理是 Alien Signals 相比纯推模型(Vue 3.0–3.4)的核心优化点:如果一个 computed 没人读,它永远不会被重算。
回到第 3 章讨论过的”推 vs 拉”对比——现在你终于在具体实现里看到了两者是怎么组合的。它不是一个简单的 if/else,而是对象类型决定传播策略:遇到 computed 下游,就”标记脏、等读取”;遇到 effect 下游,就”调度执行、不等人”。这种”按下游类型分支处理”的模型让 Vue 响应式既能享受惰性求值的性能优势(对 computed)、又能保证副作用的及时执行(对 effect)——鱼与熊掌兼得的工程智慧。
4.5 computed():惰性求值的精妙实现
computed 是我个人最喜欢的 Vue 响应式 API——它小巧、优雅、威力巨大。表面上它只是”缓存计算结果”,本质上它是响应式图里的灵魂:连接 signal(源)和 effect(汇)的中间节点、用惰性求值过滤无意义变化、用版本号实现 O(1) 脏检查。一个中型应用可能有几百个 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 被标记为 dirty,但不重算
console.log(total.value) // 60 — 此时才重新计算
computed 的完整实现
// packages/reactivity/src/computed.ts(简化)
export class ComputedRefImpl<T = any> {
_value: T = undefined as T
readonly dep: Dep = new Dep()
// 作为 subscriber 的属性
_deps: Link | undefined = undefined
_depsTail: Link | undefined = undefined
_flags: number = SubscriberFlags.COMPUTED | SubscriberFlags.DIRTY
_globalVersion: number = globalVersion - 1
constructor(
private readonly _fn: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T> | undefined,
isSSR: boolean
) {}
get value(): T {
// 1. 检查是否需要重算
const flags = this._flags
if (
flags & (SubscriberFlags.DIRTY | SubscriberFlags.MAYBE_DIRTY) ||
this._globalVersion !== globalVersion
) {
if (this._globalVersion !== globalVersion) {
this._globalVersion = globalVersion
}
// 2. 尝试更新值
this.update()
}
// 3. 依赖收集(让消费者追踪这个 computed)
this.dep.track()
return this._value
}
set value(newValue: T) {
if (this._setter) {
this._setter(newValue)
}
}
update(): boolean {
const oldValue = this._value
// 1. 检查依赖是否真的变了
if (this._flags & SubscriberFlags.MAYBE_DIRTY) {
// 惰性脏检查——递归检查上游依赖
if (!checkDirty(this._deps!)) {
this._flags &= ~SubscriberFlags.MAYBE_DIRTY
return false // ← 依赖没变,不需要重算
}
}
// 2. 重新计算
const prevSub = activeSub
activeSub = this // ← 将自己设为当前活跃 subscriber
try {
const newValue = this._fn() // ← 执行 getter
if (hasChanged(newValue, oldValue)) {
this._value = newValue
this.dep._version++ // ← 值变了,递增版本号
return true
}
return false // ← 值没变,不递增版本号
} finally {
activeSub = prevSub
this._flags &= ~(SubscriberFlags.DIRTY | SubscriberFlags.MAYBE_DIRTY)
}
}
}
checkDirty:渐进式脏检查
checkDirty 是本章(乃至本书)我最想让你仔细读的一段源码。它不长、不到 25 行,但它是 Alien Signals 最核心的算法——整个”惰性求值 + 变化过滤”模型的灵魂就在这里。理解了它,你就理解了 Vue 3.6 响应式系统相对 Vue 3.4 的本质升级。读下面这段代码时请不要急——每一行都值得慢慢品。
checkDirty 是 Alien Signals 最精巧的算法。它实现了渐进式脏检查——不是简单地回答”你脏了吗”,而是沿着依赖链逐层检查”你的哪个依赖变了”:
// packages/reactivity/src/dep.ts(简化)
function checkDirty(deps: Link): boolean {
let link: Link | undefined = deps
while (link) {
const dep = link.dep
if (dep._version !== link.version) {
// 版本号不匹配——依赖确实变了
// 如果 dep 是 computed,先让它更新
if ('update' in dep) {
(dep as ComputedRefImpl).update()
if (dep._version !== link.version) {
return true // ← 更新后版本号仍然不匹配,确实脏了
}
} else {
return true // ← dep 是 signal,版本号变了就是脏了
}
}
// 更新 link 的版本快照
link.version = dep._version
link = link.nextDep
}
return false // ← 所有依赖都没变
}
让我们用一个例子来理解这个算法:
const a = ref(1)
const b = computed(() => a.value > 0 ? 'positive' : 'non-positive')
const c = computed(() => `Result: ${b.value}`)
// 初始状态:
// a._version = 0, b._version = 0, c._version = 0
// b._value = 'positive', c._value = 'Result: positive'
a.value = 2 // a._version = 1, b 和 c 被标记为 MAYBE_DIRTY
console.log(c.value)
// 1. c.get() → c 是 MAYBE_DIRTY → checkDirty(c._deps)
// 2. checkDirty 检查 c 的依赖 b:b._version(0) vs link.version(0) → 匹配
// 但 b 也是 MAYBE_DIRTY → 递归检查 b
// 3. b.update() → b._fn() → a.value > 0 → true → 'positive'
// b._value 从 'positive' 到 'positive' → 没变!→ b._version 不递增
// 4. 回到 c 的 checkDirty → b._version 仍然匹配 → 返回 false
// 5. c 不需要重算!
这就是 Alien Signals 的”变化过滤”能力:虽然 a 从 1 变成了 2,但 b 的结果没变(仍然是 'positive'),所以 c 不需要重算。每一层 computed 都是一道”变化防火墙”。
这个”变化防火墙”的能力在真实项目里的价值有多大?——想象一个股票行情面板:后端每秒 push 一次股价更新,但用户界面上显示的不是原始价格,而是”涨/跌/平”这三种状态的标签。如果没有 computed 的变化过滤:每次股价变化(即使涨幅只有 0.01%、显示状态仍然是”涨”)都会触发 DOM 更新、重绘这个标签。有了 computed 的变化过滤:只有”涨/跌/平”这个派生值真的切换时,DOM 才会更新——中间几百次微小的价格波动全部被 computed 层过滤掉、不会传到 DOM。这种”源头抖、中间静、末端不动”的效果是大型实时应用性能的基石。下次你看到一个 Vue 应用在高频数据场景下依然流畅,你就知道背后是这种 computed 过滤在默默工作。
graph TD
A["ref(a) = 1 → 2<br/>版本 0 → 1"] -->|依赖| 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:#fff
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 变了但排序结果不变,display 不会重算
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(简化)
const mutableCollectionHandlers: ProxyHandler<any> = {
get(target, key, receiver) {
// 当用户访问 map.get / map.set / map.has 等方法时
// 返回我们包装过的方法
if (key === 'get') return instrumentedGet
if (key === 'set') return instrumentedSet
if (key === 'has') return instrumentedHas
if (key === 'delete') return instrumentedDelete
if (key === 'clear') return instrumentedClear
if (key === 'forEach') return instrumentedForEach
if (key === 'size') {
track(target, TrackOpTypes.ITERATE, ITERATE_KEY)
return Reflect.get(target, 'size', target)
}
// ...
return Reflect.get(target, key, receiver)
}
}
function instrumentedGet(this: Map<any, any>, key: unknown) {
const target = toRaw(this)
track(target, TrackOpTypes.GET, key) // ← 追踪键的访问
const value = target.get(key)
return isObject(value) ? reactive(value) : value // ← 嵌套对象惰性代理
}
function instrumentedSet(this: Map<any, any>, key: unknown, value: unknown) {
const target = toRaw(this)
const hadKey = target.has(key)
const oldValue = target.get(key)
target.set(key, toRaw(value))
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
集合操作的追踪矩阵
下面这张表是集合类型响应式处理的”完整对照表”——和前面”触发更新的类型矩阵”一样适合当成参考卡片使用。集合类型不像普通对象那么直观、每一种操作触发哪些 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.has(k) | track | k |
map.delete(k) | trigger(DELETE) | k + ITERATE_KEY |
map.clear() | trigger(CLEAR) | 所有 key |
map.size | track | ITERATE_KEY |
map.forEach() | track | 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 包装任意值(包括基本类型),内部的
Dep节点是 Alien Signals 的依赖管理核心。 -
track() 通过
targetMap(WeakMap → Map → Dep)的两级映射定位依赖节点,通过Link双向链表建立 Dep 与 Subscriber 的关联。 -
trigger() 根据操作类型(ADD/SET/DELETE/CLEAR)精确确定需要通知的 Dep 集合,然后通过
propagate()沿链表传播更新。 -
computed() 实现了惰性求值——只在被读取时才通过
checkDirty()渐进式检查依赖是否变化。每一层 computed 都是一道”变化防火墙”,过滤掉无意义的更新。 -
集合类型(Map/Set)使用独立的 handler,通过拦截方法获取返回修改过的方法实现来追踪和触发更新。
下一章继续拆 effect、effectScope、shallowReactive、readonly。
延伸阅读
- Vue 3 源码
packages/reactivity/src/reactive.ts、baseHandlers.ts、ref.ts、effect.ts、computed.ts、dep.ts:本章讨论的所有代码的原始位置,建议全部 clone 到本地配合阅读。 - MDN Proxy 文档:每一个 trap 的完整行为规范——读 Vue 的 baseHandlers 前先把这个文档过一遍会省下大量迷惑时间。
- Vue RFC #0017(ref 设计):
.valueAPI 设计的社区讨论原始记录,能让你看到 Vue 团队是怎么权衡取舍到最后方案的。 - Alien Signals GitHub 仓库
medv/alien-signals(注:早期原型实际由 Johnson Chu 独立开发,仓库名见 GitHub):Alien Signals 算法的完整独立实现,不到 500 行代码,非常值得精读。 - V8 博客 Hidden Classes and Inline Caches:理解
.value这种 getter 访问在 V8 上的运行时开销——会改变你对”.value 是否真的慢”的直觉。
思考题
-
概念理解:Vue 的
reactive()使用了惰性代理策略——嵌套对象只在被访问时才创建 Proxy。请分析这种策略在”大对象、少量访问”和”小对象、频繁全量访问”两种场景下的性能表现差异。 -
深入思考:
ref的set value()中使用hasChanged(newValue, oldValue)避免无效更新。如果新值是一个对象(ref.value = { name: 'Vue' }),每次赋值即使内容相同,Object.is也会返回false(因为是不同的对象引用)。这会导致不必要的更新吗?Vue 是如何在 computed 层面缓解这个问题的? -
工程实践:为什么
push、pop等数组变异方法需要pauseTracking()?请构造一个不暂停追踪时会导致无限循环的具体代码示例。 -
横向对比:Solid.js 使用函数调用(
count())来触发依赖收集,而 Vue 使用属性访问(count.value)。从 JavaScript 引擎优化(如 V8 的 hidden class 和 inline cache)的角度分析,哪种方式在运行时性能上可能更优?为什么? -
开放讨论:
checkDirty()的渐进式脏检查可能导致深层依赖链的递归遍历。在极端情况下(如 100 层嵌套的 computed),这种递归的栈深度是否会成为问题?如果会,你能想到什么优化方案?
延伸阅读:关键设计点速览
- reactive vs ref:前者代理对象,访问属性天然响应式,但解构会丢失响应(
const { x } = reactive(obj)中x不再响应);后者包装值,模板里自动解包,可作为引用传递。 - Proxy 的性能:读约 2-5x、写约 3-10x 慢于普通对象,V8 的 inline caching 将实际影响压在可接受范围。巨大对象 (> 100KB) 应考虑
shallowReactive。 - 异步批处理:多次 state 修改通过
queueMicrotask延迟到微任务合并,一次刷新 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[0] = x可被has/set捕获并触发TriggerOpTypes.ADD;class 实例的this绑定需要注意——const m = reactive(new A()).method; m()的this会丢失。