Vue 3 核心源码深度剖析系列(v3.5.44)-第 03 章 · 响应式内幕(二):reactive / ref 与代理陷阱
本章源码基线:
vuejs/core@3.5.44主战场:packages/reactivity/src/{reactive,ref,baseHandlers,collectionHandlers,arrayInstrumentations,constants,dep}.ts前置阅读:第 02 章(effect.ts/dep.ts的依赖追踪机制)。本章默认读者已经理解targetMap → depsMap → Dep → Link的四层结构与activeSub/shouldTrack这两个全局状态。
导语:Proxy 不是"更快的 defineProperty",而是一次范式迁移
Vue 2 的响应式建立在 Object.defineProperty 之上。它的本质约束是:必须在对象被观察之前,枚举出所有需要拦截的 key。这带来三个天花板:
- 新增属性不可响应。
this.newField = 1不会触发setter,只能靠Vue.set/this.$set兜底(内部走defineReactive补挂)。 - 删除属性不可响应。
delete this.foo无感知,需要Vue.delete。 - 数组索引赋值与
length修改不可响应。Vue 2 只重写了 7 个数组方法(push/pop/shift/unshift/splice/sort/reverse),arr[0] = x与arr.length = 0都不能触发更新。
Vue 3 换成了 Proxy。Proxy 的语义是在对象的外层套一个"元对象协议(meta-object protocol)"代理,把 JS 引擎内部对对象的一切操作([[Get]]、[[Set]]、[[HasProperty]]、[[Delete]]、[[OwnPropertyKeys]]、[[GetPrototypeOf]] …)化为可编程的陷阱(trap)函数。由此:
- 拦截是动态的——不再需要预先枚举 key,新增/删除/未知 key 天然可拦截;
- 拦截是懒的——嵌套对象只有在被访问时才被递归代理(lazy deep),不用启动时就递归遍历整棵对象树;
- 拦截是全操作的——
in、delete、Object.keys、数组索引都覆盖。
但 Proxy 也付出了代价:每一次属性访问都要穿过一层 JS 函数,而 defineProperty 的 getter 是原生 C++ 实现的。所以 Vue 3 的响应式开销主要在"代理层数 × 访问次数"上。本章会反复出现一个贯穿全文的性能主线:代理是懒的、可跳过、可标记 raws,理解这些开关就能把开销降到最低。
本章要回答的核心问题:
- 四种代理变体(
reactive/readonly/shallowReactive/shallowReadonly)在源码里到底是不是四套独立代码? reactive()拿到一个对象后,做了哪几步校验才决定"代理 or 原样返回"?- 一个
get陷阱里,为什么先判ReactiveFlags、再判数组 instrumentation、再track、最后才isRef/isObject递归?这个顺序能不能换? - 为什么
ref要和reactive共用一个ReactiveFlags命名空间?__v_isRef在get陷阱里扮演什么角色? - 数组的
push为什么会"触发两次",noTracking+pauseTracking到底暂停了什么? Map/Set的size、keys()这些操作的依赖键为什么必须单独设计ITERATE_KEY/MAP_KEY_ITERATE_KEY?
reactive() 的完整决策链(详见 3.2):非对象 → 已是代理(readonly(reactive) 例外)→ 被 markRaw/不可扩展 → 非 Object/Array/Map/Set/WeakMap/WeakSet 类型 → 缓存命中 → 建代理并缓存。理解这条链,本章的四个入口函数就都通了。
3.1 四种代理类型与 ReactiveFlags 全枚举
3.1.1 四种代理变体,其实只有两套 Handler 类
初学者常以为 reactive / readonly / shallowReactive / shallowReadonly 是四份独立的陷阱实现。看源码会发现完全不是——packages/reactivity/src/baseHandlers.ts:49 只有一个 BaseReactiveHandler,然后派生出两个子类:
// packages/reactivity/src/baseHandlers.ts:49-53
class BaseReactiveHandler implements ProxyHandler<Target> {
constructor(
protected readonly _isReadonly = false,
protected readonly _isShallow = false,
) {}
// ...
}
两个布尔字段 _isReadonly / _isShallow 决定了 get 陷阱的所有分支,而 set / deleteProperty / has / ownKeys 只在可变(mutable)分支实现,只读分支用 ReadonlyReactiveHandler 覆盖成"警告 + 不变更"。
| 变体 | Handler 实例 | _isReadonly | _isShallow | proxyMap | 源码 |
|---|---|---|---|---|---|
reactive | mutableHandlers | false | false | reactiveMap | baseHandlers.ts:251-252 |
shallowReactive | shallowReactiveHandlers | false | true | shallowReactiveMap | baseHandlers.ts:257-258 |
readonly | readonlyHandlers | true | false | readonlyMap | baseHandlers.ts:254-255 |
shallowReadonly | shallowReadonlyHandlers | true | true | shallowReadonlyMap | baseHandlers.ts:263-264 |
四个 Handler 是模块级单例,意味着所有 reactive 对象共享同一 Handler 实例——没有 O(对象数) 的额外分配,new Proxy(target, mutableHandlers) 的第二个参数是共享引用、不克隆。这点关键:Proxy 成本落在 target 与 handler 上,handler 复用让成本只按每个被观察对象一份计。
// packages/reactivity/src/baseHandlers.ts:251-264
export const mutableHandlers: ProxyHandler<object> =
/*@__PURE__*/ new MutableReactiveHandler()
export const readonlyHandlers: ProxyHandler<object> =
/*@__PURE__*/ new ReadonlyReactiveHandler()
export const shallowReactiveHandlers: MutableReactiveHandler =
/*@__PURE__*/ new MutableReactiveHandler(true)
// Props handlers are special in the sense that it should not unwrap top-level
// refs (in order to allow refs to be explicitly passed down), but should
// retain the reactivity of the normal readonly object.
export const shallowReadonlyHandlers: ReadonlyReactiveHandler =
/*@__PURE__*/ new ReadonlyReactiveHandler(true)
注意注释的关键信息:shallowReadonlyHandlers 除了"浅",还承担不自动解包顶层 ref 的语义——runtime-core 处理 props 时的刚需:父组件传下的 props 里若有 ref,子组件应拿到"ref 本身"而非解包值(详见 3.7)。
3.1.2 ReactiveFlags:六个内部标记的完整语义
// packages/reactivity/src/constants.ts:17-24
export enum ReactiveFlags {
SKIP = '__v_skip',
IS_REACTIVE = '__v_isReactive',
IS_READONLY = '__v_isReadonly',
IS_SHALLOW = '__v_isShallow',
RAW = '__v_raw',
IS_REF = '__v_isRef',
}
这六个是普通字符串 key(不是 Symbol);注释说得很直白:using literal strings instead of numbers so that it's easier to inspect debugger events——人类可读字符串让你在 DevTools 的 Proxy 面板里一眼看出对象性质。
逐个说明语义、写入方、读取方:
| Flag | 字符串 | 语义 | 谁写入 | 谁读取 |
|---|---|---|---|---|
SKIP | __v_skip | 该对象永远不转代理 | markRaw() 用 def() 挂上 | createReactiveObject 早退、BaseReactiveHandler.get 透传 |
IS_REACTIVE | __v_isReactive | "这是 reactive 代理(mutable)" | 不写入对象,由 get 陷阱动态求值 !isReadonly | isReactive()、reactive() 的 readonly(reactive) 特判 |
IS_READONLY | __v_isReadonly | "这是 readonly 代理" | 由 get 陷阱动态返回 isReadonly | isReadonly()、ref.ts 的 isReadonly(value) 分支 |
IS_SHALLOW | __v_isShallow | "这是浅代理 / 浅 ref" | 代理由 get 动态返回;RefImpl 构造函数里真实写入 this[IS_SHALLOW] | isShallow()、createIterableMethod、ObjectRefImpl |
RAW | __v_raw | "拿回原始对象" | 由 get 陷阱动态返回 target(带条件判断,见 3.3) | toRaw()、isProxy()、trigger 里的 toRaw(receiver) 比较、ObjectRefImpl |
IS_REF | __v_isRef | "这是 ref" | RefImpl / CustomRefImpl / ObjectRefImpl / GetterRefImpl 用类字段声明 | isRef()、BaseReactiveHandler.get 里的自动解包 |
必须区分两种存在形式:
- 真实字段(存在对象自身上):
SKIP(markRaw写入)、IS_REF(各种 ref 类的字段)、IS_SHALLOW(RefImpl构造函数写入)、以及Dep类的readonly __v_skip = true(dep.ts:99,让Dep实例本身不被代理)。 - 虚拟字段(不在对象上存在,靠
get陷阱动态应答):IS_REACTIVE/IS_READONLY/IS_SHALLOW/RAW在代理对象上被访问时,均由BaseReactiveHandler.get提前拦截返回(baseHandlers.ts:60-85);而在原始对象上访问这些 key,只会返回undefined(除非原始对象恰好有同名属性,所以用__v_前缀降低碰撞概率)。
由此得到一个重要性质:探针"零成本"——isReactive(x) 不需遍历、不查 WeakMap,就是一次 x['__v_isReactive'] 属性访问。对代理而言它命中 get 陷阱第一个分支、在 track() 前就 return,不产生依赖收集,所以 isReactive 可标记 /*@__NO_SIDE_EFFECTS__*/。
3.1.3 IS_REACTIVE / IS_READONLY / IS_SHALLOW 的求值分支
// packages/reactivity/src/baseHandlers.ts:55-65
get(target: Target, key: string | symbol, receiver: object): any {
if (key === ReactiveFlags.SKIP) return target[ReactiveFlags.SKIP]
const isReadonly = this._isReadonly,
isShallow = this._isShallow
if (key === ReactiveFlags.IS_REACTIVE) {
return !isReadonly
} else if (key === ReactiveFlags.IS_READONLY) {
return isReadonly
} else if (key === ReactiveFlags.IS_SHALLOW) {
return isShallow
}
// ...
}
注意三点:
SKIP分支直接透传target[SKIP]而非返回true:SKIP真实值可能是true(被markRaw)也可能undefined,若一律返回true,markRaw(proxy)的判断就会被污染。IS_REACTIVE返回!isReadonly。也就是mutable 代理(含shallowReactive)答true,readonly 代理(含shallowReadonly)答false。所以isReactive(readonly(x))不能只看这一层——readonly.ts的isReactive会先判断isReadonly,再对value[RAW]递归(reactive.ts:327-332)。IS_SHALLOW返回this._isShallow。shallowReactive与shallowReadonly都答true。
由此可以画出判定矩阵(对代理对象本身求值):
| 对象 | __v_isReactive | __v_isReadonly | __v_isShallow | __v_raw |
|---|---|---|---|---|
reactive(obj) | true | false | false | obj |
shallowReactive(obj) | true | false | true | obj |
readonly(obj) | false | true | false | obj |
shallowReadonly(obj) | false | true | true | obj |
原始对象 obj | undefined | undefined | undefined | undefined |
3.1.4 RAW 分支:为什么需要那个"prototype 相等"的绕行
RAW 的处理是 get 里最微妙的一段:
// packages/reactivity/src/baseHandlers.ts:66-85
} else if (key === ReactiveFlags.RAW) {
if (
receiver ===
(isReadonly
? isShallow
? shallowReadonlyMap
: readonlyMap
: isShallow
? shallowReactiveMap
: reactiveMap
).get(target) ||
// receiver is not the reactive proxy, but has the same prototype
// this means the receiver is a user proxy of the reactive proxy
Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)
) {
return target
}
// early return undefined
return
}
它在防一种攻击性场景:用户自己再包了一层 Proxy。
const raw = {}
const observed = reactive(raw)
// 用户手动套一层 Proxy,并在 get 里把 receiver 透传给内层
const userProxy = new Proxy(observed, {
get(t, k, r) {
// 典型实现:Reflect.get(t, k, r)
return Reflect.get(t, k, r)
}
})
userProxy.__v_raw // 若不判 prototype,会返回 observed(代理),而不是 raw
receiver 是"实际调用陷阱的那个代理"。用户用自己的代理读 __v_raw 时,Reflect.get(observed, '__v_raw', userProxy) 传入 receiver = userProxy;此时 userProxy !== reactiveMap.get(raw),若不处理会 return undefined,toRaw(userProxy) 就拿不到原对象。
源码用 Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver) 兜底:target 是原始对象,receiver 是用户代理(prototype 通常也是 Object.prototype),两者相同即返回 target,于是 toRaw 能穿透用户代理拿到 raw。这是极少人踩到、一旦踩到却很难排查的边界,Vue 用一条廉价的原型比较兜住了。
3.1.5 isProxy 与三个判定函数的关系
// packages/reactivity/src/reactive.ts:363-365
export function isProxy(value: any): boolean {
return value ? !!value[ReactiveFlags.RAW] : false
}
isProxy 只读 RAW。它是三个判定里最宽的一个:任何 Vue 造的代理(reactive/readonly/浅变体)都答 true。而 isReactive / isReadonly 各自读 IS_REACTIVE / IS_READONLY,是更窄的判定。
它们之间的关系,可以用一段真实代码验证:
// reactive.ts:327-332
export function isReactive(value: unknown): boolean {
if (isReadonly(value)) {
return isReactive((value as Target)[ReactiveFlags.RAW])
}
return !!(value && (value as Target)[ReactiveFlags.IS_REACTIVE])
}
isReactive(readonly(reactive({}))) 的求值链:
isReadonly(x)→ 读x.__v_isReadonly→true;- 对
x.__v_raw(即内部 reactive 代理)递归; - 内层不是 readonly,读
__v_isReactive→true。
所以 readonly(reactive(obj)) 同时满足 isReactive === true 与 isReadonly === true。这就是文档注释里那组例子的来源(reactive.ts:308-321):
isReactive(readonly(reactive({}))) // => true
isReactive(ref({}).value) // => true(ref 内部 _value 是 toReactive 的)
isReactive(readonly(ref({})).value) // => true
isReactive(ref(true)) // => false(基础类型不代理)
isReactive(shallowRef({}).value) // => false(shallowRef 不 toReactive)
isReactive(shallowReactive({})) // => true
易错点:
isReadonly(ref(true))为false,但isReadonly(computed(() => 1))为true——只读computed(没有 setter)在computed.ts里被标记了IS_READONLY。判定函数读的是"行为标记",不是"类型"。
3.1.6 流转总览
原始对象 ──new Proxy──▶ 代理(__v_isReactive/__v_isReadonly/__v_isShallow 由 get 陷阱动态求值)
│ │
│ markRaw → def(__v_skip) ├─ toRaw / __v_raw → 回到原始对象
▼ ├─ reactive(代理) → 直接返回自身
永远不代理 └─ readonly(代理) → 允许再包一层(见 3.2.3)
3.2 reactive() 入口:createReactiveObject 的三次拦截
3.2.1 为什么 reactive 第一行是 isReadonly
// packages/reactivity/src/reactive.ts:85-99
export function reactive<T extends object>(target: T): Reactive<T>
/*@__NO_SIDE_EFFECTS__*/
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
if (isReadonly(target)) {
return target
}
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap,
)
}
第一行 isReadonly(target) 是一次 O(1) 探针:reactive(readonly(x)) 不会"升级"成可变代理,而是原样返回 readonly 代理。这是刻意的——若允许,会得到"外层可变、内层只读"的混合代理,破坏 readonly 契约,且 set 会先命中 outer 再打到 inner 的 readonly set(警告),行为混乱。所以 Vue 选择"readonly 优先":只读就是只读,不可反悔。它与 createReactiveObject 里那条 !(isReadonly && target[IS_REACTIVE]) 相互配合:readonly(reactive(obj)) 允许,reactive(readonly(obj)) 不允许。
3.2.2 校验一:isObject
// packages/reactivity/src/reactive.ts:269-278
if (!isObject(target)) {
if (__DEV__) {
warn(
`value cannot be made ${isReadonly ? 'readonly' : 'reactive'}: ${String(
target,
)}`,
)
}
return target
}
isObject 的定义(packages/shared/src/general.ts:53-54):
export const isObject = (val: unknown): val is Record<any, any> =>
val !== null && typeof val === 'object'
注意它只收 typeof === 'object':reactive(1)/reactive('a')/reactive(true)/reactive(Symbol())/reactive(1n)/reactive(null)/reactive(undefined) 全部被拒,DEV 下 warn 并返回原值;reactive(function(){}) 也被拒,因为 typeof function === 'function'——函数不能被 reactive,需要观察函数对象属性时得换 ref 或绕开。
返回值是 target 本身而非 undefined:reactive(0) 返回 0,调用方可安全继续用返回值,不会拿到 undefined 造成连锁 TypeError。这是"宽容式降级"——DEV 提示、生产静默降级。
3.2.3 校验二:已是代理则直返(唯一的例外)
// packages/reactivity/src/reactive.ts:279-286
// target is already a Proxy, return it.
// exception: calling readonly() on a reactive object
if (
target[ReactiveFlags.RAW] &&
!(isReadonly && target[ReactiveFlags.IS_REACTIVE])
) {
return target
}
这段代码处理四种组合:
| 调用 | target[RAW] | target[IS_REACTIVE] | 是否进入分支并返回自身 |
|---|---|---|---|
reactive(plainObj) | 假值 | — | 否,继续代理 |
reactive(reactiveObj) | 代理 | — | 是,直接返回原代理 |
reactive(readonlyObj) | 代理 | false | 是,直接返回(配合 3.2.1 的双保险) |
readonly(plainObj) | 假值 | — | 否,继续代理 |
readonly(reactiveObj) | 代理 | true | 否,继续走 readonly 代理 |
readonly(readonlyObj) | 代理 | false | 是,直接返回 |
由此保证 reactive 的幂等:reactive(reactive(x)) === reactive(x)——引用相等级别的幂等(同一个 Proxy 对象),而非值相等。
readonly(reactive(x)) 必须放行,因为 readonly 是"再包一层只读外壳",语义上合法且必要(如把可变 store 暴露成只读 API)。它走新代理 + readonlyMap,与原 reactiveMap 是两张表,互不冲突。
RAW读取顺序:target[RAW]若是原始对象,__v_raw返回undefined(除非用户自定义同名属性)——这就是"探针零成本":一次属性查找而已。
3.2.4 校验三:SKIP 与不可扩展对象
// packages/reactivity/src/reactive.ts:288-290
// only specific value types can be observed.
if (target[ReactiveFlags.SKIP] || !Object.isExtensible(target)) {
return target
}
两个拒绝条件:① target[SKIP] 为真——被 markRaw 标记过(def() 挂不可枚举、不可写 writable=false、可配置的 __v_skip: true);② !Object.isExtensible(target)——被 Object.freeze/preventExtensions/seal 过,这类对象在严格模式下赋值会抛错,代理它没有意义(甚至危险)。
// packages/reactivity/src/reactive.ts:420-425
export function markRaw<T extends object>(value: T): Raw<T> {
if (!hasOwn(value, ReactiveFlags.SKIP) && Object.isExtensible(value)) {
def(value, ReactiveFlags.SKIP, true)
}
return value
}
markRaw 也做了幂等保护:若已有自有 SKIP 或对象不可扩展则不动它;def 默认 writable=false 让 __v_skip 无法被意外覆盖。
易错点:
markRaw只对它自己生效。markRaw(foo)后再reactive(foo)得到foo本身;markRaw 对象作为已代理对象的嵌套值时也不会被代理——深层递归时reactive(child)命中SKIP返回child(即reactive.ts:398-418的bar.foo例子)。
3.2.5 proxyMap 缓存:WeakMap 的三重收益
// packages/reactivity/src/reactive.ts:26-35
export const reactiveMap: WeakMap<Target, any> = new WeakMap<Target, any>()
export const shallowReactiveMap: WeakMap<Target, any> = new WeakMap<Target, any>()
export const readonlyMap: WeakMap<Target, any> = new WeakMap<Target, any>()
export const shallowReadonlyMap: WeakMap<Target, any> = new WeakMap<Target, any>()
// packages/reactivity/src/reactive.ts:291-295
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
四张缓存表各对应一个变体。用 WeakMap 而非 Map 有三重收益:
- 身份一致性:同一 raw 对象在同一变体下的代理全局唯一,
reactive(a) === reactive(a)恒为true,即使两次调用发生在不同组件、不同模块。 - 避免重复代理开销:
new Proxy相对昂贵(要建立元对象协议),缓存把它降到一次WeakMap.get。 - 不阻止 GC:
WeakMap键是弱引用,raw 无其他引用时代理与 raw 一起回收、缓存条目自动消失;若用Map,被代理过的对象将永不释放,是灾难性泄漏源。
性能账本:稳态
reactive(obj)= 一次isReadonly探针 + 一次RAW探针 + 一次SKIP探针 + 一次isExtensible+ 一次toRawType(Object.prototype.toString)+ 一次WeakMap.get,全部 O(1) 无分配,只有首次才new Proxy。
3.2.6 targetTypeMap:把 rawType 映射到四种代理分类
// packages/reactivity/src/reactive.ts:37-56
enum TargetType {
INVALID = 0,
COMMON = 1,
COLLECTION = 2,
}
function targetTypeMap(rawType: string) {
switch (rawType) {
case 'Object':
case 'Array':
return TargetType.COMMON
case 'Map':
case 'Set':
case 'WeakMap':
case 'WeakSet':
return TargetType.COLLECTION
default:
return TargetType.INVALID
}
}
toRawType 来自 packages/shared/src/general.ts:69-72,实现是 Object.prototype.toString.call(value).slice(8, -1),即把 [object Map] 截成 Map。这个 slice(8, -1) 是跨 realm 安全的:不像 instanceof 会被 iframe / vm 沙箱的原型链割裂,toString 走的是引擎内部的 [[Class]]。
三分类的后果:
- COMMON(
Object、Array):普通对象与数组,用baseHandlers那套get/set/has/ownKeys/deleteProperty陷阱。它们的共同点是"key 是属性名/索引",可以按 key 建 dep。 - COLLECTION(
Map/Set/WeakMap/WeakSet):用collectionHandlers,因为原生集合的**内部槽(internal slot)**不受[[Get]]/[[Set]]控制。例如map.size不是普通属性,而是 getter;map.get(k)无法通过get陷阱拦截"k → 值"的关系。详见 3.9。 - INVALID(
Date、RegExp、Promise、Error、Function、Window、DOM 节点……):不代理,返回原对象。
Promise 归入 INVALID 很关键——如果代理一个 Promise,await 时会遍历 thenable 协议,代理层会带来诡异的行为;而且 Promise 内部状态变化不通过属性访问暴露。Date/RegExp 同理:它们是"内部槽驱动"的,代理后调用方法会因为 this 不是真 Date 而抛 TypeError。
边界:
Object.create(null)创建的无原型对象,toRawType仍返回Object(因为toString不依赖原型链),所以会被代理。但switch里的rawType匹配的是'Object'字符串,没问题。若原始对象有自定义Symbol.toStringTag,toRawType会返回自定义值,从而落到 INVALID——这是可被"污染"的,但正常业务代码不会这么干。
3.2.7 创建与缓存
// packages/reactivity/src/reactive.ts:296-306
const targetType = targetTypeMap(toRawType(target))
if (targetType === TargetType.INVALID) {
return target
}
const proxy = new Proxy(
target,
targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
)
proxyMap.set(target, proxy)
return proxy
四个函数共用这一份实现,只是入参不同(isReadonly、baseHandlers、collectionHandlers、proxyMap)。这就是"四变体、一实现"的核心成就:校验逻辑只写一次,变体差异全部下沉到 Handler。
对照 reactive.ts 四个导出函数的入参:
| 函数 | isReadonly | baseHandlers | collectionHandlers | proxyMap | 行号 |
|---|---|---|---|---|---|
reactive | false | mutableHandlers | mutableCollectionHandlers | reactiveMap | 92-98 |
shallowReactive | false | shallowReactiveHandlers | shallowCollectionHandlers | shallowReactiveMap | 146-152 |
readonly | true | readonlyHandlers | readonlyCollectionHandlers | readonlyMap | 212-218 |
shallowReadonly | true | shallowReadonlyHandlers | shallowReadonlyCollectionHandlers | shallowReadonlyMap | 253-259 |
3.2.8 完整走读:reactive({ a: 1 })
调用 reactive({a:1})
├─ isReadonly({a:1}) → 访问 __v_isReadonly → undefined → false,不早退
├─ 进入 createReactiveObject(target, false, mutableHandlers, mutableCollectionHandlers, reactiveMap)
│ ├─ isObject({a:1}) → true,过
│ ├─ target[RAW] → undefined → 假,过
│ ├─ target[SKIP] → undefined;Object.isExtensible → true;过
│ ├─ reactiveMap.get({a:1}) → undefined,未缓存
│ ├─ targetTypeMap(toRawType({a:1})) → toRawType = 'Object' → COMMON
│ ├─ new Proxy({a:1}, mutableHandlers) ← 单例 handler
│ ├─ reactiveMap.set({a:1}, proxy)
│ └─ return proxy
后续任意位置再调 reactive(sameObj):reactiveMap.get 命中,直接返回同一个 proxy,O(1)。
3.3 mutableHandlers 逐陷阱剖析
get 陷阱是整章复杂度最高的函数(baseHandlers.ts:55-134,共 80 行)。它同时承担了六件事:探针应答、数组方法注入、依赖收集、浅层短路、ref 解包、嵌套懒代理。分支顺序不能随意调换,因为后面的分支依赖前面的判断结果。下面按执行顺序逐段拆。
3.3.1 分支顺序总览
get(target,key,receiver)
①探针 SKIP/IS_REACTIVE/IS_READONLY/IS_SHALLOW/RAW ─命中─▶ 直接应答(不 track、不递归)
②!isReadonly 且数组且命中 instrumentation ─命中─▶ 返回 instrumentation 函数
③res = Reflect.get(target, key, isRef(target) ? target : receiver)
④内建 symbol / __proto__ / __v_isRef / __isVue ─命中─▶ return res(不 track)
⑤!isReadonly && track(target, GET, key)
⑥isShallow ─命中─▶ return res(不递归、不解包)
⑦isRef(res) ─命中─▶ 数组+整数索引保留 ref;否则 res.value(readonly 再套 readonly)
⑧isObject(res) ─命中─▶ isReadonly ? readonly(res) : reactive(res);否则 return res
顺序不可交换:探针 key 在 target 上通常不存在,必须先于 Reflect.get;数组方法注入必须先于 Reflect.get(否则拿到原生方法);track 必须在 ref 解包与递归之前(否则解包/递归会引入额外依赖)。
3.3.2 第一段:探针与 RAW(55-85 行)
已在 3.1 详述。这里补充一个顺序意义:探针分支(SKIP / 三个 IS_* / RAW)必须在 targetIsArray 和 Reflect.get 之前。原因是:这些 key 在 target 上通常不存在,若先走 Reflect.get 会拿到 undefined,探针就失效了。它们本质是"虚拟属性",只能由陷阱合成。
另一个细节:SKIP 分支放在最前,且不看 _isReadonly / _isShallow。因为 SKIP 是"物理标记",与变体无关。
3.3.3 第二段:数组方法注入(87-97 行)
// packages/reactivity/src/baseHandlers.ts:87-97
const targetIsArray = isArray(target)
if (!isReadonly) {
let fn: Function | undefined
if (targetIsArray && (fn = arrayInstrumentations[key])) {
return fn
}
if (key === 'hasOwnProperty') {
return hasOwnProperty
}
}
两点必须注意:
-
!isReadonly守卫:只读数组不注入任何 instrumentation。于是readonly([1,2]).push(3)的链条是:get('push')因守卫为假而跳过注入 → 返回原生Array.prototype.push→ 原生push在this(readonly 代理)上做[[Set]]/[[DefineOwnProperty]]→set陷阱拦到并 DEV 警告。即只读数组的写入仍会发出 "target is readonly" 警告,只是来自set陷阱而非 instrumentation;也正因不注入,它不会获得ARRAY_ITERATE_KEY级迭代追踪。 -
arrayInstrumentations[key]是真值判断:if (fn)才注入。arrayInstrumentations用__proto__: null声明(arrayInstrumentations.ts:53),所以'constructor'/'toString'等不会命中原型链,只有真正定义的实例方法才命中,防止原型污染导致的误注入。
// packages/reactivity/src/arrayInstrumentations.ts:52-53
export const arrayInstrumentations: Record<string | symbol, Function> = <any>{
__proto__: null,
// ...
关于 hasOwnProperty 的包装:
// packages/reactivity/src/baseHandlers.ts:41-47
function hasOwnProperty(this: object, key: unknown) {
// #10455 hasOwnProperty may be called with non-string values
if (!isSymbol(key)) key = String(key)
const obj = toRaw(this)
track(obj, TrackOpTypes.HAS, key)
return obj.hasOwnProperty(key as string)
}
为什么单独包装 hasOwnProperty?两个原因:
- 收集
HAS依赖:obj.hasOwnProperty(k)语义上等价于询问"k 存不存在",与'k' in obj是同类操作。当新增/删除 key 时,trigger(ADD/DELETE)会通知ITERATE_KEY,而HAS依赖键是k本身。这里用TrackOpTypes.HAS而非ITERATE,意味着它只对具体 key 的新增/删除敏感,不对整体结构变化敏感——这是合理的(问hasOwnProperty('a')只需要 a 的变化)。 toRaw(this):this可能是代理,toRaw拿回 raw 再调原生hasOwnProperty,避免在代理上调用时又触发一轮陷阱递归。
3.3.4 第三段:Reflect.get 与 receiver 的两种身份(99-106 行)
// packages/reactivity/src/baseHandlers.ts:99-106
const res = Reflect.get(
target,
key,
// if this is a proxy wrapping a ref, return methods using the raw ref
// as receiver so that we don't have to call `toRaw` on the ref in all
// its class methods
isRef(target) ? target : receiver,
)
receiver 是"这次属性访问的 this"。普通对象代理传 receiver(代理本身);若 target 是 ref(isRef(target) 为真)则传 target。原因是 Vue 内部确实会代理 ref 对象:reactive({ foo: ref(1) }) 递归时会把 foo 的 RefImpl 包成代理。若 receiver 传代理,RefImpl 的 class getter 里 this 就是代理,this.dep.track() 会再穿一层陷阱(多余开销甚至行为偏差);传 target 让 this 直接命中 RefImpl 实例字段。注释 so that we don't have to call toRaw on the ref in all its class methods 正是此意。
深入
Reflect.getvstarget[key]:Reflect.get与target[key]的差别在于能显式指定 receiver,且返回布尔失败(虽然Reflect.get不返回布尔)。用Reflect.get(target, key, receiver)而非target[key]是为了正确支持继承链上的 getter——若key定义在原型上(如 class getter),receiver 必须是最外层代理,this才会指向代理,从而在 getter 内部访问this.xxx时也能触发追踪。这是Vue 3支持 class 实例 / 原型 getter 响应式的关键。
3.3.5 第四段:不可追踪 key 过滤(108-110 行)
// packages/reactivity/src/baseHandlers.ts:28-39
const isNonTrackableKeys = /*@__PURE__*/ makeMap(`__proto__,__v_isRef,__isVue`)
const builtInSymbols = new Set(
Object.getOwnPropertyNames(Symbol)
.filter(key => key !== 'arguments' && key !== 'caller')
.map(key => Symbol[key as keyof SymbolConstructor])
.filter(isSymbol),
)
// packages/reactivity/src/baseHandlers.ts:108-110
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res
}
三类 key 被排除追踪:
- 内建 Symbol(
Symbol.iterator、Symbol.toPrimitive、Symbol.toStringTag……)。访问它们不会track。原因:这些 symbol 通常是"元操作",每个对象都一样,追踪它们会造成巨大的依赖爆炸且毫无意义。源码在构造builtInSymbols时特意过滤了'arguments'与'caller',注释解释:iOS 10.x 的Object.getOwnPropertyNames(Symbol)会枚举出这两个,但在Symbol(严格模式函数)上访问它们会抛TypeError(baseHandlers.ts:30-39)。 __proto__:访问原型不该触发更新。__v_isRef:这是 ref 解包的判据,若被追踪会导致解包时产生不必要的依赖。__isVue:Vue 实例的身份标记(runtime-core里给组件实例挂的),避免组件实例被当数据追踪。
makeMap 的实现(packages/shared/src/makeMap.ts)是把字符串切分成 Set,返回一个 key => boolean 的闭包,比每次 indexOf 更快。
builtInSymbols.has(key) 只对 symbol 求值,isNonTrackableKeys(key) 只对 string 求值。三元分支避免了 makeMap 拿到 symbol 时 Set.has(symbol) 的失配(Set 可以存 symbol,但字符串集合不含 symbol,返回 false,安全但多一次调用)。
3.3.6 第五段:track 与 shallow 短路(112-118 行)
// packages/reactivity/src/baseHandlers.ts:112-118
if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}
if (isShallow) {
return res
}
!isReadonly才 track:readonly 代理在这一层不做依赖收集。这符合语义——只读代理本身不会变,追踪"我读过它"没有意义。但这里有一个非常容易被误解的边界:readonly(reactive(obj))的外层不 track,读到的东西是不是就永远不响应了?答案是仍然响应,原因是Reflect.get的 receiver 传递机制:
const raw = { n: 1 }
const r = readonly(reactive(raw)) // 外层 readonly,内层 mutable
// 读取 r.n 时的调用链:
// readonly 代理 get('n') → !isReadonly 为真 → 不 track
// Reflect.get(reactiveProxy, 'n', receiver = readonlyProxy)
// → 触发内层 reactive 代理的 get('n')
// → 内层是 mutable → track(raw, GET, 'n') ← 依赖在这里被收集
结论:watchEffect(() => console.log(r.n)) 会追踪到 raw.n,raw.n++ 能触发该 effect。README 里 reactive.ts:187-202 那段 readonly(reactive(original)) 的注释例子("mutating original will trigger watchers relying on the copy")验证的正是这个行为。
记住这个机制:嵌套代理的 get 会沿着 [[Get]] 链层层下探,每一层(若为 mutable)都可能 track。readonly 只是"这一层不做",不代表整条链都不做。
isShallow短路:shallowReactive/shallowReadonly在这个点 return,不做 ref 解包,也不做嵌套递归代理。这就是"浅"的全部含义:根层属性访问照样track(因为!isReadonly时已 track 过),但返回的值是原样 raw 值。
易错点:
shallowReactive的根属性是可追踪的,只是值不再被代理。所以state.nested.bar++不响应(nested 是 raw 对象),但state.count++响应。
3.3.7 第六段:ref 解包(120-124 行)
// packages/reactivity/src/baseHandlers.ts:120-124
if (isRef(res)) {
// ref unwrapping - skip unwrap for Array + integer key.
const value = targetIsArray && isIntegerKey(key) ? res : res.value
return isReadonly && isObject(value) ? readonly(value) : value
}
这是"自动解包"的运行时实现核心。逐分支:
targetIsArray && isIntegerKey(key)→ 不解包,返回 ref 本身。原因见 3.5 与 3.7:数组元素若是 ref,arr[0]应当返回 ref 本身,否则map/filter里传入的元素会被解包成值,导致toRefs场景下丢失 ref 语义。isIntegerKey的实现(packages/shared/src/general.ts:77-81):
export const isIntegerKey = (key: unknown): boolean =>
isString(key) &&
key !== 'NaN' &&
key[0] !== '-' &&
'' + parseInt(key, 10) === key
用 "String(parseInt(key)) === key" 来判定——'01' 不是(parseInt('01')=1,'1' !== '01'),'1.0' 不是,'-1' 不是,'1' 是。注意 key[0] !== '-' 与 key !== 'NaN' 的显式排除,加上 isString 保证 key 是字符串(数组索引本来就是字符串形式)。
-
res.value:触发RefImpl.get value,即对 ref 自身的dep.track()。这一步让"computed ref 被访问"能建立依赖。 -
readonly 情况下对解包结果再包一层 readonly:
isReadonly && isObject(value) ? readonly(value) : value。理由:ref 的.value可能是对象,而 readonly 语义要求深层只读。不加这层,readonly 对象里嵌的 ref 解包后会泄漏一个可变对象引用。注释"the unwrapped values will also be made readonly"(reactive.ts:185)就是这个。
性能提示:
res.value只对 ref 触发;对普通对象不值一提。但isRef(res)本身是一次属性访问res[__v_isRef],对每个 get 都要执行一次。这是每次属性读取的固定成本之一。
3.3.8 第七段:isObject 懒递归(126-133 行)
// packages/reactivity/src/baseHandlers.ts:126-133
if (isObject(res)) {
// Convert returned value into a proxy as well. we do the isObject check
// here to avoid invalid value warning. Also need to lazy access readonly
// and reactive here to avoid circular dependency.
return isReadonly ? readonly(res) : reactive(res)
}
return res
"懒深层"的实现就在这里:只有当一个对象的属性值被读取且它是对象时,才递归包代理。这让"深层响应式"的初始成本与对象的形状无关,只与被实际访问的路径有关。
注释里两个理由很关键:
- 提前
isObject检查避免无效警告:如果对基础类型调用reactive,createReactiveObject会 warn。在陷阱里先判isObject,就不会因为res = 1而触发无谓的 DEV 警告。 readonly/reactive的懒引用避免循环依赖:baseHandlers.ts顶部从./reactive导入了reactive/readonly/toRaw等,而reactive.ts又导入baseHandlers,这是 ESM 循环依赖。由于调用发生在运行时(函数体内部),而非模块顶层,循环依赖得以安全解开。
性能账本:每次 get 一次的判断链是
isRef(res)→isObject(res)。前者是一次__v_isRef属性读,后者是typeof。两者都是极廉价的原生操作。真正昂贵的是递归创建代理,而它被缓存(proxyMap)与本源判据(isObject)共同限制了次数:同一个嵌套对象第一次访问建代理,之后命中缓存。
3.3.9 set 陷阱:三条前置归一化(142-172 行)
// packages/reactivity/src/baseHandlers.ts:142-172
set(
target: Record<string | symbol, unknown>,
key: string | symbol,
value: unknown,
receiver: object,
): boolean {
let oldValue = target[key]
const isArrayWithIntegerKey = isArray(target) && isIntegerKey(key)
if (!this._isShallow) {
const isOldValueReadonly = isReadonly(oldValue)
if (!isShallow(value) && !isReadonly(value)) {
oldValue = toRaw(oldValue)
value = toRaw(value)
}
if (!isArrayWithIntegerKey && isRef(oldValue) && !isRef(value)) {
if (isOldValueReadonly) {
if (__DEV__) {
warn(
`Set operation on key "${String(key)}" failed: target is readonly.`,
target[key],
)
}
return true
} else {
oldValue.value = value
return true
}
}
} else {
// in shallow mode, objects are set as-is regardless of reactive or not
}
// ...
}
第一步:oldValue = target[key]。注意这里是直接在 target(raw)上读,不经过 this 的 get 陷阱——target 是 raw,target[key] 是原生属性读,不会 track。这一步也很关键:oldValue 用于后面的 hasChanged 比较,必须是 raw,否则"代理 vs raw"的引用不等会导致假变更(每次都 trigger)。
第二步:isArrayWithIntegerKey。数组 + 整数索引的组合在 set 里处处特殊对待。
第三步:mutable 深代理下的归一化——if (!isShallow(value) && !isReadonly(value)) 时 oldValue = toRaw(oldValue); value = toRaw(value),把新旧值都转成 raw,让 hasChanged 比较"原始对象身份"而非代理身份,避免"同一对象的代理与 raw 被判为不同"。之所以还要判 isShallow(value)/isReadonly(value),是因为浅值与只读代理不该被 toRaw——它们的语义就是保留代理身份,只归一化"普通深代理"才精确。
ref 委托:!isArrayWithIntegerKey && isRef(oldValue) && !isRef(value) 时写 oldValue.value = value 而非替换整个属性——这就是"state.count = 2 在 state.count 是 ref 时等价于 state.count.value = 2"的原因,也是 v-model 作用于 ref 属性且写回不破坏 ref 身份的关键。若老 ref 是只读(isOldValueReadonly)→ DEV 警告 + return true(假装成功、不写入);return true 而非 false 是为了避免严格模式下 [[Set]] 失败触发 TypeError,同时保持"静默失败"。
第四步(shallow 分支):注释一句话说清——objects are set as-is regardless of reactive or not;浅代理直接存原值,不 toRaw、不委托 ref。
3.3.10 set 陷阱:写入 + trigger 的 ADD/SET 区分(174-191 行)
// packages/reactivity/src/baseHandlers.ts:174-191
const hadKey = isArrayWithIntegerKey
? Number(key) < target.length
: hasOwn(target, key)
const result = Reflect.set(
target,
key,
value,
isRef(target) ? target : receiver,
)
// don't trigger if target is something up in the prototype chain of original
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
hadKey 的两种判定:
- 数组整数索引:
Number(key) < target.length,即"这个索引是否在现有范围内"。这是数组语义的ADD定义:arr[3] = x对长度 3 的数组是 ADD(扩展),对长度 5 的数组是 SET。 - 其他:
hasOwn(target, key)。用hasOwn(自有属性)而非in,避免原型链上的属性被误判为已存在。
Reflect.set 的 receiver:与 get 同理,isRef(target) 时传 target,让 RefImpl 的 setter 以真实例为 this。
target === toRaw(receiver) 守卫:防"原型链上的 setter 重复触发"。设 obj 继承自 proto 且 proto 上有个 accessor,代理 obj 后 obj.x = 1 时 [[Set]] 会走到 proto 的 setter,其 this(receiver)是代理;Reflect.set 可能把写入落到 receiver(代理)上再次触发 set 陷阱 → 双重 trigger 甚至无限递归。守卫的作用:只有 receiver 是"这个 target 的直接代理"(toRaw(receiver) === target)才 trigger;receiver 是更上层代理或别的对象时说明写入不是针对本代理的真实语义,跳过。注释 don't trigger if target is something up in the prototype chain of original 精确描述。
ADD / SET 区分:
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
- 新增(
ADD):无条件 trigger(不存在"值没变"一说,因为 key 从无到有); - 存在(
SET):只有hasChanged(value, oldValue)才 trigger。hasChanged(packages/shared/src/general.ts:143-144)是!Object.is(value, oldValue)——用Object.is而非===,正确处理NaN(NaN !== NaN但Object.is(NaN, NaN) === true,所以给NaN赋NaN不触发,符合直觉)与+0/-0。
ADD 与 SET 在 dep.ts:359-384 的 trigger() 里有不同副作用(对 ITERATE_KEY / MAP_KEY_ITERATE_KEY 的通知),详见 3.10。
3.3.11 deleteProperty / has / ownKeys
// packages/reactivity/src/baseHandlers.ts:194-222
deleteProperty(
target: Record<string | symbol, unknown>,
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(target: Record<string | symbol, unknown>, key: string | symbol): boolean {
const result = Reflect.has(target, key)
if (!isSymbol(key) || !builtInSymbols.has(key)) {
track(target, TrackOpTypes.HAS, key)
}
return result
}
ownKeys(target: Record<string | symbol, unknown>): (string | symbol)[] {
track(
target,
TrackOpTypes.ITERATE,
isArray(target) ? 'length' : ITERATE_KEY,
)
return Reflect.ownKeys(target)
}
deleteProperty:先记hadKey与oldValue,删除成功且原本存在才 trigger。删一个不存在的 key 不该通知任何 effect。DeleteOpTypes.DELETE对ITERATE_KEY的副作用在trigger里。has:'k' in proxy的路径。返回Reflect.has(含原型链),track 用的是TrackOpTypes.HAS。内建 Symbol 的in不 track。注意has陷阱只 track,不递归代理、不解包——in只关心 key 在不在,不关心值。ownKeys:Object.keys/for...in/getOwnPropertyNames的路径。数组 track'length',对象 trackITERATE_KEY。为什么数组是'length'?因为数组的枚举/结构变化本质由length驱动(push/splice都会改length),而trigger里的length分支(dep.ts:336-346)会额外通知所有key >= newLength的 dep 与ARRAY_ITERATE_KEY。对象则用ITERATE_KEY统一代表"整体结构变了"。
注意
ownKeys不处理只读:readonly 代理也会 track 迭代吗?不会——ReadonlyReactiveHandler只覆盖了set与deleteProperty,ownKeys继承自BaseReactiveHandler……但BaseReactiveHandler没有ownKeys。核对源码:ownKeys定义在MutableReactiveHandler(215-222)。所以 readonly 代理没有ownKeys陷阱,Object.keys(readonly(obj))会走引擎默认行为,不 track。这与get里!isReadonly不 track 是同一哲学:只读对象不收集依赖。
3.3.12 陷阱 → 行为对照总表
| 陷阱 | 读/写逻辑 | 依赖行为 | 关键边界 |
|---|---|---|---|
get | 探针 → 数组方法注入 → Reflect.get → 过滤 → track(GET,key) → 解包 → 递归 | track(GET, key);内建 symbol / 非可追踪 key 不 track;readonly 不 track | 数组+整数索引不解包 ref;shallow 短路 |
set | 归一化 → ref 委托 → Reflect.set → ADD/SET 判定 | 新增 ADD,存在且变 SET;target===toRaw(receiver) 守卫 | 数组整数索引用 Number(key)<length;旧只读 ref 静默失败 |
has | Reflect.has | track(HAS, key);内建 symbol 不 track | 只 track 不递归 |
deleteProperty | 记旧值 → Reflect.deleteProperty → 存在才 trigger | DELETE(含 ITERATE_KEY 副作用) | 删不存在的 key 不 trigger |
ownKeys | Reflect.ownKeys | 数组 track(ITERATE,'length');对象 track(ITERATE,ITERATE_KEY) | readonly 无此陷阱 |
hasOwnProperty(方法注入) | toRaw(this).hasOwnProperty(key) | track(HAS, key) | 非 symbol key 先 String() |
3.4 readonly / shallow 变体:与 mutable 的差异表
3.4.1 ReadonlyReactiveHandler:只覆盖两个陷阱
// packages/reactivity/src/baseHandlers.ts:225-249
class ReadonlyReactiveHandler extends BaseReactiveHandler {
constructor(isShallow = false) {
super(true, isShallow)
}
set(target: object, key: string | symbol) {
if (__DEV__) {
warn(
`Set operation on key "${String(key)}" failed: target is readonly.`,
target,
)
}
return true
}
deleteProperty(target: object, key: string | symbol) {
if (__DEV__) {
warn(
`Delete operation on key "${String(key)}" failed: target is readonly.`,
target,
)
}
return true
}
}
这是整个响应式系统里最"小"的一个类:只覆盖 set 与 deleteProperty,都只做 DEV 警告并返回 true。true 表示"操作成功"(欺骗调用方的 [[Set]]),实际不写入。
值得深思的两点:
return true而不是抛错。这决定了readonly对象上的写入是"静默失败 + DEV 警告",而不是运行时异常。这在生产环境是宽容的(不会因为一次意外写入白屏),在开发环境有警告提醒。- 没有
has/ownKeys/get覆盖。get用的是BaseReactiveHandler.get的只读分支(isReadonly=true):不 track、ref 解包时对对象结果再套readonly。has/ownKeys干脆没有——引擎默认行为。
3.4.2 shallowReactiveHandlers 的继承差异
shallowReactiveHandlers = new MutableReactiveHandler(true),_isShallow = true。它在 get 里于 track 之后直接 return(3.3.6),set 里跳过整个归一化块(3.3.9 的 else 空分支)。所以:
shallowReactive({ n: { a: 1 } }):state.n可 track,返回 raw 的{a:1};state.n.a = 2不触发(n 是 raw)。shallowReactive({ r: ref(1) }):state.r不自动解包,返回 ref 本身。这与reactive的行为不同!注释reactive.ts:118说properties with ref values will not be automatically unwrapped。
3.4.3 四变体差异总表
| 维度 | mutableHandlers | shallowReactiveHandlers | readonlyHandlers | shallowReadonlyHandlers |
|---|---|---|---|---|
_isReadonly | false | false | true | true |
_isShallow | false | true | false | true |
get:track | ✅ | ✅ | ❌ | ❌ |
get:ref 自动解包 | ✅ | ❌(短路) | ✅(再套 readonly) | ❌ |
get:嵌套递归代理 | ✅ | ❌ | ✅ | ❌ |
set:实际写入 | ✅ | ✅ | ❌(警告) | ❌(警告) |
set:ref 委托 | ✅ | ❌ | ❌ | ❌ |
set:toRaw 归一化 | ✅ | ❌ | ❌ | ❌ |
deleteProperty | 真实删除 | 真实删除 | 警告 | 警告 |
has | track(HAS) | track(HAS) | 无(引擎默认) | 无(引擎默认) |
ownKeys | track(ITERATE) | track(ITERATE) | 无 | 无 |
IS_REACTIVE | true | true | false | false |
IS_READONLY | false | false | true | true |
IS_SHALLOW | false | true | false | true |
表中
has/ownKeys的"无"是指ReadonlyReactiveHandler未覆盖、也未定义,走引擎默认行为(不 track)。而MutableReactiveHandler覆盖了这两个,会 track。
3.4.4 shallowReadonlyHandlers 与 props 的运行时契约
baseHandlers.ts:260-262 的注释点明了 shallowReadonlyHandlers 的真实用户:
// Props handlers are special in the sense that it should not unwrap top-level
// refs (in order to allow refs to be explicitly passed down), but should
// retain the reactivity of the normal readonly object.
runtime-core 的 componentProps.ts 用 shallowReadonly 包装 props,需同时满足两个约束:① 只读——子组件不能改 props;② 不自动解包顶层 ref——父组件传 ref 作为 prop 时,子组件应拿到"ref 本身"而非解包值,这样才能"父传 ref、子组件 .value 读写联动"。
若用 readonly(),get 会在 isRef(res) 分支把 prop 解包掉,子组件就丢了 ref 身份。所以必须是"只读 + 浅",而 shallowReadonly 恰好是唯一满足的变体(浅 → 不递归、不解包;只读 → 不写入)。这就是为什么它不只是"性能选项",而是有明确语义职责的 API——同时用于 props 与 defineModel。
3.5 数组的处理:索引、length 与浅数组
数组是 baseHandlers 里最特殊的分支。它在 get 里被注入 instrumentation(3.3.3),在 set 里对整数索引特殊判定(3.3.10),在 trigger 里有专门的 length 分支(dep.ts:336-346)。原因只有一个:数组的"结构变更"由 length 与索引共同表达,而普通对象的"结构变更"由 key 集合表达。
3.5.1 isIntegerKey:什么才算"数组索引"
// packages/shared/src/general.ts:77-81
export const isIntegerKey = (key: unknown): boolean =>
isString(key) &&
key !== 'NaN' &&
key[0] !== '-' &&
'' + parseInt(key, 10) === key
逐条件分析:
| 输入 | isString | !=='NaN' | key[0]!=='-' | String(parseInt)===key | 结果 |
|---|---|---|---|---|---|
'0' | ✓ | ✓ | ✓ | '0'==='0' | true |
'3' | ✓ | ✓ | ✓ | ✓ | true |
'01' | ✓ | ✓ | ✓ | '1'!=='01' | false |
'1.0' | ✓ | ✓ | ✓ | '1'!=='1.0' | false |
'-1' | ✓ | ✓ | ✗ | — | false |
'NaN' | ✓ | ✗ | — | — | false |
'foo' | ✓ | ✓ | ✓ | 'NaN'!=='foo' | false |
1(number) | ✗ | — | — | — | false |
关键点:key 在陷阱里始终是字符串(JS 规范里对象属性 key 要么 string 要么 symbol,数组索引访问会被转成字符串 '0'、'1'……)。所以 isIntegerKey 首先要求 isString。
这个函数也是"数组索引越界"判定的前提。所谓"越界",就是 Number(key) >= target.length。源码不做 DEV 警告——越界索引写入是合法的 ADD(会把 length 撑大)。若要在框架层看到行为上的"警告",只有一种:对一个只读数组写越界索引,会命中 ReadonlyReactiveHandler.set 的 warn('Set operation on key "N" failed: target is readonly.')(baseHandlers.ts:230-238)——警告里带的是索引字符串本身。
易错点:
arr['01'] = 1不触发响应式!因为'01'不满足isIntegerKey,会被当作普通字符串属性走hasOwn(target, key)判定。它既不是数组索引、也不影响length,虽然Object.keys能枚举到(ownKeys 会 track'length'),但对'01'这个 key 的 dep 不会被有效建立。同理arr['1.0']。用数字索引时确保 key 是规范化的整数字符串。
3.5.2 越界索引 = 新增:ADD 语义与 length 联动
set 陷阱里对数组的判断(baseHandlers.ts:174-176):
const hadKey = isArrayWithIntegerKey
? Number(key) < target.length
: hasOwn(target, key)
举例,长度为 3 的数组:
| 操作 | Number(key) < length | hadKey | type | 副作用 |
|---|---|---|---|---|
arr[0] = x | 0 < 3 ✓ | true | SET(值变才触发) | 无结构变更 |
arr[2] = x | 2 < 3 ✓ | true | SET | 无 |
arr[3] = x | 3 < 3 ✗ | false | ADD | trigger 里额外通知 'length' 依赖 |
arr[10] = x | 10 < 3 ✗ | false | ADD | 同上;数组变稀疏,length 直接变 11 |
对应到 trigger(dep.ts:359-370)的 ADD 分支:
// packages/reactivity/src/dep.ts:359-370
case TriggerOpTypes.ADD:
if (!targetIsArray) {
run(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
run(depsMap.get(MAP_KEY_ITERATE_KEY))
}
} else if (isArrayIndex) {
// new index added to array -> length changes
run(depsMap.get('length'))
}
break
数组的 ADD 不通知 ITERATE_KEY(数组不使用 ITERATE_KEY 这套对象结构键),而是通知 'length'——因为新增索引必然改变 length。完整链路是:arr[3] = x → ADD → run(depsMap.get('length')) → 触发所有依赖 length 的 effect(v-for 迭代时通过 ownKeys track 的正是 'length',见 3.3.11)→ 重渲染。这条链严密且开销固定。
注意
isArrayIndex的定义(dep.ts:334):const isArrayIndex = targetIsArray && isIntegerKey(key)。所以若用arr['01'] = x,isArrayIndex为 false,ADD分支走!targetIsArray为假(targetIsArray 是 true)→else if (isArrayIndex)也为假 → 什么都不通知。这再次说明非规范索引写数组是个"响应式黑洞"。
3.5.3 length 的特殊处理
数组 length 是一个非 index 但极其特殊的属性。写入 arr.length = 2 会截断数组,需要通知所有"被截掉"的索引依赖,以及 ARRAY_ITERATE_KEY:
// packages/reactivity/src/dep.ts:332-346
const targetIsArray = isArray(target)
const isArrayIndex = targetIsArray && isIntegerKey(key)
if (targetIsArray && key === 'length') {
const newLength = Number(newValue)
depsMap.forEach((dep, key) => {
if (
key === 'length' ||
key === ARRAY_ITERATE_KEY ||
(!isSymbol(key) && key >= newLength)
) {
run(dep)
}
})
} else {
// ...
}
逐条件:当 trigger 的 key 是 'length' 时,遍历整个 depsMap,只运行三类 dep:
key === 'length':直接依赖长度的 effect(如arr.length作为展示);key === ARRAY_ITERATE_KEY:各种 instrumented 迭代方法(map/filter/forEach/展开运算符等,见 3.8)收集的迭代依赖;!isSymbol(key) && key >= newLength:被截断的索引('2' >= 2、'3' >= 2……)。
第三类是最精妙的部分:key >= newLength 里的 key 是字符串,>= 会做字符串与数字比较——JS 规范里 '2' >= 2 会先把字符串转数字,比较结果是 true;'0' >= 2 是 false。于是"只有被删掉的索引的依赖者"被通知,未被删的索引的读者不被惊动。这正是 arr.length = 0 能精准通知所有元素读者、而 arr.length = 3(不变短)不该误伤的原因。
性能观察:
length的 trigger 会depsMap.forEach遍历该数组所有 dep。这是一个 O(deps) 操作,与数组长度无关,只与"被追踪过的 key 数"有关。对大数组频繁改length会略贵,但远比"通知所有元素"便宜。
3.5.4 稀疏数组与 holes
Vue 的 ownKeys / get 不特殊处理 holes。arr[0] 若不存在,Reflect.get 返回 undefined,但 track('0') 仍执行——"读不存在的索引"会建立对 '0' 的依赖,后续 arr[0] = x(ADD)会通知它,与普通对象读不存在 key 的行为一致。
forEach/map 类 instrumented 方法内部走原生实现,会跳过 holes,所以 [1, , 3].map(...) 与原生行为一致;instrumentation 只在原生结果上包装返回元素,不改变遍历语义。
3.5.5 "shallow" 数组
shallowReactive([...]) 得到的是 MutableReactiveHandler(true) 代理的数组。行为:
arr[0]会track(GET, '0')(因为!isReadonly);- 但
isShallow分支在解包/递归之前 return,元素对象不会被递归代理; - 元素若是 ref,不会自动解包(返回 ref 本身);
arr[0] = obj直接存 raw obj。
shallowRef([...]) 则是另一回事:RefImpl 的 isShallow=true,_value 与 _rawValue 都存原值,不 toReactive。于是 shallowRef([1]).value.push(2) 不会触发——因为数组本身不是代理。要触发得 triggerRef。这在处理"大型不可变数组"时是重要的优化手段:把数组整体作为不可变值,只在整体替换时更新。
3.6 ref 体系:RefImpl 字段与 get/set 逐行
3.6.1 RefImpl 的六个字段
// packages/reactivity/src/ref.ts:114-127
class RefImpl<T = any> {
_value: T
private _rawValue: T
dep: Dep = new Dep()
public readonly [ReactiveFlags.IS_REF] = true
public readonly [ReactiveFlags.IS_SHALLOW]: boolean = false
constructor(value: T, isShallow: boolean) {
this._rawValue = isShallow ? value : toRaw(value)
this._value = isShallow ? value : toReactive(value)
this[ReactiveFlags.IS_SHALLOW] = isShallow
}
| 字段 | 类型 | 语义 | 谁写 | 谁读 |
|---|---|---|---|---|
_value | T(public,@internal) | 对外暴露的值。深 ref 下是 toReactive(value) 的结果(可能是代理) | 构造函数、set value | get value 返回、triggerRef 的 debugInfo |
_rawValue | T(private) | 原始值。深 ref 下是 toRaw(value);用于 hasChanged 比较 | 构造函数、set value | set value 里的 oldValue |
dep | Dep | 该 ref 专属的依赖容器(每个 ref 一个,无共享) | 构造函数 new Dep() | get value 的 track、set value 的 trigger、triggerRef、ObjectRefImpl.dep |
__v_isRef | true | 真实字段,isRef 的判据 | 类字段初始化 | isRef()、baseHandlers 解包 |
__v_isShallow | boolean | 浅 ref 标记 | 构造函数最后一行 | isShallow()、set value 的 useDirectValue |
为什么 ref 需要自己的 dep,而不是共用 targetMap? 因为 ref 不是以属性访问被追踪的普通对象——它是"一个 .value 属性"。若把它塞进 targetMap,target 就是 RefImpl 实例、key 就是 'value'。Vue 选择让每个 RefImpl 直接持有 dep,省掉一次 targetMap.get(target) → depsMap.get('value') 的两级查找。这是一个典型的"用内存换速度"的取舍:每个 ref 多一个 Dep 对象,换来访问路径上少两次 Map 查找。
3.6.2 _rawValue 与 _value 分离的动机
深 ref 的 _value = toReactive(value),即 ref 的值若是对象,会被自动包成 reactive 代理。所以:
const r = ref({ n: 1 })
r.value.n++ // 触发(r.value 是 reactive 代理)
r.value.n // 读到 reactive 代理上的 n
那 _rawValue 存什么?存 toRaw(value),即原始对象。为什么要两份?
set value 里 hasChanged(newValue, oldValue) 的比较必须有稳定基准。若用 _value 比较:_value 是代理,而新进来的 newValue 也常是 toReactive 后的代理——两个代理若是同一对象(proxyMap 保证),Object.is 相等,OK。但如果用户传入的是 raw 对象,而 _value 是代理,Object.is(raw, proxy) 为 false,会被判定为"变了"(假变更)。用 _rawValue(两者都归一化为 raw)比较,才能正确判定"值真的变了没"。
// packages/reactivity/src/ref.ts:142-164
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._rawValue = newValue
this._value = useDirectValue ? newValue : toReactive(newValue)
if (__DEV__) {
this.dep.trigger({
target: this,
type: TriggerOpTypes.SET,
key: 'value',
newValue,
oldValue,
})
} else {
this.dep.trigger()
}
}
}
useDirectValue 的三个条件(与 set 陷阱归一化同源):① this[IS_SHALLOW] 浅 ref 直存;② isShallow(newValue) 传入的已是浅代理,不 toRaw/toReactive;③ isReadonly(newValue) 只读代理不能被 toReactive(reactive 拒绝 readonly,见 3.2.1)。任意一真则原样存,否则 _rawValue = toRaw(newValue)、_value = toReactive(newValue)。
DEV / PROD 分支:__DEV__ 时给 dep.trigger 传完整 debugInfo(target/type/key/newValue/oldValue)供 onTrigger 调试钩子;生产构建里这些参数被 tree-shake 掉,只调空参 trigger()——Vue 用"DEV 丰富、PROD 精简"换包体积的典型手法。
3.6.3 get value 与依赖收集
// packages/reactivity/src/ref.ts:129-140
get value() {
if (__DEV__) {
this.dep.track({
target: this,
type: TrackOpTypes.GET,
key: 'value',
})
} else {
this.dep.track()
}
return this._value
}
这是 ref 的"读路径":每次读 .value 都 track 一次。Dep.track(dep.ts:108-165)内部会:
- 若无
activeSub或shouldTrack为假 → 直接 return(不在 effect 上下文中,不收集); - 若
activeLink已是当前activeSub的链接且不是新单元格 → 复用并同步version(dep.ts:127-151)——这是 3.5 引入的"Link 复用 + 版本号"优化,避免每次 get 都新建Link对象; - 否则新建
Link,挂到activeSub.deps链表尾部,并addSub到 dep 的 subs 链表。
性能账本:一次
.value读 = 一次Dep.track,稳态下通常命中"复用已存在 Link + 同步 version"分支,只有链表指针操作与整数比较,无分配。这是 Vue 3.5 相比 3.4 的显著改进(3.4 每次都要从activeSub.deps里indexOf找 Link,O(n))。
3.6.4 shallowRef 与 triggerRef
// packages/reactivity/src/ref.ts:100-102
export function shallowRef(value?: unknown) {
return createRef(value, true)
}
// packages/reactivity/src/ref.ts:104-109
function createRef(rawValue: unknown, shallow: boolean) {
if (isRef(rawValue)) {
return rawValue
}
return new RefImpl(rawValue, shallow)
}
createRef 里 isRef(rawValue) 早退:shallowRef(existingRef) 返回原 ref,不重包。这与 reactive 幂等性同构。
shallowRef 的效果:_value/_rawValue 都是原值,不 toReactive。所以 shallowRef({n:1}).value.n++ 不触发(对象未被代理)。但 shallowRef({n:1}).value = {n:2} 触发(整体替换)。
triggerRef(ref.ts:192-206)是"手动触发"逃生舱:
export function triggerRef(ref: Ref): void {
// ref may be an instance of ObjectRefImpl
if ((ref as unknown as RefImpl).dep) {
// __DEV__ 时带 debugInfo,否则空参
;(ref as unknown as RefImpl).dep.trigger(...)
}
}
注释 ref may be an instance of ObjectRefImpl 提示:ObjectRefImpl 的 dep 是个 getter(ref.ts:422-424),它从 getDepFromReactive(this._raw, this._key) 动态取——也就是复用了 targetMap 里对应属性的 dep。所以 triggerRef(toRef(state, 'foo')) 能正确触发 state.foo 的依赖者。
实战:
shallowRef+triggerRef是"手动控制更新粒度"的利器。典型场景:shallowRef(largeImmutableArray),只在整体替换时更新;或配合第三方非响应式实例(如 ECharts 实例),改内部状态后手动triggerRef通知模板。
3.6.5 toRef / toRefs / ObjectRefImpl
toRef(ref.ts:500-514)有三种形态:
export function toRef(source, key?, defaultValue?): Ref {
if (isRef(source)) {
return source // ① 已是 ref → 原样返回
} else if (isFunction(source)) {
return new GetterRefImpl(source) // ② 函数 → getter ref
} else if (isObject(source) && arguments.length > 1) {
return propertyToRef(source, key!, defaultValue) // ③ 对象 + key → ObjectRefImpl
} else {
return ref(source) // ④ 其他 → 普通 ref
}
}
ObjectRefImpl(ref.ts:372-425)是 toRef(obj, key) 的核心。它不持有自己的值,而是"转发"到源对象的属性:
class ObjectRefImpl<T extends object, K extends keyof T> {
public readonly [ReactiveFlags.IS_REF] = true
public _value: T[K] = undefined!
private readonly _raw: T
private readonly _key: K
private readonly _shallow: boolean
constructor(private readonly _object: T, key: K, private readonly _defaultValue?: T[K]) {
this._key = (isSymbol(key) ? key : String(key)) as K
this._raw = toRaw(_object)
let shallow = true
let obj = _object
// For an array with integer key, refs are not unwrapped
if (!isArray(_object) || isSymbol(this._key) || !isIntegerKey(this._key)) {
do {
shallow = !isProxy(obj) || isShallow(obj)
} while (shallow && (obj = (obj as Target)[ReactiveFlags.RAW]))
}
this._shallow = shallow
}
// ...
}
构造函数里那段 do...while 是**"逐层剥代理":从 _object 开始,若它不是代理**(!isProxy)或是浅代理(isShallow),则 shallow = true 并继续往上扒 RAW(obj = obj[RAW]);一旦遇到"是代理且非浅"(深代理),shallow = false 并终止。目的是判断:这条 toRef 链路上,值解包应遵循哪一层语义——若从顶到底都是浅/非代理,_shallow = true,get value 对值执行 unref(最外层已是深代理解包过,再解包会双重解包)。
例外:数组 + 整数索引直接跳过循环,shallow 保持初值 true。理由与 3.3.7、3.5 一致——数组整数索引元素不参与 ref 解包。于是 toRef(arr,0).value 会执行 unref(arr[0])(_shallow 为 true),逻辑自洽:数组元素是 ref → 想拿它的解包值 → unref。
get value / set value:
// packages/reactivity/src/ref.ts:402-424
get value() {
let val = this._object[this._key]
if (this._shallow) {
val = unref(val)
}
return (this._value = val === undefined ? this._defaultValue! : val)
}
set value(newVal) {
if (this._shallow && isRef(this._raw[this._key])) {
const nestedRef = this._object[this._key]
if (isRef(nestedRef)) {
nestedRef.value = newVal
return
}
}
this._object[this._key] = newVal
}
get dep(): Dep | undefined {
return getDepFromReactive(this._raw, this._key)
}
- 读:
this._object[this._key]会触发源对象(若为代理)的track(GET, key),因此toRef天然同步源。=== undefined时才用_defaultValue——这解释了toRef(props, 'x', 0)的默认值语义(只在显式undefined时生效,null不会)。 - 写:若
_shallow且源属性本身是 ref,则写进那个 ref 的.value(不替换 ref 身份);否则直接给源对象属性赋值。这两种路径分别对应"ref 属性"与"普通属性"。 dep:不是存储字段,而是 getter,动态从targetMap取。见 3.6.4 的triggerRef(ObjectRefImpl)场景。
toRefs(ref.ts:361-370):
export function toRefs<T extends object>(object: T): ToRefs<T> {
if (__DEV__ && !isProxy(object)) {
warn(`toRefs() expects a reactive object but received a plain one.`)
}
const ret: any = isArray(object) ? new Array(object.length) : {}
for (const key in object) {
ret[key] = propertyToRef(object, key)
}
return ret
}
- DEV 警告:传非代理对象时警告——普通对象
toRefs得到的 ref 不会响应(源变更无代理在 track);生产环境只警告、不阻止。 - 数组特殊处理:
isArray(object) ? new Array(object.length) : {}——用new Array(length)而非[]以保留 holes 语义(for...in跳过 holes,hole 位置保持空)。 for...in与Object.keys语义等价(都枚举可枚举自有 + 可枚举继承属性,且都触发代理的ownKeys陷阱)。
3.6.6 customRef、GetterRefImpl、unref、toValue
// packages/reactivity/src/ref.ts:293-317
class CustomRefImpl<T, S = T> {
public dep: Dep
private readonly _get: ReturnType<CustomRefFactory<T, S>>['get']
private readonly _set: ReturnType<CustomRefFactory<T, S>>['set']
public readonly [ReactiveFlags.IS_REF] = true
public _value: T = undefined!
constructor(factory: CustomRefFactory<T, S>) {
const dep = (this.dep = new Dep())
const { get, set } = factory(dep.track.bind(dep), dep.trigger.bind(dep))
this._get = get
this._set = set
}
get value(): T {
return (this._value = this._get())
}
set value(newVal: S) {
this._set(newVal)
}
}
customRef 把 Dep 的 track / trigger 以 bind 后的函数交给用户,由用户自己决定何时追踪、何时触发。get value 不透传 track(用户自己调),set value 不透传 trigger。这是防抖 ref、异步校验 ref 等场景的基础。
GetterRefImpl(ref.ts:427-436):toRef(() => expr) 的产物。它只读(同时带 IS_REF 与 IS_READONLY 两个标记),get value 每次调用 getter。
class GetterRefImpl<T> {
public readonly [ReactiveFlags.IS_REF] = true
public readonly [ReactiveFlags.IS_READONLY] = true
public _value: T = undefined!
constructor(private readonly _getter: () => T) {}
get value() {
return (this._value = this._getter())
}
}
因为带 IS_READONLY,isReadonly(toRef(() => props.x)) 为 true。所以它不能作为 v-model 的绑定目标(会警告只读),只适合只读派生。
unref(ref.ts:229-231):isRef(ref) ? ref.value : ref。一行糖。
toValue(ref.ts:249-251):isFunction(source) ? source() : unref(source)。比 unref 多处理 getter。它在 watch / toRef 的 Normalize 逻辑里被复用。
3.6.7 proxyRefs 与 shallowUnwrapHandlers
// packages/reactivity/src/ref.ts:253-267
const shallowUnwrapHandlers: ProxyHandler<any> = {
get: (target, key, receiver) =>
key === ReactiveFlags.RAW
? target
: unref(Reflect.get(target, key, receiver)),
set: (target, key, value, receiver) => {
const oldValue = target[key]
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
} else {
return Reflect.set(target, key, value, receiver)
}
},
}
// packages/reactivity/src/ref.ts:277-283
export function proxyRefs<T extends object>(objectWithRefs: T): ShallowUnwrapRef<T> {
return isReactive(objectWithRefs)
? (objectWithRefs as ShallowUnwrapRef<T>)
: new Proxy(objectWithRefs, shallowUnwrapHandlers)
}
proxyRefs 是"浅解包代理":get 时对每个属性 unref,set 时若老值是 ref 且新值不是 ref,则写 oldValue.value。它不是 baseHandlers 那套,而是独立的一套极简 handler。它的主战场在 runtime-core 的 setup 返回对象——见 3.7。注意 isReactive 早退:已是 reactive 的对象直接返回(因为它已经解包了)。
3.6.8 isRef 与"class ref"编译器糖
// packages/reactivity/src/ref.ts:46-50
export function isRef<T>(r: Ref<T> | unknown): r is Ref<T>
/*@__NO_SIDE_EFFECTS__*/
export function isRef(r: any): r is Ref {
return r ? r[ReactiveFlags.IS_REF] === true : false
}
isRef 的判据是 === true(严格),不是真值。这防止了"用户对象恰好有 __v_isRef: 1"这类误判。
什么是"class ref / 编译器糖"? Vue 3.5 的 useTemplateRef()(在 runtime-core)返回"class 风格 ref"。需澄清:把顶层 let count = 1 编译成 ref(1) 的是 @vue/reactivity-transform(已弃用),不是模板编译器。真正仍在生效的运行时还原机制有三处:① 模板 $refs——this.$refs.foo 读写由 rendererTemplateRef.ts 处理;② useTemplateRef——返回带特殊标记的 ref,模板 ref="foo" 由运行时自动填充;③ SFC defineModel——useModel 用 customRef + proxyRefs 组合,让 modelValue 读写直通 emit('update:modelValue')。
关于 RefSymbol(ref.ts:26):它是 declare const 的 unique symbol,仅用于类型层面区分 Ref,注释说 "Type differentiator only… don't want it to show up in IDE autocomplete"——运行时不存在,纯 TS 技巧。
3.7 模板自动解包:三层机制协作
"reactive 对象里嵌 ref 会自动解包、模板里访问不需要 .value" 是 Vue 的招牌手感。它由三层机制共同实现:代理层的解包(3.3.7)、setup 返回值的 proxyRefs、编译层的 unref 注入。
3.7.1 第一层:代理层解包(为什么 reactive 里的 ref 要解包)
baseHandlers.ts:120-124 已经在 3.3.7 分析过:reactive({ count: ref(0) }) 读 state.count 时,isRef(res) 为真 → 返回 res.value。设计动机是心智模型统一:
const count = ref(0)
const state = reactive({ count })
console.log(state.count) // 0,而不是 Ref
state.count++ // 通过 set 陷阱的 ref 委托写到 count.value
console.log(count.value) // 1
于是"reactive 里的 ref"和"普通属性"在使用体感上是同一种东西——都能直接读写,不需要 .value。这消除了"有的属性要 .value、有的不要"的认知负担。代价是"你无法从 state.count 判断它底层是不是 ref"(除非用 toRaw)。
3.7.2 第二层:setup 返回 ref 的运行时还原(proxyRefs)
<script setup> 里:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>{{ count }}</template>
count 是 ref,但模板里写 count 而非 count.value。这是怎么做到的?
setup 返回的对象(或 <script setup> 绑定的 bindings)会被 runtime-core 用 proxyRefs 包装,得到 setupState。于是:
// packages/reactivity/src/ref.ts:280-282
return isReactive(objectWithRefs)
? (objectWithRefs as ShallowUnwrapRef<T>)
: new Proxy(objectWithRefs, shallowUnwrapHandlers)
setupState.count 时,shallowUnwrapHandlers.get 执行 unref(Reflect.get(target, 'count')) → 拿到 0。所以模板 {{ count }} 渲染的是解包值。
写回同理:setupState.count = 1 时,set 里 isRef(oldValue) 为真且新值非 ref → oldValue.value = 1,即写到 count 这个 ref 上。这保证了 ref 身份不被破坏(否则每次渲染都会新建 ref,依赖追踪全乱)。
关键区别:
proxyRefs是浅解包(只解包对象自身顶层的 ref 属性),deep 解包靠baseHandlers。二者是"两层不同的解包",覆盖不同场景。
3.7.3 第三层:模板 ref($refs)与 rendererTemplateRef
模板里 ref="foo" 的绑定值由 rendererTemplateRef.ts 的 setRef 处理。它把 DOM 元素/组件实例写到三个目标之一:refs($refs)、setupState(若 key 在 setup 里是 ref)、或用户传入的 ref / 函数。
关键分支(rendererTemplateRef.ts:142-186):
const _isString = isString(ref)
const _isRef = isRef(ref)
if (_isString || _isRef) {
const doSet = () => {
if (rawRef.f) { // ref_for,v-for 上的 ref 收集为数组
const existing = _isString ? (canSetSetupRef(ref) ? setupState[ref] : refs[ref]) : (canSetRef(ref) || !rawRef.k ? ref.value : refs[rawRef.k])
if (isUnmount) {
isArray(existing) && remove(existing, refValue)
} else if (!isArray(existing)) {
// 首次 → 建数组
} else if (!existing.includes(refValue)) {
existing.push(refValue)
}
} else if (_isString) {
refs[ref] = value
if (canSetSetupRef(ref)) setupState[ref] = value
} else if (_isRef) {
if (canSetRef(ref, rawRef.k)) ref.value = value
if (rawRef.k) refs[rawRef.k] = value
}
// ...
}
// #1789: for non-null values, set them after render
三个细节值得注意:
_isString与_isRef的双路:ref="foo"(字符串)走refs+setupState双写;:ref="myRef"(ref 对象)走myRef.value = value,同时若rawRef.k存在也写refs[k]。canSetSetupRef(rendererTemplateRef.ts:85-108):若 setup 里foo存在且不是 ref,DEV 警告:
// packages/runtime-core/src/rendererTemplateRef.ts:89-95
if (hasOwn(rawSetupState, key) && !isRef(rawSetupState[key])) {
warn(
`Template ref "${key}" used on a non-ref value. ` +
`It will not work in the production build.`,
)
}
这是因为 setupState[key] = value 要走 proxyRefs 的 set 陷阱——若 foo 不是 ref,写入会替换 setupState.foo 指向,而 proxyRefs 的值不是响应式的(普通对象 + 浅 handler),模板不会更新。DEV 提示"生产不工作",正是提醒这个静默陷阱。
3. rawRef.f(ref_for):v-for 里同一个 ref 被多个元素复用,需要聚合成数组,卸载时从数组里 remove。
3.7.4 数组里 ref 不解包:三处一致的例外
把"数组 + 整数索引 + ref"排除在解包之外,这个规则在三个地方都有体现,形成了一致的设计:
| 位置 | 代码 | 效果 |
|---|---|---|
BaseReactiveHandler.get | targetIsArray && isIntegerKey(key) ? res : res.value(baseHandlers.ts:122) | 数组元素若是 ref,返回 ref 本身 |
ObjectRefImpl 构造函数 | if (!isArray(_object) || isSymbol(this._key) || !isIntegerKey(this._key)) 才做逐层剥代理(ref.ts:392) | 数组整数索引的 toRef 保持 _shallow = true |
baseHandlers.set | !isArrayWithIntegerKey && isRef(oldValue) && !isRef(value) 才委托(baseHandlers.ts:156) | 数组整数索引写入不委托到 ref.value,而是替换 |
为什么数组必须有这个例外?因为数组的常见操作会遍历元素(map/filter/for...of),如果元素被解包,那么在 toRefs(reactiveArray) 或"数组里存 ref"的场景中,遍历拿到的就是值快照而非 ref,后续对元素的写回就丢了响应式身份。所以数组元素保留 ref 原样,只有显式 .value 才能解包。这是"数组是连续行为、对象是命名行为"的语义差异在响应式上的投射。
3.8 数组 instrumentations:重写方法、暂停追踪与两次触发问题
arrayInstrumentations(arrayInstrumentations.ts:52-240)是数组代理的"方法层"。它把数组的 20+ 个方法替换成 Vue 版本,每个替换都为了一个明确目的:或是收集更精确的依赖,或是避免收集错误的依赖。
3.8.1 全量列表与三类目的
// packages/reactivity/src/arrayInstrumentations.ts:52-56
export const arrayInstrumentations: Record<string | symbol, Function> = <any>{
__proto__: null,
[Symbol.iterator]() {
return iterator(this, Symbol.iterator, item => toWrapped(this, item))
},
// ...
按目的分三组:
| 组别 | 方法 | 目的 | 依赖键 |
|---|---|---|---|
| 迭代读 | map / filter / forEach / some / every / find / findLast / findIndex / findLastIndex / reduce / reduceRight / join / concat / entries / values / keys / Symbol.iterator / toReversed / toSorted / toSpliced | 用一个统一的 ARRAY_ITERATE_KEY 依赖替代"逐个索引依赖",减少 dep 数量;同时把返回元素包装成代理 | ARRAY_ITERATE_KEY |
| 身份搜索 | includes / indexOf / lastIndexOf | 处理"代理 vs raw 身份不等"导致的查找失败 | ARRAY_ITERATE_KEY |
| 变更方法 | push / pop / shift / unshift / splice | 暂停依赖收集与批量,避免 length 被误收集导致无限循环;只触发一次 | 不 collect(noTracking) |
__proto__: null 是必须的:让 arrayInstrumentations 没有原型链,从而 arrayInstrumentations['toString'] 等返回 undefined(不误注入原型方法)。
3.8.2 变更方法的 noTracking:逐行走读
// packages/reactivity/src/arrayInstrumentations.ts:401-412
function noTracking(
self: unknown[],
method: keyof Array<any>,
args: unknown[] = [],
) {
pauseTracking()
startBatch()
const res = (toRaw(self) as any)[method].apply(self, args)
endBatch()
resetTracking()
return res
}
调用方:
// packages/reactivity/src/arrayInstrumentations.ts:169-175, 201-203, 214-216, 233-235
pop() { return noTracking(this, 'pop') },
push(...args: unknown[]) { return noTracking(this, 'push', args) },
shift() { return noTracking(this, 'shift') },
splice(...args: unknown[]) { return noTracking(this, 'splice', args) },
unshift(...args: unknown[]) { return noTracking(this, 'unshift', args) },
逐行解释:
pauseTracking()(effect.ts:525-528):把当前shouldTrack压栈并置false。此后任何track()调用都会因shouldTrack为假而直接 return(dep.ts:263)。关键:pauseTracking是栈式的(trackStack.push),支持嵌套恢复。startBatch()(effect.ts:265-267):batchDepth++。批处理让endBatch之前的所有trigger只标记 effect 为待执行(EffectFlags.NOTIFIED),不真正调度。(toRaw(self) as any)[method].apply(self, args):注意这个"错位"——函数来自toRaw(self),但this是self(代理)。也就是"取原生的方法实现,但以代理为 receiver 调用"。为什么要这样?- 从 raw 上取方法,避免
self[method]又走一次get陷阱(虽然事务期间不 track,但仍是多余开销); - 以
self为 receiver,让原生方法内部的this.length/this[i] = x继续走代理的get/set陷阱——这正是我们想要的:读不收集(tracking 已暂停),写要触发(trigger 未暂停)。
- 从 raw 上取方法,避免
endBatch():batchDepth--;归零时统一执行所有被标记的 effect(effect.ts:273-310)。于是多次trigger合并为一次调度。resetTracking():弹出栈顶,恢复之前的shouldTrack(effect.ts:541-544)。
pauseTracking/resetTracking是一对,startBatch/endBatch是一对。前者管"读要不要收集",后者管"写要不要立即执行"。两者配合:事务期间不收集依赖(避免把自己读的length变成依赖),但收集到的写入仍会在批末统一触发。
3.8.3 为什么 push 会"触发两次":问题与解法
先看没有 noTracking 时会发生什么。假设有这样的代码:
const arr = reactive([])
watchEffect(() => { arr.push(1) }) // 一个"自己改自己"的 effect
原生 push 的执行序列:
- 读
arr.length(走get陷阱)→ track('length'); - 写
arr[len] = 1(走set陷阱)→ 若len < length判定 → 若是新增则ADD→ trigger('length'); - 写
arr.length = len + 1(走set陷阱)→ trigger('length')。
问题:第 2、3 步都会 trigger('length'),而 effect 恰好依赖 'length'(第 1 步收集的)→ 触发 → 重跑 → 又 push → 无限循环(历史 issue #2137)。
Vue 的解法:pauseTracking() 让第 1 步的 track('length') 不生效(effect 不再依赖 length,后续 trigger 打不到自己);startBatch()/endBatch() 把第 2、3 步的 trigger 合并成一次通知(真正依赖 length 的 effect 如 v-for 仍被通知一次);且 pauseTracking 不阻止写入触发(只暂停 track),所以 push 对外部 effect 的响应性完好。
一句话:"依赖收集暂停 + 手动/批处理触发"——既切断自触发循环,又保留对外通知。noTracking 注释(arrayInstrumentations.ts:399-400:instrument length-altering mutation methods to avoid length being tracked which leads to infinite loops in some cases)记录了这个动机。
对照 Vue 2:Vue 2 重写这 7 个方法并在内部手动
dep.notify(),用__ob__.dep兜底。它没有"暂停收集"问题(defineProperty的 getter 不会因push内部读length而反复收集),但需要更复杂的"索引/长度双重 notify"。Vue 3 用"统一暂停 + 批处理"把复杂度收敛到pauseTracking/startBatch。
3.8.4 searchProxy:身份搜索的三重尝试
// packages/reactivity/src/arrayInstrumentations.ts:380-397
function searchProxy(
self: unknown[],
method: keyof Array<any>,
args: unknown[],
) {
const arr = toRaw(self) as any
track(arr, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
// we run the method using the original args first (which may be reactive)
const res = arr[method](...args)
// if that didn't work, run it again using raw values.
if ((res === -1 || res === false) && isProxy(args[0])) {
args[0] = toRaw(args[0])
return arr[method](...args)
}
return res
}
includes / indexOf / lastIndexOf 是身份敏感的方法。问题场景:
const obj = {}
const arr = reactive([obj])
arr.includes(reactive(obj)) // 应该 true —— 但?
arr.indexOf(obj) // 应该 0
arr 是代理数组,元素是 obj(raw)。当 arr.includes(reactive(obj)) 时:
- 若只做一次
arr.includes(reactive(obj)),reactive(obj)是代理,raw 数组里是 raw obj,引用不同 → 返回false(错误)。
searchProxy 的策略:① 先用原参数跑一次 arr[method](...args)(arr = toRaw(self),是 raw 数组,元素也是 raw):传 raw 值(arr.includes(obj))第一次即命中、只扫一次;传 代理(arr.includes(reactive(obj)))raw 数组里没有该引用,第一次失败(-1/false)。② 若失败且 args[0] 是代理((res === -1 || res === false) && isProxy(args[0])):把 args[0] 换成 toRaw(args[0]) 再跑一次即命中。
这里
arr是toRaw(self)而非代理数组,元素本身也是 raw,不会被"读元素时递归代理"干扰——查找必须在身份统一(全 raw)的空间里进行,toRaw(args[0])才能命中。
track 在 raw 数组上做一次 ARRAY_ITERATE_KEY(依赖挂在 raw 上,targetMap 的键是 raw 对象,见第 02 章)。
性能观察:最坏跑两次原生查找(O(n) × 2)——
includes在"元素是对象 + 传代理"时是两次线性扫描,这是为正确性付的代价。实践建议:includes/indexOf前用toRaw归一化参数可避免第二次扫描;或直接用Set(但集合代理也有独立开销,见 3.9)。
3.8.5 迭代器:iterator 与 ARRAY_ITERATE_KEY
// packages/reactivity/src/arrayInstrumentations.ts:243-291
function iterator(
self: unknown[],
method: keyof Array<unknown>,
wrapValue: (value: any) => unknown,
) {
// note that taking ARRAY_ITERATE dependency here is not strictly equivalent
// to calling iterate on the proxied array.
// creating the iterator does not access any array property:
// it is only when .next() is called that length and indexes are accessed.
// ...
const arr = shallowReadArray(self)
const methodFn = arr[method] as any
const iter = methodFn.call(arr) as IterableIterator<unknown> & {
_next: IterableIterator<unknown>['next']
}
if (arr !== self && !isShallow(self)) {
// #15643 an own `next` on a native array iterator permanently disables
// V8's array iteration fast paths ...
if ((iter as any)[Symbol.toStringTag] === 'Array Iterator') {
const wrapped = Object.create(Object.getPrototypeOf(iter))
wrapped.next = () => wrapResult(iter.next(), wrapValue)
// ...
return wrapped
}
iter._next = iter.next
iter.next = () => wrapResult(iter._next(), wrapValue)
}
return iter
}
注释那段话是理解该优化的关键:创建迭代器(arr[Symbol.iterator]())本身不访问任何数组属性,只有 .next() 被调用时才访问 length 与索引,所以严格等价的做法应是"在 .next() 里 track"。但 Vue 做了简化:创建迭代器时就直接 track(ARRAY_ITERATE_KEY)(经 shallowReadArray,见 arrayInstrumentations.ts:40-43)——理由是 iterator 通常只被读一次,拆到多个 effect 里迭代不现实,该简化可接受。
// packages/reactivity/src/arrayInstrumentations.ts:40-43
export function shallowReadArray<T>(arr: T[]): T[] {
track((arr = toRaw(arr)), TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
return arr
}
shallowReadArray 做三件事:toRaw 归一、track(ARRAY_ITERATE_KEY)、返回 raw 数组(返回 raw 是刻意的,让后续 arr[method] 走原生实现,避免再穿代理)。
#15643 分支很硬核:V8 的数组迭代快速路径(spread、Array.from 等)一旦发现迭代器上有 own next,就会永久对该 isolate 关闭快速路径。所以 Vue 检测到原生 Array Iterator 时,用 Object.create(Object.getPrototypeOf(iter)) 建"影子对象",把 next 定义在影子对象自身,而非改写原生迭代器——既包装了值,又不破坏 V8 优化。wrapResult(293-301)即"非 done 时才包装 value"。
3.8.6 apply 与 reduce:包装元素与第三参数
// packages/reactivity/src/arrayInstrumentations.ts:310-346
function apply(
self: unknown[],
method: ArrayMethods,
fn: (item: unknown, index: number, array: unknown[]) => unknown,
thisArg?: unknown,
wrappedRetFn?: (result: any) => unknown,
args?: IArguments,
) {
const arr = shallowReadArray(self)
const needsWrap = arr !== self && !isShallow(self)
const methodFn = arr[method]
// #11759
// If the method being called is from a user-extended Array, ... skip ...
if (methodFn !== arrayProto[method as any]) {
const result = methodFn.apply(self, args)
return needsWrap ? toReactive(result) : result
}
let wrappedFn = fn
if (arr !== self) {
if (needsWrap) {
wrappedFn = function (this: unknown, item, index) {
return fn.call(this, toWrapped(self, item), index, self)
}
} else if (fn.length > 2) {
wrappedFn = function (this: unknown, item, index) {
return fn.call(this, item, index, self)
}
}
}
const result = methodFn.call(arr, wrappedFn, thisArg)
return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result
}
三个要点:
needsWrap:arr !== self && !isShallow(self)——只有当"目标不是浅代理、且toRaw确实拿回了不同对象(即self是代理)"时才需要包装元素。对 raw 数组或浅数组,跳过包装(保持原样)。这是零成本的快速路径。else if (fn.length > 2)分支:当arr !== self(代理)但!needsWrap(浅)时,回调第三参数array需换成self(代理)而非 rawarr;判fn.length > 2是因为只有回调声明了第三参数才需要传self,否则省掉引用可减少闭包分配——基于Function.length的微优化。#11759:若method被用户改写(methodFn !== arrayProto[method]),Vue 的参数顺序假设失效,于是放弃包装,直接methodFn.apply(self, args),仅needsWrap时对返回值toReactive——"不假设用户不改原生方法"的防御。
reduce(349-377)比 apply 多一个"初始累加器包装":无初始值时,第一次回调的 acc 是数组第一个元素(raw),需要 toWrapped。它用一个 wrapInitialAccumulator 标志保证只包一次。
// packages/reactivity/src/arrayInstrumentations.ts:359-368
if (arr !== self) {
if (needsWrap) {
wrapInitialAccumulator = args.length === 0
wrappedFn = function (this: unknown, acc, item, index) {
if (wrapInitialAccumulator) {
wrapInitialAccumulator = false
acc = toWrapped(self, acc)
}
return fn.call(this, acc, toWrapped(self, item), index, self)
}
}
// ...
}
3.8.7 toWrapped 与 reactiveReadArray:包装规则
// packages/reactivity/src/arrayInstrumentations.ts:45-50
function toWrapped(target: unknown, item: unknown) {
if (isReadonly(target)) {
return isReactive(target) ? toReadonly(toReactive(item)) : toReadonly(item)
}
return toReactive(item)
}
包装规则:若数组是只读,元素包成 toReadonly;若数组是"只读 + 响应式"(readonly(reactive(arr))),元素要包成 toReadonly(toReactive(item))(保留内层响应式)。这与 collectionHandlers 的 wrap = isReadonly ? toReadonly : toReactive 是同一套语义。
// packages/reactivity/src/arrayInstrumentations.ts:22-35
export function reactiveReadArray<T>(array: T[]): T[] {
const raw = toRaw(array)
if (raw === array) return raw
track(raw, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
if (isShallow(array)) return raw
if (!isReadonly(array)) return raw.map(toReactive)
return (
isReactive(array)
? raw.map(item => toReadonly(toReactive(item)))
: raw.map(toReadonly)
) as T[]
}
reactiveReadArray 用于 join / concat / toReversed / toSorted / toSpliced 这类"需要完整元素列表"的方法:它返回一个新的 raw 数组,元素按需包装。raw === array 早退(输入已是 raw)省掉一次 track 与 map。
3.8.8 instrumentations 全景对照表
| 原生方法 | Vue 版本行为 | 依赖 | 备注 |
|---|---|---|---|
push/pop/shift/unshift/splice | noTracking:暂停收集 + 批处理 | 无(事务内不收集) | 避免 length 自依赖 → 无限循环 |
includes/indexOf/lastIndexOf | searchProxy:raw 优先,失败再对参数 toRaw 重试 | ARRAY_ITERATE_KEY | 最坏两次 O(n) |
map/filter/every/some/find/findIndex/findLast/findLastIndex/forEach | apply:shallowReadArray + 包装元素 | ARRAY_ITERATE_KEY | 第三参数传 self |
reduce/reduceRight | reduce:额外包装初始累加器 | ARRAY_ITERATE_KEY | 无初始值时包首元素 |
join/concat/toReversed/toSorted/toSpliced | reactiveReadArray | ARRAY_ITERATE_KEY | 返回 raw 数组副本 |
keys/values/entries/Symbol.iterator | iterator + 包装 next | ARRAY_ITERATE_KEY | #15643 V8 快速路径保护 |
flat/flatMap/slice | 未 instrumented(走原生) | 各自索引/length | 注释:受益不明显/需要 range tracking |
为什么
slice/flat/flatMap没被 instrumented?源码注释:slice could use ARRAY_ITERATE but also seems to beg for range tracking(切片更接近"范围追踪",用ARRAY_ITERATE会过度触发);flat, flatMap could benefit from ARRAY_ITERATE but are not straight-forward to implement(回调与结果嵌套层级使其不易统一包装)。未 instrumented 意味着走原生实现、逐个索引 track——arr.slice()会建 N 个索引依赖,大数组上昂贵,故避免在模板表达式里对大数组用slice/flat。
3.9 collectionHandlers:Map/Set 的内部槽难题
3.9.1 为什么集合不能复用 baseHandlers
Map/Set 的操作不走 [[Get]] / [[Set]]:
map.get(key)是方法调用,不是属性访问。get陷阱拦不到"读 key"这件事;map.size是原型上的 getter,读取它虽然会走get陷阱(因为size是原型 getter,Reflect.get会触发),但语义上不是"读某个 key";map.set(k, v)/map.add(v)/map.delete(k)都是方法调用,set/deleteProperty陷阱完全拦不到。
所以集合必须走另一条路:拦截 get,把方法替换成"instrumentation"函数。当用户写 map.get(k) 时,get 陷阱返回一个"被 Vue 改写过的 get 函数";用户调用它时,函数内部完成 track / trigger。
// packages/reactivity/src/collectionHandlers.ts:270-296
function createInstrumentationGetter(isReadonly: boolean, shallow: boolean) {
const instrumentations = createInstrumentations(isReadonly, shallow)
return (target, key, receiver) => {
if (key === ReactiveFlags.IS_REACTIVE) {
return !isReadonly
} else if (key === ReactiveFlags.IS_READONLY) {
return isReadonly
} else if (key === ReactiveFlags.IS_SHALLOW) {
return shallow
} else if (key === ReactiveFlags.RAW) {
return target
}
return Reflect.get(
hasOwn(instrumentations, key) && key in target
? instrumentations
: target,
key,
receiver,
)
}
}
关键在最后一个 Reflect.get:
hasOwn(instrumentations, key) && key in target→ 只注入"instrumentations 里有、且目标上也确实有"的方法。key in target这个条件防止注入"目标没有的方法"(例如对Set注入get,会打破语义);- 三元决定"从 instrumentations 取,还是从 target 取"。注意 receiver 始终是原来的
receiver(代理)——所以原生方法的this仍是代理,内部还会再走陷阱(比如forEach内部读size)。
注意集合 handler 只有 get——没有 set / has / ownKeys / deleteProperty。所有写操作都在 instrumentation 函数里完成(add / set / delete / clear),所有读操作(get / has / size / forEach / 迭代)也都在 instrumentation 里完成。这是集合代理与对象代理最根本的结构差异。
// packages/reactivity/src/collectionHandlers.ts:298-313
export const mutableCollectionHandlers: ProxyHandler<CollectionTypes> = {
get: /*@__PURE__*/ createInstrumentationGetter(false, false),
}
export const shallowCollectionHandlers = { get: createInstrumentationGetter(false, true) }
export const readonlyCollectionHandlers = { get: createInstrumentationGetter(true, false) }
export const shallowReadonlyCollectionHandlers = { get: createInstrumentationGetter(true, true) }
四个单例。createInstrumentationGetter 在模块初始化时调用 createInstrumentations,返回一个闭包。也就是说,instrumentations 对象只在模块加载时构造一次(每个变体一份),永不重复。
3.9.2 get / has:raw 与 rawKey 的双 track
// packages/reactivity/src/collectionHandlers.ts:101-124
get(this: MapTypes, key: unknown) {
// #1772: readonly(reactive(Map)) should return readonly + reactive version of the value
const target = this[ReactiveFlags.RAW]
const rawTarget = toRaw(target)
const rawKey = toRaw(key)
if (!readonly) {
if (hasChanged(key, rawKey)) {
track(rawTarget, TrackOpTypes.GET, key)
}
track(rawTarget, TrackOpTypes.GET, rawKey)
}
const { has } = getProto(rawTarget)
const wrap = shallow ? toShallow : readonly ? toReadonly : toReactive
if (has.call(rawTarget, key)) {
return wrap(target.get(key))
} else if (has.call(rawTarget, rawKey)) {
return wrap(target.get(rawKey))
} else if (target !== rawTarget) {
// #3602 readonly(reactive(Map))
// ensure that the nested reactive `Map` can do tracking for itself
target.get(key)
}
}
这段是集合里最复杂的一个。逐步拆解:
this[ReactiveFlags.RAW]:instrumentation 函数被调用时,this是集合代理,this[RAW]拿到原始Map。注意:读RAW不 track(createInstrumentationGetter的RAW分支提前 return)。toRaw(target):target可能是内层代理(readonly(reactive(map))场景),toRaw剥到最底层 raw。rawKey = toRaw(key):key 也可能是代理(例如用 reactive 对象做 key)。归一化。- 双 track:
hasChanged(key, rawKey)→ 说明 key 本身是代理(有"身份差")。此时对 代理 key 也track一次(#1772相关:只读场景下 key 也需被追踪);- 对 rawKey 无条件
track。 - 为什么同时挂两个键?因为用户可能用代理 key 查询,也可能用 raw key 查询;
trigger时会用实际存储的 key 通知,两套都要能命中。
getProto(rawTarget).has:拿原型上的原生has并call(避免this是代理导致递归)。- 三级查找:先用原 key(可能是代理)、再用 rawKey、最后(
target !== rawTarget,即存在内层代理时)调target.get(key)——后者是为了触发内层 reactive 代理的 track(#3602)。注释说得很清楚:ensure that the nested reactive Map can do tracking for itself。 wrap:shallow ? toShallow : readonly ? toReadonly : toReactive。toShallow是恒等函数(collectionHandlers.ts:28:const toShallow = <T>(value: T): T => value)。
has(130-143)与 get 同构,只是把 TrackOpTypes.GET 换成 HAS,且查找逻辑合并成一行:
return key === rawKey
? target.has(key)
: target.has(key) || target.has(rawKey)
易错点(Map 作为 key):
map.set(reactive(obj), v)会把 reactive 代理作为 key 存进 raw Map。之后map.get(obj)(raw)会找不到——因为 Map 用SameValueZero,raw 与代理不等。Vue 的setinstrumentation(见 3.9.3)会先key = toRaw(key)再存,但只在"没找到"时才toRaw。而checkIdentityKeys(315-331)会在 DEV 警告"同时存在 raw 和 reactive 版本"。
3.9.3 add / set / delete / clear:写操作的四条陷阱
// packages/reactivity/src/collectionHandlers.ts:169-188
add(this: SetTypes, value: unknown) {
const target = toRaw(this)
const proto = getProto(target)
const rawValue = toRaw(value)
const valueToAdd =
!shallow && !isShallow(value) && !isReadonly(value) ? rawValue : value
const hadKey =
proto.has.call(target, valueToAdd) ||
(hasChanged(value, valueToAdd) && proto.has.call(target, value)) ||
(hasChanged(rawValue, valueToAdd) && proto.has.call(target, rawValue))
if (!hadKey) {
target.add(valueToAdd)
trigger(target, TriggerOpTypes.ADD, valueToAdd, valueToAdd)
}
return this
}
add 的"要去重"是 Set 的核心语义,但代理/raw 的身份问题让"是否已存在"变得复杂。Vue 用三重 has 检查:
has(target, valueToAdd):查"最终归一化后的值"是否已存在;hasChanged(value, valueToAdd) && has(target, value):若原值与归一值不同(说明原值是代理),查"原代理值"是否已存在;hasChanged(rawValue, valueToAdd) && has(target, rawValue):若 raw 值与归一值不同,查"raw 值"是否已存在。
三者任一存在都视为 hadKey,不重复添加、不 trigger。这保证了 set.add(obj) 与 set.add(reactive(obj)) 不会造成"同一个对象两个身份各存一份"。
set(189-212):
set(this: MapTypes, key: unknown, value: unknown) {
if (!shallow && !isShallow(value) && !isReadonly(value)) {
value = toRaw(value)
}
const target = toRaw(this)
const { has, get } = getProto(target)
let hadKey = has.call(target, key)
if (!hadKey) {
key = toRaw(key) // ← 只在没找到时才 toRaw key
hadKey = has.call(target, key)
} else if (__DEV__) {
checkIdentityKeys(target, has, 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
}
要点:value 先 toRaw(除非浅/只读);key 的 toRaw 只在第一次 has 失败后才做(有意顺序:优先按原 key 查,Map 里通常存 raw key,原 key 若是 raw 就能直接命中,省一次 toRaw);checkIdentityKeys 只在 DEV 且"第一次就命中"时调用——那意味着 raw 与代理都存在于 Map 中(不一致状态)。
delete(213-231)与 set 同构。clear(232-252):
clear(this: IterableCollections) {
const target = toRaw(this)
const hadItems = target.size !== 0
const oldTarget = __DEV__
? isMap(target) ? new Map(target) : new Set(target)
: undefined
const result = target.clear()
if (hadItems) {
trigger(target, TriggerOpTypes.CLEAR, undefined, undefined, oldTarget)
}
return result
}
clear 只在原本非空时才 trigger(CLEAR)。DEV 下把清空前的整个集合拷贝进 oldTarget(供调试/onTrigger 展示)。trigger 的 CLEAR 分支(dep.ts:328-331)会 depsMap.forEach(run)——通知该集合的所有依赖,因为 size / 迭代 / 每个 key 的 dep 都受影响。
3.9.4 forEach:三个"必须"的细节
// packages/reactivity/src/collectionHandlers.ts:144-156
forEach(this: IterableCollections, callback: Function, thisArg?: unknown) {
const observed = this
const target = observed[ReactiveFlags.RAW]
const rawTarget = toRaw(target)
const wrap = shallow ? toShallow : readonly ? toReadonly : toReactive
!readonly && track(rawTarget, TrackOpTypes.ITERATE, ITERATE_KEY)
return target.forEach((value: unknown, key: unknown) => {
// important: make sure the callback is
// 1. invoked with the reactive map as `this` and 3rd arg
// 2. the value received should be a corresponding reactive/readonly.
return callback.call(thisArg, wrap(value), wrap(key), observed)
})
}
注释列了两个"重要":① 回调的 this 与第三参数——第三参数 observed 必须是代理(this)而非 raw,因为用户可能在回调里继续用它,给 raw 就丢了响应式;callback.call(thisArg, ...) 保证 thisArg 生效。② 值要包装——集合里存的是 raw 值,暴露给用户时要 wrap(value)/wrap(key) 成 reactive/readonly。
track 用 ITERATE_KEY(不是 MAP_KEY_ITERATE_KEY),因为 forEach 遍历"键值对",任何 key 或 value 变化都该触发;!readonly && 守卫保证只读集合不收集依赖。
3.9.5 createIterableMethod:迭代器的包装工厂
// packages/reactivity/src/collectionHandlers.ts:33-75
function createIterableMethod(
method: string | symbol,
isReadonly: boolean,
isShallow: boolean,
) {
return function (this: IterableCollections, ...args: unknown[]) {
const target = this[ReactiveFlags.RAW]
const rawTarget = toRaw(target)
const targetIsMap = isMap(rawTarget)
const isPair =
method === 'entries' || (method === Symbol.iterator && targetIsMap)
const isKeyOnly = method === 'keys' && targetIsMap
const innerIterator = target[method](...args)
const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive
!isReadonly &&
track(
rawTarget,
TrackOpTypes.ITERATE,
isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY,
)
// return a wrapped iterator which returns observed versions of the
// values emitted from the real iterator
return extend(
Object.create(innerIterator),
{
next() {
const { value, done } = innerIterator.next()
return done
? { value, done }
: {
value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
done,
}
},
},
)
}
}
四个关键点:① isPair 与 isKeyOnly——isPair 指 Map 迭代产出 [key,value] 对(entries 与 Symbol.iterator),Set 产出单值;isKeyOnly 指 Map.keys() 只产出 key。② 依赖键选择 isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY——keys() 只关心 key 集合变化、不关心 value,故用更窄的 MAP_KEY_ITERATE_KEY,其他迭代用 ITERATE_KEY。③ Object.create(innerIterator) + extend——包装不改变 innerIterator 本身,而是建"原型指向它"的新对象再定义 next,从而保留所有迭代器协议属性(Symbol.iterator 经原型继承),思路与 3.8.5 的 V8 快速路径保护一致。④ wrap 选择:isShallow ? toShallow : isReadonly ? toReadonly : toReactive。
// packages/reactivity/src/collectionHandlers.ts:256-265
const iteratorMethods = ['keys', 'values', 'entries', Symbol.iterator] as const
iteratorMethods.forEach(method => {
instrumentations[method] = createIterableMethod(method, readonly, shallow)
})
四个迭代方法统一用同一工厂生成。
3.9.6 只读变体:createReadonlyMethod
// packages/reactivity/src/collectionHandlers.ts:77-92
function createReadonlyMethod(type: TriggerOpTypes): Function {
return function (this: CollectionTypes, ...args: unknown[]) {
if (__DEV__) {
const key = args[0] ? `on key "${args[0]}" ` : ``
warn(
`${capitalize(type)} operation ${key}failed: target is readonly.`,
toRaw(this),
)
}
return type === TriggerOpTypes.DELETE
? false
: type === TriggerOpTypes.CLEAR
? undefined
: this
}
}
只读集合的写方法返回值的差异很讲究,是为了匹配各原生方法的契约:
| 方法 | 原生返回 | 只读版本返回 | 原因 |
|---|---|---|---|
delete | boolean | false | 谎称"没删掉",语义上合理(确实没删) |
clear | undefined | undefined | 匹配原生 |
add / set | this(集合本身) | this | 支持链式调用,不报错 |
set 的 key 只取 args[0](Map.set 第一个参数是 key);警告文案 ${capitalize(type)} operation on key "X" failed: target is readonly. 中的 capitalize 把 'add' 变成 'Add'。
3.9.7 checkIdentityKeys:DEV 的一致性告警
// packages/reactivity/src/collectionHandlers.ts:315-331
function checkIdentityKeys(
target: CollectionTypes,
has: (key: unknown) => boolean,
key: unknown,
) {
const rawKey = toRaw(key)
if (rawKey !== key && has.call(target, rawKey)) {
const type = toRawType(target)
warn(
`Reactive ${type} contains both the raw and reactive ` +
`versions of the same object${type === `Map` ? ` as keys` : ``}, ` +
`which can lead to inconsistencies. ` +
`Avoid differentiating between the raw and reactive ` +
`versions of an object and only use the reactive version if possible.`,
)
}
}
触发条件:传入的 key 是代理(rawKey !== key)且 raw 版本也已在集合里。这意味着集合里同时存在 raw 与代理两个身份,后续的 get/has/delete 会因为身份不同而行为不一致("你查到的和存进去的不是一个")。Vue 在 DEV 明确警告并给出建议("只用响应式版本")。
3.9.8 集合性能分析:与对象/数组的对比
集合代理的性能特征与对象有本质不同:
| 维度 | 对象/数组代理(baseHandlers) | 集合代理(collectionHandlers) |
|---|---|---|
| handler 陷阱数 | 5~6(get/set/has/deleteProperty/ownKeys…) | 只有 1 个(get) |
| 拦截粒度 | 属性访问级 | 方法调用级 |
| 每次操作开销 | 一次陷阱 + track/trigger | 一次 get 陷阱(返回方法)+ 方法内部 track/trigger |
toRaw 调用次数 | set 里 0~2 次 | get/set/has/delete 里 2~4 次 |
| 迭代依赖键 | 数组 ARRAY_ITERATE_KEY / 对象 ITERATE_KEY | ITERATE_KEY + MAP_KEY_ITERATE_KEY |
集合的每次方法调用比对象属性访问更贵:① 先过一层 get 陷阱拿到 instrumentation 函数;② instrumentation 内部要多次 toRaw(this/target/key/value);③ get/has 要"三级查找"(原 key / raw key / 内层),最坏三次 Map.has。
实践建议:热点集合若不需要响应式,用
markRaw(map)或toRaw后再操作;若需要,尽量减少 key 身份歧义(统一 raw 或统一代理),避免三级查找与checkIdentityKeys。Map的keys()迭代比values()/entries()便宜,因为它挂的是更窄的MAP_KEY_ITERATE_KEY。
3.10 集合的依赖键设计:ITERATE_KEY / MAP_KEY_ITERATE_KEY / size
3.10.1 三个特殊 Symbol
// packages/reactivity/src/dep.ts:242-250
export const ITERATE_KEY: unique symbol = Symbol(__DEV__ ? 'Object iterate' : '')
export const MAP_KEY_ITERATE_KEY: unique symbol = Symbol(__DEV__ ? 'Map keys iterate' : '')
export const ARRAY_ITERATE_KEY: unique symbol = Symbol(__DEV__ ? 'Array iterate' : '')
用 Symbol 而非字符串是刻意的:这些 key 会作为 depsMap 的键(Map<any, Dep>),必须与用户的属性名零碰撞。unique symbol 类型 + DEV 下的可读描述,兼顾类型安全与调试可读性。
| Symbol | 语义 | 谁 track | 什么操作会 trigger |
|---|---|---|---|
ITERATE_KEY | "整体结构(对象 key 集合 / 集合内容)" | 对象 ownKeys、Map/Set forEach、values/entries/[Symbol.iterator] | 对象 ADD/DELETE;Map 的 ADD/SET/DELETE;Set 全部写操作;CLEAR |
MAP_KEY_ITERATE_KEY | "Map 的 key 集合" | Map.keys() | Map 的 ADD/DELETE(不含 SET,因为 SET 不改 key 集合) |
ARRAY_ITERATE_KEY | "数组迭代" | instrumented 迭代方法与 iterator | 任意整数索引变更;length 变更 |
3.10.2 trigger 的类型通知矩阵
回到 dep.ts:359-384,把"哪个操作通知哪些依赖键"列全:
| trigger type | target | 通知的依赖键 |
|---|---|---|
ADD | 非数组对象 | key、ITERATE_KEY;若 Map 还加 MAP_KEY_ITERATE_KEY |
ADD | 数组整数索引 | key、ARRAY_ITERATE_KEY、'length' |
SET | 对象/数组 | key;若 Map 还加 ITERATE_KEY |
SET | 数组整数索引 | key、ARRAY_ITERATE_KEY |
DELETE | 非数组对象 | key、ITERATE_KEY;若 Map 还加 MAP_KEY_ITERATE_KEY |
SET on 'length'(数组) | 数组 | 'length'、ARRAY_ITERATE_KEY、所有 key >= newLength 的索引 dep |
CLEAR | 集合 | depsMap.forEach(run):全部 |
两个反直觉但正确的点:① Map.set 已存在的 key 会通知 ITERATE_KEY(dep.ts:379-383)但不通知 MAP_KEY_ITERATE_KEY——改 value 不改 key 集合,但会改变 forEach/entries 的产出,而 keys() 读者不受影响。② SET 对普通对象不通知 ITERATE_KEY——obj.x = 1(x 已存在)只影响 x 的读者,不影响"有哪些 key"的读者;只有 ADD/DELETE 才通知 ITERATE_KEY。这正是"改属性值不会让 Object.keys 消费者重跑"的基础,比 Vue 2(用 __ob__.dep 统一 notify)更精确。
3.10.3 size 的响应式来源
// packages/reactivity/src/collectionHandlers.ts:125-129
get size() {
const target = (this as unknown as IterableCollections)[ReactiveFlags.RAW]
!readonly && track(toRaw(target), TrackOpTypes.ITERATE, ITERATE_KEY)
return target.size
}
size 是 getter instrumentation:读 map.size track 的是 ITERATE_KEY(不是 'size')——因为 size 不是独立追踪的属性,而任何改变集合内容量的操作(ADD/DELETE/CLEAR)都会通知 ITERATE_KEY,复用它省去为 size 单独维护一套逻辑。MAP_KEY_ITERATE_KEY 更窄,也恰好覆盖 size 变化(key 增删必改 size)。
易错点:
map.size是只读 getter,但没有set size陷阱(集合 handler 只有get)。给它赋值map.size = 5会被Reflect.set到原型 getter(无 setter)上静默失败。响应式集合的size不可写,这符合Map规范。
3.11 markRaw / toRaw / shallow 深入与判定逻辑
3.11.1 toRaw 的递归与终止
// packages/reactivity/src/reactive.ts:391-394
export function toRaw<T>(observed: T): T {
const raw = observed && (observed as Target)[ReactiveFlags.RAW]
return raw ? toRaw(raw) : observed
}
toRaw 是递归的:因为可以有多层代理(readonly(reactive(obj)))。对最外层读 RAW 拿到内层代理,再对内层读 RAW 拿到 raw。终止条件是 raw 为假(raw 对象上 __v_raw 是 undefined)。
observed && 守卫让 toRaw(null)/toRaw(0)/toRaw('') 都安全(短路后不读 [RAW])——这是 toRaw 能复用于任意值的底气。
性能提示:
toRaw每次调用是一次属性读 + 可能递归;热点路径(如searchProxy)会先toRaw(self)并缓存,而非每处都调。
3.11.2 markRaw 的边界与 Raw<T> 类型标记
// packages/reactivity/src/reactive.ts:420-425
export function markRaw<T extends object>(value: T): Raw<T> {
if (!hasOwn(value, ReactiveFlags.SKIP) && Object.isExtensible(value)) {
def(value, ReactiveFlags.SKIP, true)
}
return value
}
// packages/reactivity/src/reactive.ts:396
export type Raw<T> = T & { [RawSymbol]?: true }
Raw<T> 类型标记的作用:UnwrapRefSimple(ref.ts:554-577)里有 T extends { [RawSymbol]?: true } ? T,即"被 markRaw 的类型不参与深层解包"。这让 TS 能推导出 ref(markRaw(obj)).value 的类型仍是 obj 而非解包后的形状。
三个边界:① 不能撤销——__v_skip 由 def 挂上,writable=false 且 Vue 不再提供删除 API,"临时 markRaw 再恢复"做不到。② 不可扩展对象——Object.isExtensible 为假时 def 因 if 短路而不抛错,结果等价:不可扩展对象本来就在 createReactiveObject 里被拒(reactive.ts:288-290)。③ 不影响已存在的代理——const p = reactive(obj); markRaw(obj) 后 p 仍是代理(已建好),只是新的 reactive(obj) 返回 obj;这是易被忽视的"时机陷阱"。
3.11.3 shallow 家族一览与选择指南
| API | 深层代理 | 自动解包 ref | 可写 | 典型用途 |
|---|---|---|---|---|
reactive | ✅ | ✅ | ✅ | 普通响应式状态 |
shallowReactive | ❌ | ❌ | ✅ | "顶层只需响应、嵌套是第三方/大对象" |
readonly | ✅ | ✅(解包值再套 readonly) | ❌ | 向外部暴露只读视图 |
shallowReadonly | ❌ | ❌ | ❌ | props(保留 ref 身份)、不可变配置 |
shallowRef | ❌(值不 toReactive) | —(本身是 ref) | ✅(整体替换) | 大数组/实例整体替换、triggerRef 手动通知 |
markRaw | —(永不代理) | — | ✅(原生) | 第三方实例、地图数据、只读大常量 |
选择顺序建议:能 markRaw 就 markRaw(零运行时开销)→ 需要整体替换就 shallowRef → 只需顶层响应就 shallowReactive → 其余才 reactive。
3.11.4 四个判定函数的完整逻辑
// packages/reactivity/src/reactive.ts:346-353
export function isReadonly(value: unknown): boolean {
return !!(value && (value as Target)[ReactiveFlags.IS_READONLY])
}
export function isShallow(value: unknown): boolean {
return !!(value && (value as Target)[ReactiveFlags.IS_SHALLOW])
}
// packages/reactivity/src/reactive.ts:327-332
export function isReactive(value: unknown): boolean {
if (isReadonly(value)) {
return isReactive((value as Target)[ReactiveFlags.RAW])
}
return !!(value && (value as Target)[ReactiveFlags.IS_REACTIVE])
}
// packages/reactivity/src/reactive.ts:363-365
export function isProxy(value: any): boolean {
return value ? !!value[ReactiveFlags.RAW] : false
}
| 函数 | 读取的 flag | 是否递归 | 特殊逻辑 |
|---|---|---|---|
isReactive | IS_REACTIVE(间接) | ✅ 遇 readonly 递归 RAW | readonly(reactive(x)) 也答 true |
isReadonly | IS_READONLY | ❌ | computed(无 setter)答 true |
isShallow | IS_SHALLOW | ❌ | shallowRef 也答 true(RefImpl 字段) |
isProxy | RAW | ❌ | 任何 Vue 代理答 true |
四者都只读虚拟探针字段,O(1)、无副作用,因此全部标记了 /*@__NO_SIDE_EFFECTS__*/(利于打包器 tree-shake 与死代码消除)。
3.12 实战与陷阱:五个高频踩坑
3.12.1 解构丢失响应式
const state = reactive({ count: 0, user: { name: 'a' } })
let { count } = state // ✗ count 是快照 0,脱离响应式
count++ // 不触发任何更新,state.count 仍是 0
const { count: countRef } = toRefs(state) // ✓ 拿到 ref
countRef.value++ // 触发
根因:state.count 的读取走 get 陷阱 track('count'),然后返回基础类型的值快照。解构赋值复制的是这个快照,与 state 的依赖关系在解构那一刻就断了。修复必须让"新变量仍指向源属性"——toRefs / toRef 通过 ObjectRefImpl 转发读写(3.6.5),保持连接。
进阶:解构
reactive对象的嵌套对象属性(const { user } = state)不丢响应式——因为user是对象,get陷阱返回的是递归代理,解构拿到的是代理引用,仍响应。丢的只是"当前这个属性本身被替换"的响应。
3.12.2 忘记 .value
const count = ref(0)
count = 1 // ✗ 重新赋值了局部变量,ref 被丢弃
count.value = 1 // ✓
const state = reactive({ count: ref(0) })
state.count = 1 // ✓ set 陷阱委托到 ref.value(3.3.9)
state.count++ // ✓
根因:ref 是对象,.value 才是值。在 <script setup> 里模板会自动解包(3.7.2),但JS 代码里没有这个魔法。v-model="count" 能工作是因为编译产物在赋值时识别出 count 是 ref 并补了 .value;裸写 count = 1 不会。
3.12.3 readonly 深层代理成本
const big = reactive(hugeNestedTree) // 只代理访问到的路径
const ro = readonly(big) // 再套一层只读代理
ro.a.b.c // 每读一层,readonly 代理 + 内层 reactive 代理 → 两次陷阱
成本:readonly(reactive(x)) 让每次深层访问要穿两层代理(外层 readonly 不 track 但要走 get 陷阱,内层 reactive 要 track)。对"读多写少的只读视图",更优的做法是只用 shallowReadonly 暴露顶层,或直接 markRaw 大型只读数据,避免为"永远不会写"的数据付双份代理成本。
3.12.4 Map 作为 key / 用代理对象作 key
const key = { id: 1 }
const m = reactive(new Map())
m.set(key, 'v')
m.get(key) // ✓ 'v'(raw key 命中,第一次)
m.set(reactive(key), 'w') // set 里第一次 has 失败 → key = toRaw(key) → 命中 raw key
// → 是 SET 而非 ADD,不会新增第二个 key
m.get(reactive(key)) // ✓ get 三级查找命中
但若混用:
m.set(key, 'a')
m.set(reactive(key), 'b') // 若实现有偏差,会 DEV 警告 checkIdentityKeys
Vue 用 checkIdentityKeys 在 DEV 兜底告警(3.9.7)。最佳实践:作 Map/Set 的 key 时统一用 raw 对象(不要用代理),可避免三级查找、toRaw 与身份不一致。
3.12.5 可运行验证示例
const { reactive, readonly, ref, toRaw, isReactive, isProxy, effect } = require('@vue/reactivity')
// ① 幂等 & 探针零成本(proxyMap 缓存)
const raw = { a: 1 }
console.log(reactive(raw) === reactive(raw)) // true
console.log(isReactive(reactive(raw)), isReactive(raw)) // true false
console.log(toRaw(reactive(raw)) === raw) // true
// ② readonly(reactive) 仍可被追踪(内层 reactive 在 track)
const r = readonly(reactive(raw))
let runs = 0
effect(() => { r.a; runs++ })
raw.a = 2
console.log(runs) // 2
// ③ ref 在 reactive 属性中被自动解包
const s = reactive({ n: ref(1) })
console.log(s.n) // 1
s.n = 5 // set 陷阱委托到 ref.value
console.log(isReactive(s), isProxy(s)) // true true
// ④ push 只在批末合并触发一次
const arr = reactive([])
let n = 0
effect(() => { n++; arr.length })
arr.push(1) // 首次 n=1,随后合并触发一次
console.log(n >= 2)
在仓库内可用 pnpm run build reactivity 后,node 引 packages/reactivity/dist/reactivity.cjs.js 复现这些断言。
3.13 本章要点回顾
- 四变体、两套 Handler 类:
reactive/readonly/shallowReactive/shallowReadonly共用createReactiveObject,差异全部下沉到_isReadonly/_isShallow两个布尔字段(baseHandlers.ts:49-53);四个 Handler 是模块级单例。 ReactiveFlags两类存在形式:SKIP/IS_REF/IS_SHALLOW(RefImpl)是真实字段;IS_REACTIVE/IS_READONLY/IS_SHALLOW/RAW(在代理上)是虚拟探针,由get陷阱动态求值,O(1) 无副作用。createReactiveObject的四次拦截:非对象 →RAW已存在(readonly(reactive)例外)→SKIP/不可扩展 →INVALID类型;proxyMap(WeakMap)保证"同 raw 同变体→同一代理"且不阻碍 GC。get分支顺序不可换:探针 → 数组方法注入 →Reflect.get(receiver 按isRef(target)切换)→ 不可追踪 key 过滤 →track→ shallow 短路 → ref 解包(数组整数索引例外)→isObject懒递归。set的三条归一化:toRaw新旧值(排除浅/只读)、ref 委托(oldValue.value = value)、target === toRaw(receiver)守卫;ADD/SET用Number(key) < length(数组)或hasOwn(对象)区分,值比较用Object.is。- 数组的三重特殊性:
isIntegerKey定义什么是索引;越界索引 =ADD→ 通知'length';length写入只通知被截断的索引 +ARRAY_ITERATE_KEY,非规范索引('01')是响应式黑洞。 ref的双值设计:_value(暴露值,深 ref 为toReactive)与_rawValue(比较基准,为toRaw)分离,避免代理/raw 身份差造成假变更;每个 ref 独占Dep,省去targetMap两级查找。- 自动解包三层机制:代理层(
baseHandlers.get的isRef分支)、setup 层(proxyRefs浅解包)、模板层(编译期.value);数组 + 整数索引在三处一致地不解包。 - 数组 instrumentations 的动机:
noTracking(pauseTracking+startBatch)切断length自依赖、避免push无限循环,同时保持对外部 effect 通知;searchProxy用"raw 优先 + 代理参数重试"解决身份查找;#15643用影子迭代器保护 V8 快速路径。 - 集合必须独立 handler:Map/Set 操作是方法调用而非属性访问,
collectionHandlers只挂一个get陷阱,把 20+ 方法替换为 instrumentation;get/has做"双 track + 三级查找",写操作做多重身份检查。 - 集合依赖键的分工:
ITERATE_KEY(结构/内容整体)、MAP_KEY_ITERATE_KEY(Map.keys()专用)、ARRAY_ITERATE_KEY(数组迭代);Map.set改 value 通知ITERATE_KEY但不通知MAP_KEY_ITERATE_KEY,size复用ITERATE_KEY。 - 性能账本:稳态
reactive(obj)= 4 次探针 + 1 次 WeakMap.get;每次get= O(1) 判断链 + 稳态Dep.track复用 Link;集合操作 = 1 次陷阱 + 多次toRaw+ 三级查找,比对象属性访问更贵。 - 逃生舱与优化武器:
markRaw(零代理开销)、shallowRef+triggerRef(整体替换/手动通知)、shallowReactive/shallowReadonly(只代理顶层)、toRaw(临时绕过代理但不长期持有)、customRef(自定义 track/trigger)。
下一章预告:第 04 章将进入 computed.ts / watch.ts / effectScope.ts,剖析 ComputedRefImpl 的脏标记与惰性求值、watch 的 deep/immediate/flush 调度,以及 effectScope 的嵌套作用域收集机制——届时本章反复出现的 Dep / Link / version / globalVersion 将成为理解 computed 缓存的直接前置。