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 支持代理上的索引赋值 |
| 数组 length | Vue 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 去重,新赋入的对象也要观测:
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,不会补装访问器。
// ❌ 新增属性不触发视图更新
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 的完整依赖系统:
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 同步观察响应式变化;组件渲染通常会由调度器批处理:
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. 性能对比
初始化性能
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,再运行:
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 更新,也需要专门的依赖与数组处理
- 原对象写入、解构得到的普通值、非支持对象类型等边界仍然存在
- 惰性代理减少未访问分支的初始化工作,整体性能需要按实际工作负载比较