Vue3 响应式原理:从 Object.defineProperty 到 Proxy

Vue 的响应式系统是其最核心的设计之一,也是它区别于传统模板引擎的根本特征。Vue 2 使用 实现响应式,而 Vue 3 全面拥抱 ,不仅解决了诸多历史痛点,还带来了更灵活的扩展能力。本文从源码级角度深入解析 Vue 3 响应式系统的完整链路。

Vue 的响应式系统是其最核心的设计之一,也是它区别于传统模板引擎的根本特征。Vue 2 使用 Object.defineProperty 实现响应式,而 Vue 3 全面拥抱 Proxy,不仅解决了诸多历史痛点,还带来了更灵活的扩展能力。本文从源码级角度深入解析 Vue 3 响应式系统的完整链路。


一、Vue 2 的局限:Object.defineProperty 的边界

Vue 2 在初始化阶段遍历 data 对象的所有属性,对每个属性调用 Object.defineProperty 重新定义其 getter/setter,从而实现依赖收集与触发更新:

function defineReactive(obj, key, val) {
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) dep.depend()
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.notify()
    }
  })
}

然而,Object.defineProperty 存在三个无法绕过的限制:

  • 无法检测新增属性:直接用 obj.newKey = 'value' 添加的属性不是响应式的,必须使用 Vue.set(obj, 'newKey', 'value')
  • 无法检测删除属性delete obj.key 不会触发视图更新,必须使用 Vue.delete(obj, 'key')
  • 数组索引变更无法响应:通过索引直接修改数组元素(arr[0] = 1)不会触发更新,因为 Vue 2 只对数组的 pushpopsplice 等变异方法进行了包装劫持,而直接赋值绕过了拦截逻辑。

这些限制源自 Object.defineProperty 的设计本质——它只能拦截单个已有属性的读写,无法触及对象本身的整体语义操作。


二、Vue 3 Proxy:面向对象的拦截器

Vue 3 使用 Proxy 代理整个对象,统一拦截所有操作:

const proxy = new Proxy(target, {
  get(target, key, receiver) { /* 依赖收集 */ },
  set(target, key, value, receiver) { /* 触发更新 */ },
  deleteProperty(target, key) { /* 删除响应 */ },
  has(target, key) { /* in 运算符 */ },
  ownKeys(target) { /* Object.keys / for...in */ }
})

Proxy 的优势显而易见:

  1. 属性增删即响应:新增属性时,set 拦截器自然触发;删除属性时,deleteProperty 被调用,无需额外 API。
  2. 数组原生能力:数组索引修改直接命中 set,无需重写数组原型方法。
  3. 操作粒度更广:不仅拦截读写,还能拦截 in 运算符、for...in 遍历、Reflect.ownKeys 等。
  4. 惰性代理:Vue 3 的 reactive() 采用惰性深度代理——只在访问嵌套对象时才递归创建 Proxy,而 Vue 2 需在初始化时递归遍历整个对象树。

三、依赖收集与触发:从 targetMap 到 dep

Vue 3 的响应式核心模块 @vue/reactivity 独立为一个包,其依赖收集机制由三个层级构成:

// 全局依赖存储结构
type TargetMap = WeakMap<Target, Map<Key, Dep>>
const targetMap: TargetMap = new WeakMap()

具体结构分解如下:

  • targetMapWeakMap<目标对象, 属性映射>WeakMap 的键是原始对象,当对象不再被引用时,WeakMap 不会阻止垃圾回收,避免内存泄漏。
  • depsMapMap<属性键, Set<Effect>>。每个属性对应一个 Set,存储所有订阅该属性的 effect 函数。
  • depSet<Effect>Set 自动去重,确保同一 effect 不会被重复收集。

依赖收集发生在 get 拦截器中,触发更新发生在 set 拦截器中。以下是一个简化但完整的实现:

let activeEffect = null
const targetMap = new WeakMap()

function track(target, key) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Set()
    depsMap.set(key, dep)
  }
  dep.add(activeEffect)
  // 双向记录,用于 cleanup
  activeEffect.deps.push(dep)
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) {
    const effects = new Set(dep)
    effects.forEach(effect => effect.run())
  }
}

值得注意的是,Vue 3 使用 new Set(dep) 复制一份 effect 集合后再遍历执行。这是因为某些 effect 在执行过程中可能会再次触发依赖收集,修改原 Set 的结构,导致遍历出错。这一防御性拷贝是生产级框架对边界的精细处理。


四、ref 与 reactive:原始值的"装箱"策略

reactive() 只能对对象生效——因为 Proxy 需要一个对象作为目标。对于原始值(stringnumberboolean 等),Vue 3 设计了 ref() 进行"装箱"处理:

function ref(value) {
  return createRef(value, false)
}

function createRef(rawValue, shallow) {
  if (isRef(rawValue)) return rawValue
  return new RefImpl(rawValue, shallow)
}

class RefImpl {
  constructor(value, __v_isShallow) {
    this._value = __v_isShallow ? value : toReactive(value)
    this.dep = new Set()
    this.__v_isRef = true
  }
  get value() {
    trackRefValue(this)
    return this._value
  }
  set value(newVal) {
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      this._value = __v_isShallow ? newVal : toReactive(newVal)
      triggerRefValue(this)
    }
  }
}

ref 的内部并非通过 Proxy 实现,而是直接在实例的 value 属性上通过 getter/setter 拦截,底层同样调用 tracktrigger,共享同一套依赖系统。

使用上的选择建议:

  • reactive:适用于对象/数组类型,在 <script setup> 中使用时变量名无需 .value,访问更自然。
  • ref:适用于原始值,或需要解构/重新赋值时(因为 reactive 对象的解构会失去响应性)。
  • 自动解包ref 作为 reactive 对象的属性时,模板中访问会自动解包 value;但在普通 JavaScript 表达式中仍需显式写 .value

五、Effect 与调度器:批量更新与 nextTick

Vue 3 中的 effect 是响应式的执行单元,对标 Vue 2 的 Watcher

function effect(fn, options = {}) {
  const _effect = new ReactiveEffect(fn)
  if (options.scheduler) {
    _effect.scheduler = options.scheduler
  }
  _effect.run()
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}

class ReactiveEffect {
  constructor(fn) {
    this.fn = fn
    this.deps = []
    this.scheduler = null
  }
  run() {
    try {
      activeEffect = this
      cleanupEffect(this)
      return this.fn()
    } finally {
      activeEffect = null
    }
  }
}

核心设计亮点:调度器(Scheduler)。当你传入 scheduler 选项时,trigger 不再立即执行 effect.run(),而是将 effect 推入调度队列,由调度器决定何时执行。Vue 的 computedwatch 底层都依赖调度器实现各自语义:

const queue = new Set()
let isFlushing = false

function queueJob(job) {
  queue.add(job)
  if (!isFlushing) {
    isFlushing = true
    Promise.resolve().then(() => {
      queue.forEach(job => job())
      queue.clear()
      isFlushing = false
    })
  }
}

这就是 nextTick 的本质——利用微任务队列将多次更新合并为一次 DOM 刷新。假设你在一次同步代码中连续修改了 count 三次,Vue 不会刷新三次 DOM,而是将三个 effect 收集到队列中,在下一轮微任务中统一执行,最终只渲染一次。


六、Computed 与 Watch:惰性求值与即时追踪

Computed:惰性的缓存

computed 的核心特征是惰性求值(lazy evaluation):只有当依赖发生变化且值被实际访问时,才重新计算。

function computed(getterOrOptions) {
  let getter = getterOrOptions
  const cRef = new ComputedRefImpl(getter)
  return cRef
}

class ComputedRefImpl {
  constructor(getter) {
    this._getter = getter
    this._dirty = true
    this._value = undefined
    this.dep = new Set()
    // 创建内部 effect,挂 lazy: true
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        this._dirty = true
        triggerRefValue(this)
      }
    })
    this.effect.computed = this
  }
  get value() {
    trackRefValue(this)
    if (this._dirty) {
      this._dirty = false
      this._value = this.effect.run()
    }
    return this._value
  }
}

当依赖变化时,调度器将 _dirty 置为 true,但不立即执行计算函数。稍后如果用户读取 .value,才会真正执行 getter 并缓存结果。这种设计避免了不必要的计算开销。

Watch:即时追踪

watchcomputed 相反,它的 effect 不设置 lazy,因此依赖变化时立即触发回调。它不要求返回值,而是通过回调函数暴露 newValoldVal

function watch(source, callback, options) {
  let getter = () => traverse(source)
  let oldValue, newValue
  const job = () => {
    newValue = effect.run()
    callback(newValue, oldValue)
    oldValue = newValue
  }
  const effect = new ReactiveEffect(getter, job)
  oldValue = effect.run()
}

traverse 函数递归访问响应式对象的每个属性,确保所有深层属性都被追踪——这正是 watch 能监听嵌套对象变化的原因。


七、Reactivity Transform 与演进

Vue 3.2 曾短暂实验过 Reactivity Transform,通过编译器宏让 .value 自动解包:

// Reactivity Transform(已废弃)
let count = $ref(0)   // 自动编译为 ref,无需 .value
count++               // 编译后变为 count.value++

这一提案在 v3.3 被正式标记为废弃,原因在于:

  1. 心智负担复杂:编译器魔法的边界难以界定,开发者无法直观判断何时需要 .value、何时不需要。
  2. TypeScript 兼容性:宏转换增加了类型系统的推理复杂度,IDE 支持不够稳定。
  3. 生态碎片化:第三方库无法使用编译器宏,导致库与业务代码风格不一致。

现代 Vue 3 项目推荐的模式是明确的 ref() + .value,配合 ESLint 规则(vue/ref-macros)维持一致性。对于偏好无 .value 语法的场景,reactive() 结构化的对象仍然是最清晰的选择。


八、Vue 与 React:推模型 vs 拉模型

理解 Vue 和 React 响应式差异的关键在于它们对组件重新渲染的判断机制:

维度Vue(推模型,Push-based)React(拉模型,Pull-based)
触发时机状态变更时,框架"推送"通知到精确的依赖组件状态变更时,组件函数重新执行,React"拉取"新 VDOM
依赖追踪编译期+运行时自动追踪,无需声明依赖依赖由开发者手动声明(useEffect deps),易遗漏
粒度属性级精度,只更新相关的组件组件级精度,默认重新执行整个组件函数
优化手段computed 缓存、v-oncev-memouseMemoReact.memouseCallback
心智模型自动化,隐式追踪,写更少优化代码显式声明,可控性高,但需手动避免重复渲染

两种模型各有优劣。Vue 的推模型在中小型项目中开发效率高得多——你几乎不需要关心 memoization;但在超大型应用中,隐式追踪可能导致难以调试的依赖链。React 的显式模型虽然代码更冗长,却提供了更明确的优化途径。


总结

Vue 3 的响应式系统是一次彻底的重构:Proxy 取代了 Object.defineProperty,独立了 @vue/reactivity 包使其可用于非 Vue 场景,调度器统一了 computed/watch/nextTick 的更新语义。源码实现上,WeakMap → Map → Set 的三层结构、基于 effect 的执行单元、以及惰性求值与调度队列的精细配合,共同构成了 Vue 响应式系统的优雅设计。掌握这些原理,不仅能写出更高效的 Vue 应用,也为理解现代响应式编程的通用模式奠定了基础。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. 前端 CI/CD 最佳实践:从代码提交到自动发布
  2. 前端 Bundle 分析与优化:从体积到执行时长的全链路
  3. 从 Webpack 到 Vite:迁移策略与原理对比