Skip to content

D03 · Proxy vs Object.defineProperty ​

对应主课: L31 Proxy 响应式原理 最后核对: 2026-09-23


1. 核心区别 ​

维度Object.defineProperty (Vue 2)Proxy (Vue 3)
拦截入口给特定属性安装 getter/setter通过代理访问对象时触发已定义的 trap
新增属性❌ 需要 Vue.set()✅ 自动拦截
删除属性❌ 需要 Vue.delete()✅ 自动拦截
数组索引Vue 2 不追踪直接索引赋值Vue 3 支持代理上的索引赋值
数组 lengthVue 2 不追踪直接 length 修改Vue 3 有对应的数组触发逻辑
深层初始化Vue 2 遍历可观测对象的现有属性Vue 3 通常在读取嵌套对象时才创建其代理
浏览器支持IE9+不支持 IE

此表比较 Vue 2 与 Vue 3 的实现选择,不是 JavaScript 能力的全部边界。Object.defineProperty 可以定义数组索引访问器;Vue 2 没有逐个用它处理索引,而是包装部分数组变更方法。Proxy 也不会观察绕过代理、直接修改原对象的操作。Vue 2 变化检测限制


2. Vue 2 的 defineProperty ​

下面是访问器机制的教学实现,通过 onRead/onWrite 留出依赖追踪与通知入口,并非 Vue 2 源码。只处理普通对象中可配置、可写、可枚举的数据属性,不实现数组方法包装、组件或 watcher。循环引用用 WeakSet 去重,新赋入的对象也要观测:

javascript
function isPlain(value) {
  if (value === null || typeof value !== 'object') return false
  const proto = Object.getPrototypeOf(value)
  return proto === Object.prototype || proto === null
}
function observeWithAccessors(object, onRead = () => {}, onWrite = () => {}, seen = new WeakSet()) {
  if (!isPlain(object) || seen.has(object)) return object
  seen.add(object)
  for (const key of Object.keys(object)) {
    const descriptor = Object.getOwnPropertyDescriptor(object, key)
    if (!descriptor?.configurable || !descriptor.writable || !('value' in descriptor)) continue
    let value = descriptor.value
    observeWithAccessors(value, onRead, onWrite, seen)
    Object.defineProperty(object, key, {
      enumerable: true,
      configurable: true,
      get() { onRead(object, key); return value },
      set(next) {
        if (Object.is(next, value)) return
        observeWithAccessors(next, onRead, onWrite, seen)
        value = next
        onWrite(object, [key])
      },
    })
  }
  return object
}

Vue 2 的已知限制 ​

以下片段放在 Vue 2 组件中,obj/arr 已声明在 data 内;它们不是上面教学函数提供的 API。Vue.set 适用于嵌套对象,不能任意给已有组件添加根级响应式 data。每组错误/正确代码是替代方案,应从同一初始数据分别运行;先直接添加普通属性后,再对这个已存在的键调用 Vue.set,不会补装访问器。

javascript
// ❌ 新增属性不触发视图更新
this.obj.newKey = 'value'  // 不是响应式的!
// 必须用 Vue.set
Vue.set(this.obj, 'newKey', 'value')  // ✅

// ❌ 数组索引赋值不触发
this.arr[0] = 'new'  // 不触发!
// 必须用 Vue.set 或 splice
Vue.set(this.arr, 0, 'new')  // ✅
this.arr.splice(0, 1, 'new')  // ✅

// ❌ 修改数组 length 不触发
this.arr.length = 0  // 不触发!
this.arr.splice(0)   // 可用此方式清空并通知

// ❌ 删除属性不触发
delete this.obj.key  // 不触发!
Vue.delete(this.obj, 'key')  // ✅

3. Vue 3 的 Proxy ​

下面同样是普通对象教学模型;它提供 get/has/ownKeys 读取与写入通知入口。onWrite 一次接收受影响的依赖键,接入 effect 系统时应先合并去重再执行消费者。这里只观察 trap,不另外实现 L32 的完整依赖系统:

javascript
const ITERATE_KEY = Symbol('iterate') // 整个模型使用同一个键
function observeWithProxy(target, onRead = () => {}, onWrite = () => {}) {
  const cache = new WeakMap()
  const proxies = new WeakSet()
  function wrap(raw) {
    if (!isPlain(raw) || !Object.isExtensible(raw) || proxies.has(raw)) return raw
    if (cache.has(raw)) return cache.get(raw)
    const proxy = new Proxy(raw, {
      get(object, key, receiver) {
        const value = Reflect.get(object, key, receiver)
        onRead(object, key)
        return wrap(value)
      },
      set(object, key, value, receiver) {
        const had = Object.prototype.hasOwnProperty.call(object, key)
        const oldValue = Reflect.get(object, key, receiver)
        const result = Reflect.set(object, key, value, receiver)
        if (result && (!had || !Object.is(oldValue, value))) {
          onWrite(object, had ? [key] : [key, ITERATE_KEY])
        }
        return result
      },
      deleteProperty(object, key) {
        const had = Object.prototype.hasOwnProperty.call(object, key)
        const result = Reflect.deleteProperty(object, key)
        if (result && had) onWrite(object, [key, ITERATE_KEY])
        return result
      },
      has(object, key) { onRead(object, key); return Reflect.has(object, key) },
      ownKeys(object) { onRead(object, ITERATE_KEY); return Reflect.ownKeys(object) },
    })
    cache.set(raw, proxy)
    proxies.add(proxy)
    return proxy
  }
  return wrap(target)
}

此模型限定可扩展的普通数据对象,不覆盖继承写入、冻结属性、数组、Map/Set 或原始值/代理归一化。尤其不能给每次 ownKeys 都新建 Symbol,否则后续新增/删除找不到原来收集的枚举依赖。Vue 真正的对象处理还区分操作类型,数组和集合也有额外处理。3.5.43 baseHandlers

使用真实 Vue 3 验证支持范围 ​

把这段保存到 L31 独立实验目录的 proxy-behavior.ts,使用已安装的 Vue 3.5.43,通过 npx tsx proxy-behavior.ts 运行。这里用 effect 同步观察响应式变化;组件渲染通常会由调度器批处理:

typescript
import { reactive, effect, stop } from 'vue'
const state = reactive<{ name?: string; newKey?: string }>({ name: 'Vue' })
const arr = reactive([1, 2, 3])
const runner = effect(() => {
  console.log('对象:', Object.keys(state), 'name' in state, state.name)
  console.log('数组:', arr.length, arr[0])
})
state.newKey = 'value'
delete state.name
arr[0] = 10
arr.length = 0
stop(runner)

要有正在收集依赖的 effect/render/watchEffect,读取操作才会建立相关订阅;独立执行一次 'name' in state 并不会凭空创建一个消费者。


4. 性能对比 ​

初始化性能 ​

javascript
const bigObj = {}
for (let i = 0; i < 10000; i++) bigObj[`key${i}`] = { nested: { value: i } }

// 教学访问器实现会遍历这棵图,改造约 30000 个属性。
// 教学 Proxy 实现最初只包顶层;下面连续读取才需要两个嵌套代理。
const proxied = observeWithProxy(bigObj)
proxied.key0.nested.value

只读取 proxied.key0 会包一层子对象;继续读取 .nested 才多一层。读取原始 bigObj 不经过代理。惰性策略减少了未访问分支的初始化工作,却不证明所有后续读取、更新或整页性能都更快;完整遍历、深度 watch 等操作也会触及更深的数据。

内存占用 ​

Vue 2 会为可观测对象/属性维护观察信息,Vue 3 也要维护代理缓存、Dep/Link、组件与 effect。只比较“一个 Proxy”和“一万个 getter”遗漏了访问后才建立的数据,不能得出固定内存倍数;应在相同访问与更新流程之后测量。


5. 为什么 Vue 3 不支持 IE ​

Vue 3 使用了 IE 无法完整支持的语言能力,包括 Proxy。普通 JavaScript polyfill 不能完整复现 Proxy 的任意属性读取、新增与删除等拦截语义;“IE9 已原生支持部分 Object.defineProperty”也不等于该能力可在所有旧引擎里完整补齐。Vue 浏览器支持、Proxy 语义

这是支持范围与实现机制的取舍,不需要用未经核实的浏览器份额或固定性能倍数证明。Vue 2 在这里用于历史对照;新项目不能因为旧浏览器需求就忽略其已结束官方维护的状态。


6. 动手实验:浏览器控制台对比 ​

先粘贴 §2 的 isPlain/observeWithAccessors 和 §3 的 ITERATE_KEY/observeWithProxy,再运行:

javascript
const notify = (object, keys) => console.log('写入通知:', keys.map(String))
const obj1 = observeWithAccessors({ name: 'Vue 2' }, undefined, notify)
obj1.name = 'updated' // 通知,之后读取得到 updated
obj1.age = 25        // 没有为新增 age 安装访问器,不通知

const raw = { name: 'Vue 3', nested: { value: 1 } }
const reads = []
const obj2 = observeWithProxy(raw, (object, key) => reads.push(key), notify)
obj2.name = 'updated' // name
obj2.age = 25        // age + ITERATE_KEY
Object.keys(obj2)    // 读取 ITERATE_KEY
console.assert(reads.includes(ITERATE_KEY))
delete obj2.name     // name + ITERATE_KEY
delete obj2.missing  // 没有真正删除,不通知
console.assert(obj2.nested === obj2.nested) // 缓存保持代理身份
raw.age = 30        // 绕过代理,不通知
console.assert(obj2.age === 30) // 值变了,不代表发出过响应式通知

这个实验检查拦截行为,没有混入计时。若另做性能实验,应把输入构造移出计时区,检查两种实现读取/写入后的结果相同,再分别测初始化、完整读取与更新;不能让一组 setter 丢弃写入、另一组真正保存值后比较速度。


7. 总结 ​

  • Vue 3 支持代理上的对象新增/删除、数组索引与 length 更新,也需要专门的依赖与数组处理
  • 原对象写入、解构得到的普通值、非支持对象类型等边界仍然存在
  • 惰性代理减少未访问分支的初始化工作,整体性能需要按实际工作负载比较