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

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

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

前置知识:枚举常量

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

TrackOpTypes ------ 读取操作类型

ts 复制代码
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 ------ 写入操作类型

ts 复制代码
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 ------ 内部标志位

ts 复制代码
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,是因为依赖关系会频繁增删,链表在这方面的性能更好。

ts 复制代码
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 ------ 全局依赖注册表

ts 复制代码
export const targetMap: WeakMap<object, Map<any, Dep>> = new WeakMap()

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

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

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

遍历操作的三个特殊 Key

ts 复制代码
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()

ts 复制代码
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 响应式里逻辑最复杂的地方,因为不同操作需要触发不同的依赖:

ts 复制代码
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 先收集起来,最后一次性跑完。

ts 复制代码
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 ------ 执行前标记

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

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

cleanupDeps ------ 执行后清理

ts 复制代码
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,说明这次执行过程中没有访问到它,就会自动从订阅列表中删除。

举个实际的例子:

ts 复制代码
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 ------ 判断是否需要重新计算

ts 复制代码
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 的刷新逻辑

ts 复制代码
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 ------ 响应式副作用的入口

ts 复制代码
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 缓存,作用是保证同一个对象不会被重复代理:

ts 复制代码
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,存入缓存,返回

三个工具函数

ts 复制代码
// 拿到原始对象(去掉代理层)
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

ts 复制代码
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 拦截器(最核心的一个):

ts 复制代码
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 拦截器

ts 复制代码
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 操作符):

ts 复制代码
has(target, key) {
  track(target, TrackOpTypes.HAS, key)
  return Reflect.has(target, key)
}

ownKeys 拦截器(遍历操作):

ts 复制代码
ownKeys(target) {
  track(target, TrackOpTypes.ITERATE, ITERATE_KEY)
  return Reflect.ownKeys(target)
}

ReadonlyReactiveHandler ------ 只读拦截

ts 复制代码
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 的核心实现

ts 复制代码
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 包一层就行:

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

computed ------ 带缓存的计算属性

ComputedRefImpl

ts 复制代码
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 的完整生命周期

用一个例子来说明:

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

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


把整个流程串起来

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

ts 复制代码
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 目录,代码量其实不大,核心逻辑都在这篇文章涉及到的几个文件里。

相关推荐
jiayong232 小时前
第 40 课:任务详情抽屉里的编辑 / 删除联动强化
java·开发语言·前端·javascript·vue.js·学习
Mr.mjw2 小时前
vue中使用 postcss-px-to-viewport 插件实现多屏适配
javascript·vue.js·postcss
踩着两条虫2 小时前
VTJ: 区块管理功能
vue.js·低代码·ai编程
nvvas2 小时前
Could not resolve “@intlify/vue-devtools‘ node modules/. pnpm/vue-118n@9. 14
前端·javascript·vue.js
yqcoder2 小时前
[特殊字符] Vue 3 组件通信全指南:从基础到进阶
前端·javascript·vue.js
梦想的颜色3 小时前
js 去掉除法后得出的小数点
javascript·vue.js
喜欢吃鱿鱼3 小时前
VUE项目 弹窗改为页面供其他项目嵌入iframe - 截取地址栏URL中的参数
前端·javascript·vue.js
gskyi3 小时前
UniApp Vue3 数据透传终极指南
javascript·vue.js·uni-app
gskyi3 小时前
uni-app 高阶实战:onLoad与getCurrentPages深度技巧
前端·javascript·vue.js·uni-app