读懂 Vue3 响应式源码:从枚举到 Proxy 拦截器

2 阅读14分钟

Vue3 响应式系统源码深度解析

之前读 Vue3 响应式源码的时候,把几个核心文件的逻辑整理了一遍。这篇文章会按照源码的调用链路,从枚举定义到 Proxy 拦截器,把整个响应式系统串起来讲清楚。如果你正在准备面试或者想深入理解 Vue3 的底层设计,希望这篇文章能帮到你。

前置知识:枚举常量

Vue3 响应式系统里有一份常量定义文件 constants.ts,用枚举把所有操作类型和标志位都统一定义好了。先花一分钟过一遍,后面会反复用到。

TrackOpTypes —— 读取操作类型

export enum TrackOpTypes {
  GET = 'get',        // 读取属性
  HAS = 'has',        // in 操作符检查
  ITERATE = 'iterate' // 遍历操作(for...in / Object.keys)
}

这三个值描述的是"你怎么读数据"。当你在代码里访问响应式对象的属性时,Proxy 的拦截器会根据具体的操作类型调用 track() 来收集依赖。比如 user.name 触发 GET'name' in user 触发 HASfor (let key in user) 触发 ITERATE

TriggerOpTypes —— 写入操作类型

export enum TriggerOpTypes {
  SET = 'set',       // 修改已有属性
  ADD = 'add',       // 新增属性
  DELETE = 'delete', // 删除属性
  CLEAR = 'clear'    // 清空集合(Map/Set.clear)
}

这四个值描述的是"你怎么改数据"。当你修改响应式数据时,Proxy 的拦截器会根据操作类型调用 trigger() 来通知相关的副作用重新执行。比如 user.name = '张三' 触发 SETuser.age = 18(之前没有 age)触发 ADDdelete user.age 触发 DELETE

ReactiveFlags —— 内部标志位

export enum ReactiveFlags {
  SKIP = '__v_skip',           // 跳过代理(markRaw)
  IS_REACTIVE = '__v_isReactive',   // 是否是 reactive 对象
  IS_READONLY = '__v_isReadonly',   // 是否是 readonly 对象
  IS_SHALLOW = '__v_isShallow',     // 是否是浅响应式
  RAW = '__v_raw',             // 获取原始对象
  IS_REF = '__v_isRef',        // 是否是 Ref 对象
}

这些标志位是 Vue 内部用来判断对象类型的。比如 toRaw(reactiveObj) 实际上就是读取 __v_raw 属性来拿到原始对象,markRaw(obj) 则是给对象打上 __v_skip 标记让它永远不被转成响应式。


依赖管理:Dep 和 targetMap

搞清楚枚举之后,接下来看 Vue 是怎么存储和管理依赖关系的。

Dep 类

Vue3 里每个响应式属性都对应一个 Dep 实例,可以把它理解成一份"订阅名单"。Dep 内部用链表结构存储所有依赖这个属性的 effect(副作用函数),主要做两件事:

  • track() —— 把当前正在执行的 effect 加入订阅名单
  • trigger() —— 通知名单里所有 effect 重新执行

用链表而不是数组来存 effect,是因为依赖关系会频繁增删,链表在这方面的性能更好。

class Dep {
  subs = null     // 订阅者链表
  version = 0     // 版本号,后面 computed 缓存会用到

  track() {
    if (activeSub) {
      // 把当前 activeSub 挂到链表上
    }
  }

  trigger() {
    this.version++    // 版本号 +1
    globalVersion++   // 全局版本 +1
    this.notify()     // 通知所有订阅者
  }

  notify() {
    startBatch() // 开启批量更新
    try {
      // 从后往前遍历 effect 链表
      for (let link = this.subs; link; link = link.prevSub) {
        if (link.sub.notify()) {
          // 如果是 computed,递归通知它的订阅者
          link.sub.dep.notify()
        }
      }
    } finally {
      endBatch() // 结束批量更新
    }
  }
}

targetMap —— 全局依赖注册表

export const targetMap: WeakMap<object, Map<any, Dep>> = new WeakMap()

targetMap 是一个三层嵌套结构,存储关系是:原始对象 → 属性名 → Dep 实例

targetMap (WeakMap)
  └── key: 原始对象(如 user)
        └── value: depsMap (Map)
              ├── key: 'name'  → Dep 实例
              ├── key: 'age'   → Dep 实例
              └── key: ITERATE_KEY → Dep 实例(遍历专用)

WeakMap 的好处是:当原始对象被销毁后,对应的依赖存储会被自动回收,不用担心内存泄漏。

遍历操作的三个特殊 Key

export const ITERATE_KEY: unique symbol = Symbol('Object iterate')
export const MAP_KEY_ITERATE_KEY: unique symbol = Symbol('Map keys iterate')
export const ARRAY_ITERATE_KEY: unique symbol = Symbol('Array iterate')

遍历操作(for...inObject.keys、数组遍历)不对应某个具体属性,所以 Vue 用 Symbol 创建了几个"虚拟 key" 来专门处理这类依赖。当你新增或删除属性时,除了触发属性本身的更新,还要触发这些虚拟 key 对应的 Dep,这样遍历操作才能正确响应变化。


依赖收集与派发更新

track() —— 依赖收集

当你读取响应式数据时,Proxy 的 get 拦截器会调用 track()

export function track(target: object, type: TrackOpTypes, key: unknown): void {
  if (shouldTrack && activeSub) {
    // 1. 获取或创建 对象 → depsMap
    let depsMap = targetMap.get(target)
    if (!depsMap) targetMap.set(target, (depsMap = new Map()))

    // 2. 获取或创建 属性名 → Dep
    let dep = depsMap.get(key)
    if (!dep) depsMap.set(key, (dep = new Dep()))

    // 3. 把当前 effect 加入 Dep
    dep.track()
  }
}

整个过程很直接:先从 targetMap 找到对象对应的 depsMap,再从 depsMap 找到属性对应的 Dep,最后把当前正在运行的 effect 加进去。

trigger() —— 派发更新

当你修改响应式数据时,Proxy 的 set/delete 拦截器会调用 trigger()。这里是 Vue 响应式里逻辑最复杂的地方,因为不同操作需要触发不同的依赖:

export function trigger(...) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  startBatch()

  if (type === TriggerOpTypes.CLEAR) {
    // 清空集合 → 所有属性都要更新
    depsMap.forEach(dep => dep.trigger())
  } else if (targetIsArray && key === 'length') {
    // 修改数组长度 → length + 迭代器 + 超出新长度的索引都要更新
    ...
  } else {
    // 1. 触发当前属性的更新
    run(depsMap.get(key))
    // 2. 数组索引修改 → 触发数组遍历依赖
    if (isArrayIndex) run(depsMap.get(ARRAY_ITERATE_KEY))
    // 3. 新增/删除属性 → 触发 for...in 遍历依赖
    if (type === ADD || type === DELETE) {
      run(depsMap.get(ITERATE_KEY))
    }
  }

  endBatch()
}

为什么要这么精细?因为 Vue 追求的是精准更新——改了 name 就只更新用到 name 的地方,不会牵连其他无关的渲染。数组新增元素时,除了更新对应索引,还要更新依赖数组遍历的副作用;新增或删除属性时,for...in 的结果也会变,所以遍历依赖也要触发。


批量更新机制

如果你连续修改了三个属性,Vue 不会每次修改都立即触发渲染。它用了一套批量更新的机制,把所有需要执行的 effect 先收集起来,最后一次性跑完。

let batchDepth = 0
let batchedSub: Subscriber | undefined       // 普通 effect 队列
let batchedComputed: Subscriber | undefined  // computed 队列

export function batch(sub: Subscriber, isComputed = false): void {
  sub.flags |= EffectFlags.NOTIFIED
  if (isComputed) {
    sub.next = batchedComputed
    batchedComputed = sub
  } else {
    sub.next = batchedSub
    batchedSub = sub
  }
}

export function startBatch(): void {
  batchDepth++
}

export function endBatch(): void {
  if (--batchDepth > 0) return
  // 先处理 computed
  while (batchedComputed) { ... }
  // 再执行普通 effect
  while (batchedSub) {
    let e = batchedSub
    batchedSub = undefined
    while (e) {
      e.flags &= ~EffectFlags.NOTIFIED
      if (e.ACTIVE) (e as ReactiveEffect).trigger()
      e = e.next
    }
  }
}

batchDepth 支持嵌套批量操作,只有最外层的 endBatch() 才会真正执行队列。执行顺序是先处理 computed,再处理普通的渲染 effect 和 watch,因为 computed 的值可能被其他 effect 依赖。


依赖清理:自动追踪与自动删除

这是 Vue3 相比 Vue2 的一个重要改进——依赖可以自动清理。

prepareDeps —— 执行前标记

function prepareDeps(sub: Subscriber) {
  for (let link = sub.deps; link; link = link.nextDep) {
    link.version = -1 // 标记为"待验证"
    link.dep.activeLink = link
  }
}

effect 重新执行之前,先把所有旧依赖的版本号标记为 -1

cleanupDeps —— 执行后清理

function cleanupDeps(sub: Subscriber) {
  let link = sub.depsTail
  while (link) {
    const prev = link.prevDep
    if (link.version === -1) {
      // 版本号还是 -1,说明这次执行没用到 → 移除
      removeSub(link)
      removeDep(link)
    }
    link = prev
  }
}

effect 执行完之后,如果某个旧依赖的版本号仍然是 -1,说明这次执行过程中没有访问到它,就会自动从订阅列表中删除。

举个实际的例子:

effect(() => {
  if (user.age > 18) {
    console.log(user.name)
  } else {
    console.log(user.address)
  }
})

age 从 17 变成 20 时,effect 重新执行,走了 if 分支,只访问了 name。这时 cleanupDeps 发现 address 的版本号还是 -1,就会把它从依赖列表中移除。以后修改 address 就不会再触发这个 effect 了。


脏检测与 computed 缓存

isDirty —— 判断是否需要重新计算

function isDirty(sub: Subscriber): boolean {
  for (let link = sub.deps; link; link = link.nextDep) {
    if (link.dep.version !== link.version) return true
  }
  return false
}

每个 Dep 都有一个 version,每次触发更新时会 version++。effect 记录的是依赖当时的版本号,如果两者不一致,说明数据变了,computed 需要重新计算。

refreshComputed —— computed 的刷新逻辑

export function refreshComputed(computed: ComputedRefImpl): undefined {
  if (computed.globalVersion === globalVersion) return
  computed.globalVersion = globalVersion

  if (!isDirty(computed)) return // 不脏,直接用缓存

  prepareDeps(computed)
  const value = computed.fn()
  computed._value = value
  computed.dep.version++ // 值变了,通知订阅者
  cleanupDeps(computed)
}

computed 的设计有三个关键特性:

  1. 惰性计算 —— 不访问就不执行,不会浪费算力
  2. 强缓存 —— 依赖没变就永远用缓存值
  3. 级联更新 —— 自身值变了会通知依赖它的 effect

这也是为什么 Vue 官方推荐用 computed 代替复杂的模板表达式——它的缓存机制能避免大量重复计算。


effect —— 响应式副作用的入口

export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
  const e = new ReactiveEffect(fn)
  extend(e, options)
  e.run() // 立即执行一次,收集依赖
  const runner = e.run.bind(e)
  runner.effect = e
  return runner
}

effect 是 Vue 响应式系统的统一入口。组件的 render 函数、computedwatch,底层都是通过 effect 来实现的。创建 effect 时会立即执行一次 run(),执行过程中访问到的响应式数据会自动收集依赖。之后当这些数据变化时,effect 就会被自动触发重新执行。


reactive —— 创建响应式对象

四个 API 和四个缓存

reactive.ts 导出了四个常用的响应式 API:

API深度可写
reactive()深层
shallowReactive()浅层
readonly()深层
shallowReadonly()浅层

每个 API 都有对应的 WeakMap 缓存,作用是保证同一个对象不会被重复代理:

export const reactiveMap: WeakMap<Target, any> = new WeakMap()
export const shallowReactiveMap: WeakMap<Target, any> = new WeakMap()
export const readonlyMap: WeakMap<Target, any> = new WeakMap()
export const shallowReadonlyMap: WeakMap<Target, any> = new WeakMap()

createReactiveObject —— 工厂函数

四个 API 最终都走 createReactiveObject 这个工厂函数,逻辑大致是:

  1. 不是对象 → 直接返回
  2. 已经是代理对象 → 直接返回
  3. 被标记为跳过(markRaw)→ 直接返回
  4. 查缓存,已有代理 → 直接返回
  5. 根据目标类型选择 handler:普通对象用 baseHandlers,Map/Set 用 collectionHandlers
  6. 创建 Proxy,存入缓存,返回

三个工具函数

// 拿到原始对象(去掉代理层)
export function toRaw(observed) {
  const raw = observed[ReactiveFlags.RAW]
  return raw ? toRaw(raw) : observed
}

// 标记对象永远不做响应式
export function markRaw(value) {
  def(value, ReactiveFlags.SKIP, true)
  return value
}

// 是对象就转 reactive,不是就原样返回
export const toReactive = (value) =>
  isObject(value) ? reactive(value) : value

Proxy 拦截器 —— 响应式的真正心脏

前面讲了依赖收集和派发更新的机制,但它们是怎么被触发的?答案就在 Proxy 拦截器里。

BaseReactiveHandler —— 读取拦截

所有响应式对象共享的基础拦截器,核心是 get

class BaseReactiveHandler {
  get(target, key, receiver) {
    // 1. 处理内部标志位
    if (key === ReactiveFlags.IS_REACTIVE) return !this._isReadonly
    if (key === ReactiveFlags.IS_READONLY) return this._isReadonly
    if (key === ReactiveFlags.RAW) return target

    // 2. 读取真实值
    const res = Reflect.get(target, key, receiver)

    // 3. 依赖收集(只读对象不需要)
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }

    // 4. 浅层响应式直接返回
    if (isShallow) return res

    // 5. ref 自动解包
    if (isRef(res)) return res.value

    // 6. 深层响应式:值是对象则递归代理
    if (isObject(res)) return reactive(res)

    return res
  }
}

这里有个细节值得注意:reactive 的深层响应式是懒加载的。不是一开始就把所有嵌套对象都变成响应式,而是当你访问到某个属性、发现它的值是对象时,才递归调用 reactive()。这样既节省了初始化开销,也避免了不必要的代理创建。

另外,在 reactive 对象里使用 ref 时不需要写 .value,就是因为第 5 步的自动解包逻辑。

MutableReactiveHandler —— 写入拦截

继承基础拦截器,增加了 setdeletePropertyhasownKeys 四个拦截:

set 拦截器(最核心的一个):

set(target, key, value, receiver) {
  let oldValue = target[key]
  const hadKey = hasOwn(target, key)
  const result = Reflect.set(target, key, value)

  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
}

注意这里有个 hasChanged 判断——如果新值和旧值相同,就不会触发更新。这个细节能避免很多无意义的渲染。

deleteProperty 拦截器

deleteProperty(target, key) {
  const hadKey = hasOwn(target, key)
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    trigger(target, TriggerOpTypes.DELETE, key)
  }
  return result
}

has 拦截器in 操作符):

has(target, key) {
  track(target, TrackOpTypes.HAS, key)
  return Reflect.has(target, key)
}

ownKeys 拦截器(遍历操作):

ownKeys(target) {
  track(target, TrackOpTypes.ITERATE, ITERATE_KEY)
  return Reflect.ownKeys(target)
}

ReadonlyReactiveHandler —— 只读拦截

class ReadonlyReactiveHandler extends BaseReactiveHandler {
  set() {
    console.warn('Set operation on key failed: target is readonly.')
    return true
  }
  deleteProperty() {
    console.warn('Delete operation on key failed: target is readonly.')
    return true
  }
}

只读拦截器继承了读取逻辑,但把写入和删除操作都拦截掉了,只给一个警告提示。


ref —— 基本类型的响应式方案

reactive 只能处理对象,对于数字、字符串这类基本类型就需要用 ref 了。

RefImpl —— ref 的核心实现

class RefImpl {
  _value         // 响应式值(对象会自动转 reactive)
  _rawValue      // 原始值
  dep = new Dep() // 依赖管理器

  constructor(value, isShallow) {
    this._rawValue = value
    this._value = toReactive(value)
  }

  get value() {
    this.dep.track()  // 收集依赖
    return this._value
  }

  set value(newValue) {
    if (hasChanged(newValue, this._rawValue)) {
      this._rawValue = newValue
      this._value = toReactive(newValue)
      this.dep.trigger() // 触发更新
    }
  }
}

ref 的原理比 reactive 简单得多——就是用 getter/setter 包了一个 .value。读的时候收集依赖,写的时候触发更新。如果传入的是对象,会自动调用 toReactive() 转成 reactive

ref 家族的几个 API

API说明
ref(value)创建深层响应式 ref
shallowRef(value)浅层 ref,只有 .value 的替换会触发更新
triggerRef(ref)手动触发 shallowRef 的更新
toRef(object, key)将 reactive 对象的某个属性转为 ref,和源属性保持同步
toRefs(object)将 reactive 对象的所有属性转为 ref,解构时不丢失响应式

toReftoRefs 在实际开发中很常用。当你需要把 reactive 对象的属性传给子组件或者解构使用时,直接解构会丢失响应式,用 toRefs 包一层就行:

const state = reactive({ name: '张三', age: 18 })
const { name, age } = toRefs(state)
// 现在 name 和 age 都是 ref,解构后仍然有响应式

computed —— 带缓存的计算属性

ComputedRefImpl

export class ComputedRefImpl {
  _value: any            // 缓存的计算结果
  dep: Dep = new Dep()   // 谁用了我
  deps?: Link            // 我用了谁
  flags: DIRTY           // 脏标记

  notify() {
    this.flags |= DIRTY    // 只标记脏,不马上算
    batch(this, true)      // 加入 computed 队列
    return true
  }

  get value() {
    this.dep.track()       // 收集依赖
    refreshComputed(this)  // 脏了就重算
    return this._value     // 返回缓存
  }

  set value(newValue) {
    if (this.setter) {
      this.setter(newValue)
    } else {
      console.warn('Write operation failed: computed value is readonly')
    }
  }
}

computed 有一个"双重身份":它既被别人依赖(通过 dep 记录),也依赖别人(通过 deps 记录)。当它依赖的数据变化时,notify() 只是把脏标记置位,不会立即重新计算。等到有人访问 .value 时,才通过 refreshComputed() 判断是否需要重算。

computed 的完整生命周期

用一个例子来说明:

const fullName = computed(() => firstName.value + lastName.value)
  1. 创建时 —— 不会执行计算函数,只是创建了一个 ComputedRefImpl 实例
  2. 第一次访问 fullName.value —— 发现是脏的,执行计算函数,收集 firstNamelastName 作为依赖,缓存结果
  3. 再次访问 —— 不脏,直接返回缓存,不执行计算函数
  4. firstName 变了 —— computed 收到通知,只标记脏了,不计算
  5. 再次访问 —— 脏了,重新计算,更新缓存

这个"标记脏但不立即计算"的设计,是 computed 性能好的关键。如果一个 computed 依赖的数据变了但没人用到这个 computed 的值,那计算函数根本不会执行。


把整个流程串起来

到这里,Vue3 响应式系统的核心模块都过了一遍。最后用一段代码的执行过程把所有环节串起来:

const user = reactive({ name: '张三', age: 18 })

effect(() => {
  document.getElementById('app').textContent = user.name
})

user.name = '李四'
  1. reactive({ name: '张三', age: 18 }) —— 通过 createReactiveObject 创建 Proxy 代理,存入 reactiveMap 缓存
  2. effect(fn) —— 创建 ReactiveEffect,立即执行一次 fn
  3. 执行 fn 时访问 user.name —— 触发 Proxy 的 get 拦截器
  4. get 拦截器调用 track(user, GET, 'name') —— 在 targetMap 中建立 user → name → Dep 的关系,把当前 effect 加入 Dep
  5. user.name = '李四' —— 触发 Proxy 的 set 拦截器
  6. set 拦截器调用 trigger(user, SET, 'name') —— 找到 name 对应的 Dep
  7. Dep 调用 trigger()notify() → 把 effect 加入批量队列
  8. endBatch() 执行队列中的 effect —— effect 重新执行,DOM 更新

整个过程就是:读数据时收集依赖,改数据时触发更新,批量执行避免重复渲染。


写在最后

Vue3 的响应式系统相比 Vue2 有几个明显的优势:

  • 用 Proxy 替代 Object.defineProperty,天然支持属性的新增和删除
  • 依赖自动清理,不会像 Vue2 那样需要开发者手动处理
  • 批量更新机制,多次数据修改只触发一次渲染
  • computed 的惰性计算和缓存机制,避免无意义的重复计算
  • WeakMap 存储依赖关系,对象销毁后自动回收,没有内存泄漏

如果你对某个模块想深入了解,建议直接看 Vue3 源码的 reactivity 目录,代码量其实不大,核心逻辑都在这篇文章涉及到的几个文件里。