Skip to content

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

本章要点

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

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

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

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

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

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

从使用到实现

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

typescript
import { reactive } from 'vue'

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

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

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

reactive() 的入口

typescript
// packages/reactivity/src/reactive.ts

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

注意两个关键细节:

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

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

typescript
// packages/reactivity/src/reactive.ts

function createReactiveObject(
  target: object,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<object, any>
) {
  // 1. 非对象类型直接返回
  if (!isObject(target)) {
    return target
  }

  // 2. 已经是代理了,直接返回(除非要对 reactive 对象做 readonly)
  if (target[ReactiveFlags.RAW] && !(isReadonly && target[ReactiveFlags.IS_REACTIVE])) {
    return target
  }

  // 3. 检查缓存
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }

  // 4. 检查目标类型是否可以被代理
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target  // ← 标记了 __v_skip 或被冻结的对象不代理
  }

  // 5. 创建代理
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION
      ? collectionHandlers  // Map/Set
      : baseHandlers         // 普通对象/数组
  )

  // 6. 存入缓存
  proxyMap.set(target, proxy)
  return proxy
}

🔥 深度洞察

getTargetType() 函数会检查对象的 Object.isExtensible() 状态。被冻结(Object.freeze())或被密封(Object.seal())的对象不会被代理。这不是一个任意的限制——Proxy 规范要求代理的行为必须与目标对象的不变量(invariant)一致。如果目标对象的属性是不可配置的,Proxy 的 get trap 必须返回与目标属性相同的值。对冻结对象创建响应式代理会导致 Proxy 内部抛出 TypeError——Vue 选择在入口处就避免这种情况。

mutableHandlers:五大拦截陷阱

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

typescript
// packages/reactivity/src/baseHandlers.ts

export const mutableHandlers: ProxyHandler<object> = {
  get,           // 拦截属性读取 → 依赖收集
  set,           // 拦截属性赋值 → 触发更新
  deleteProperty, // 拦截 delete → 触发更新
  has,           // 拦截 in 操作符 → 依赖收集
  ownKeys        // 拦截 Object.keys() / for...in → 依赖收集
}

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

typescript
// packages/reactivity/src/baseHandlers.ts(简化)

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

  const targetIsArray = isArray(target)

  // 2. 数组方法的特殊处理
  if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
    return Reflect.get(arrayInstrumentations, key, receiver)
  }

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

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

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

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

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

  return res
}

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

惰性代理(Lazy Proxy)

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

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

自动 ref 解包

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

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

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

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

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

typescript
// packages/reactivity/src/baseHandlers.ts(简化)

function set(
  target: object,
  key: string | symbol,
  value: unknown,
  receiver: object
): boolean {
  let oldValue = (target as any)[key]

  // 1. 如果旧值是 ref 而新值不是,更新 ref 的 .value
  if (isRef(oldValue) && !isRef(value)) {
    oldValue.value = value
    return true
  }

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

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

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

  return result
}

关键细节:

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

Trap 3–5: deleteProperty / has / ownKeys

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

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

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

💡 最佳实践

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

数组方法的特殊处理

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

typescript
// packages/reactivity/src/baseHandlers.ts(简化)

const arrayInstrumentations: Record<string, Function> = {}

// 查找方法:includes、indexOf、lastIndexOf
;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
  arrayInstrumentations[key] = function(this: unknown[], ...args: unknown[]) {
    const arr = toRaw(this)
    // 追踪数组每个元素的访问
    for (let i = 0, l = this.length; i < l; i++) {
      track(arr, TrackOpTypes.GET, i + '')
    }
    // 先用原始参数查找
    const res = arr[key](...args)
    if (res === -1 || res === false) {
      // 如果没找到,用 toRaw 后的参数再试一次
      return arr[key](...args.map(toRaw))
    }
    return res
  }
})

// 变异方法:push、pop、shift、unshift、splice
;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
  arrayInstrumentations[key] = function(this: unknown[], ...args: unknown[]) {
    pauseTracking()     // ← 暂停依赖收集!
    const res = (toRaw(this) as any)[key].apply(this, args)
    resetTracking()     // ← 恢复依赖收集
    return res
  }
})

基于 VitePress 构建