Vue 3 核心源码深度剖析系列(v3.5.44)-第 03 章 · 响应式内幕(二):`reactive` / `ref` 与代理陷阱

5 阅读33分钟

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。这带来三个天花板:

  1. 新增属性不可响应。this.newField = 1 不会触发 setter,只能靠 Vue.set / this.$set 兜底(内部走 defineReactive 补挂)。
  2. 删除属性不可响应。delete this.foo 无感知,需要 Vue.delete。
  3. 数组索引赋值与 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_isShallowproxyMap源码
reactivemutableHandlersfalsefalsereactiveMapbaseHandlers.ts:251-252
shallowReactiveshallowReactiveHandlersfalsetrueshallowReactiveMapbaseHandlers.ts:257-258
readonlyreadonlyHandlerstruefalsereadonlyMapbaseHandlers.ts:254-255
shallowReadonlyshallowReadonlyHandlerstruetrueshallowReadonlyMapbaseHandlers.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 陷阱动态求值 !isReadonlyisReactive()、reactive() 的 readonly(reactive) 特判
IS_READONLY__v_isReadonly"这是 readonly 代理"由 get 陷阱动态返回 isReadonlyisReadonly()、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 里的自动解包

必须区分两种存在形式:

  1. 真实字段(存在对象自身上):SKIP(markRaw 写入)、IS_REF(各种 ref 类的字段)、IS_SHALLOW(RefImpl 构造函数写入)、以及 Dep 类的 readonly __v_skip = true(dep.ts:99,让 Dep 实例本身不被代理)。
  2. 虚拟字段(不在对象上存在,靠 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)truefalsefalseobj
shallowReactive(obj)truefalsetrueobj
readonly(obj)falsetruefalseobj
shallowReadonly(obj)falsetruetrueobj
原始对象 objundefinedundefinedundefinedundefined

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({}))) 的求值链:

  1. isReadonly(x) → 读 x.__v_isReadonly → true;
  2. 对 x.__v_raw(即内部 reactive 代理)递归;
  3. 内层不是 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 有三重收益:

  1. 身份一致性:同一 raw 对象在同一变体下的代理全局唯一,reactive(a) === reactive(a) 恒为 true,即使两次调用发生在不同组件、不同模块。
  2. 避免重复代理开销:new Proxy 相对昂贵(要建立元对象协议),缓存把它降到一次 WeakMap.get。
  3. 不阻止 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 四个导出函数的入参:

函数isReadonlybaseHandlerscollectionHandlersproxyMap行号
reactivefalsemutableHandlersmutableCollectionHandlersreactiveMap92-98
shallowReactivefalseshallowReactiveHandlersshallowCollectionHandlersshallowReactiveMap146-152
readonlytruereadonlyHandlersreadonlyCollectionHandlersreadonlyMap212-218
shallowReadonlytrueshallowReadonlyHandlersshallowReadonlyCollectionHandlersshallowReadonlyMap253-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
  }
}

两点必须注意:

  1. !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 级迭代追踪。

  2. 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.get vs target[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 被排除追踪:

  1. 内建 Symbol(Symbol.iterator、Symbol.toPrimitive、Symbol.toStringTag……)。访问它们不会 track。原因:这些 symbol 通常是"元操作",每个对象都一样,追踪它们会造成巨大的依赖爆炸且毫无意义。源码在构造 builtInSymbols 时特意过滤了 'arguments' 与 'caller',注释解释:iOS 10.x 的 Object.getOwnPropertyNames(Symbol) 会枚举出这两个,但在 Symbol(严格模式函数)上访问它们会抛 TypeError(baseHandlers.ts:30-39)。
  2. __proto__:访问原型不该触发更新。
  3. __v_isRef:这是 ref 解包的判据,若被追踪会导致解包时产生不必要的依赖。
  4. __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
}

这是"自动解包"的运行时实现核心。逐分支:

  1. 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 是字符串(数组索引本来就是字符串形式)。

  1. res.value:触发 RefImpl.get value,即对 ref 自身的 dep.track()。这一步让"computed ref 被访问"能建立依赖。

  2. 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',对象 track ITERATE_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 静默失败
hasReflect.hastrack(HAS, key);内建 symbol 不 track只 track 不递归
deleteProperty记旧值 → Reflect.deleteProperty → 存在才 triggerDELETE(含 ITERATE_KEY 副作用)删不存在的 key 不 trigger
ownKeysReflect.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]]),实际不写入。

值得深思的两点:

  1. return true 而不是抛错。这决定了 readonly 对象上的写入是"静默失败 + DEV 警告",而不是运行时异常。这在生产环境是宽容的(不会因为一次意外写入白屏),在开发环境有警告提醒。
  2. 没有 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 四变体差异总表

维度mutableHandlersshallowReactiveHandlersreadonlyHandlersshallowReadonlyHandlers
_isReadonlyfalsefalsetruetrue
_isShallowfalsetruefalsetrue
get:track✅✅❌❌
get:ref 自动解包✅❌(短路)✅(再套 readonly)❌
get:嵌套递归代理✅❌✅❌
set:实际写入✅✅❌(警告)❌(警告)
set:ref 委托✅❌❌❌
set:toRaw 归一化✅❌❌❌
deleteProperty真实删除真实删除警告警告
hastrack(HAS)track(HAS)无(引擎默认)无(引擎默认)
ownKeystrack(ITERATE)track(ITERATE)无无
IS_REACTIVEtruetruefalsefalse
IS_READONLYfalsefalsetruetrue
IS_SHALLOWfalsetruefalsetrue

表中 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) < lengthhadKeytype副作用
arr[0] = x0 < 3 ✓trueSET(值变才触发)无结构变更
arr[2] = x2 < 3 ✓trueSET无
arr[3] = x3 < 3 ✗falseADDtrigger 里额外通知 'length' 依赖
arr[10] = x10 < 3 ✗falseADD同上;数组变稀疏,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:

  1. key === 'length':直接依赖长度的 effect(如 arr.length 作为展示);
  2. key === ARRAY_ITERATE_KEY:各种 instrumented 迭代方法(map/filter/forEach/展开运算符等,见 3.8)收集的迭代依赖;
  3. !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
  }
字段类型语义谁写谁读
_valueT(public,@internal)对外暴露的值。深 ref 下是 toReactive(value) 的结果(可能是代理)构造函数、set valueget value 返回、triggerRef 的 debugInfo
_rawValueT(private)原始值。深 ref 下是 toRaw(value);用于 hasChanged 比较构造函数、set valueset value 里的 oldValue
depDep该 ref 专属的依赖容器(每个 ref 一个,无共享)构造函数 new Dep()get value 的 track、set value 的 trigger、triggerRef、ObjectRefImpl.dep
__v_isReftrue真实字段,isRef 的判据类字段初始化isRef()、baseHandlers 解包
__v_isShallowboolean浅 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

三个细节值得注意:

  1. _isString 与 _isRef 的双路:ref="foo"(字符串)走 refs + setupState 双写;:ref="myRef"(ref 对象)走 myRef.value = value,同时若 rawRef.k 存在也写 refs[k]。
  2. 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.gettargetIsArray && 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) },

逐行解释:

  1. pauseTracking()(effect.ts:525-528):把当前 shouldTrack 压栈并置 false。此后任何 track() 调用都会因 shouldTrack 为假而直接 return(dep.ts:263)。关键:pauseTracking 是栈式的(trackStack.push),支持嵌套恢复。
  2. startBatch()(effect.ts:265-267):batchDepth++。批处理让 endBatch 之前的所有 trigger 只标记 effect 为待执行(EffectFlags.NOTIFIED),不真正调度。
  3. (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 未暂停)。
  4. endBatch():batchDepth--;归零时统一执行所有被标记的 effect(effect.ts:273-310)。于是多次 trigger 合并为一次调度。
  5. 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 的执行序列:

  1. 读 arr.length(走 get 陷阱)→ track('length');
  2. 写 arr[len] = 1(走 set 陷阱)→ 若 len < length 判定 → 若是新增则 ADD → trigger('length');
  3. 写 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
}

三个要点:

  1. needsWrap:arr !== self && !isShallow(self)——只有当"目标不是浅代理、且 toRaw 确实拿回了不同对象(即 self 是代理)"时才需要包装元素。对 raw 数组或浅数组,跳过包装(保持原样)。这是零成本的快速路径。
  2. else if (fn.length > 2) 分支:当 arr !== self(代理)但 !needsWrap(浅)时,回调第三参数 array 需换成 self(代理)而非 raw arr;判 fn.length > 2 是因为只有回调声明了第三参数才需要传 self,否则省掉引用可减少闭包分配——基于 Function.length 的微优化。
  3. #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/splicenoTracking:暂停收集 + 批处理无(事务内不收集)避免 length 自依赖 → 无限循环
includes/indexOf/lastIndexOfsearchProxy:raw 优先,失败再对参数 toRaw 重试ARRAY_ITERATE_KEY最坏两次 O(n)
map/filter/every/some/find/findIndex/findLast/findLastIndex/forEachapply:shallowReadArray + 包装元素ARRAY_ITERATE_KEY第三参数传 self
reduce/reduceRightreduce:额外包装初始累加器ARRAY_ITERATE_KEY无初始值时包首元素
join/concat/toReversed/toSorted/toSplicedreactiveReadArrayARRAY_ITERATE_KEY返回 raw 数组副本
keys/values/entries/Symbol.iteratoriterator + 包装 nextARRAY_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)
  }
}

这段是集合里最复杂的一个。逐步拆解:

  1. this[ReactiveFlags.RAW]:instrumentation 函数被调用时,this 是集合代理,this[RAW] 拿到原始 Map。注意:读 RAW 不 track(createInstrumentationGetter 的 RAW 分支提前 return)。
  2. toRaw(target):target 可能是内层代理(readonly(reactive(map)) 场景),toRaw 剥到最底层 raw。
  3. rawKey = toRaw(key):key 也可能是代理(例如用 reactive 对象做 key)。归一化。
  4. 双 track:
    • hasChanged(key, rawKey) → 说明 key 本身是代理(有"身份差")。此时对 代理 key 也 track 一次(#1772 相关:只读场景下 key 也需被追踪);
    • 对 rawKey 无条件 track。
    • 为什么同时挂两个键?因为用户可能用代理 key 查询,也可能用 raw key 查询;trigger 时会用实际存储的 key 通知,两套都要能命中。
  5. getProto(rawTarget).has:拿原型上的原生 has 并 call(避免 this 是代理导致递归)。
  6. 三级查找:先用原 key(可能是代理)、再用 rawKey、最后(target !== rawTarget,即存在内层代理时)调 target.get(key)——后者是为了触发内层 reactive 代理的 track(#3602)。注释说得很清楚:ensure that the nested reactive Map can do tracking for itself。
  7. 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 的 set instrumentation(见 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 检查:

  1. has(target, valueToAdd):查"最终归一化后的值"是否已存在;
  2. hasChanged(value, valueToAdd) && has(target, value):若原值与归一值不同(说明原值是代理),查"原代理值"是否已存在;
  3. 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
  }
}

只读集合的写方法返回值的差异很讲究,是为了匹配各原生方法的契约:

方法原生返回只读版本返回原因
deletebooleanfalse谎称"没删掉",语义上合理(确实没删)
clearundefinedundefined匹配原生
add / setthis(集合本身)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_KEYITERATE_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 typetarget通知的依赖键
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是否递归特殊逻辑
isReactiveIS_REACTIVE(间接)✅ 遇 readonly 递归 RAWreadonly(reactive(x)) 也答 true
isReadonlyIS_READONLY❌computed(无 setter)答 true
isShallowIS_SHALLOW❌shallowRef 也答 true(RefImpl 字段)
isProxyRAW❌任何 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 本章要点回顾

  1. 四变体、两套 Handler 类:reactive/readonly/shallowReactive/shallowReadonly 共用 createReactiveObject,差异全部下沉到 _isReadonly/_isShallow 两个布尔字段(baseHandlers.ts:49-53);四个 Handler 是模块级单例。
  2. ReactiveFlags 两类存在形式:SKIP/IS_REF/IS_SHALLOW(RefImpl)是真实字段;IS_REACTIVE/IS_READONLY/IS_SHALLOW/RAW(在代理上)是虚拟探针,由 get 陷阱动态求值,O(1) 无副作用。
  3. createReactiveObject 的四次拦截:非对象 → RAW 已存在(readonly(reactive) 例外)→ SKIP/不可扩展 → INVALID 类型;proxyMap(WeakMap)保证"同 raw 同变体→同一代理"且不阻碍 GC。
  4. get 分支顺序不可换:探针 → 数组方法注入 → Reflect.get(receiver 按 isRef(target) 切换)→ 不可追踪 key 过滤 → track → shallow 短路 → ref 解包(数组整数索引例外)→ isObject 懒递归。
  5. set 的三条归一化:toRaw 新旧值(排除浅/只读)、ref 委托(oldValue.value = value)、target === toRaw(receiver) 守卫;ADD/SET 用 Number(key) < length(数组)或 hasOwn(对象)区分,值比较用 Object.is。
  6. 数组的三重特殊性:isIntegerKey 定义什么是索引;越界索引 = ADD → 通知 'length';length 写入只通知被截断的索引 + ARRAY_ITERATE_KEY,非规范索引('01')是响应式黑洞。
  7. ref 的双值设计:_value(暴露值,深 ref 为 toReactive)与 _rawValue(比较基准,为 toRaw)分离,避免代理/raw 身份差造成假变更;每个 ref 独占 Dep,省去 targetMap 两级查找。
  8. 自动解包三层机制:代理层(baseHandlers.get 的 isRef 分支)、setup 层(proxyRefs 浅解包)、模板层(编译期 .value);数组 + 整数索引在三处一致地不解包。
  9. 数组 instrumentations 的动机:noTracking(pauseTracking + startBatch)切断 length 自依赖、避免 push 无限循环,同时保持对外部 effect 通知;searchProxy 用"raw 优先 + 代理参数重试"解决身份查找;#15643 用影子迭代器保护 V8 快速路径。
  10. 集合必须独立 handler:Map/Set 操作是方法调用而非属性访问,collectionHandlers 只挂一个 get 陷阱,把 20+ 方法替换为 instrumentation;get/has 做"双 track + 三级查找",写操作做多重身份检查。
  11. 集合依赖键的分工:ITERATE_KEY(结构/内容整体)、MAP_KEY_ITERATE_KEY(Map.keys() 专用)、ARRAY_ITERATE_KEY(数组迭代);Map.set 改 value 通知 ITERATE_KEY 但不通知 MAP_KEY_ITERATE_KEY,size 复用 ITERATE_KEY。
  12. 性能账本:稳态 reactive(obj) = 4 次探针 + 1 次 WeakMap.get;每次 get = O(1) 判断链 + 稳态 Dep.track 复用 Link;集合操作 = 1 次陷阱 + 多次 toRaw + 三级查找,比对象属性访问更贵。
  13. 逃生舱与优化武器: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 缓存的直接前置。