学完这篇,你能说清 Vue 2 的 Object.defineProperty 到底卡在哪、Vue 3 的 Proxy 又是怎么把这些坑一次性填掉的,并且能自己写出一个最小可用的响应式拦截函数。
第一步:先看 Vue 2 的拦截方式
Vue 2 的思路是「遍历属性,逐个改造」。核心代码简化后长这样:
function observe(obj) {
if (typeof obj !== 'object' || obj === null) return obj
Object.keys(obj).forEach(key => {
let val = obj[key]
observe(val) // 一上来就全量递归
Object.defineProperty(obj, key, {
get() { track(key); return val },
set(next) {
if (next === val) return
val = next
trigger(key)
}
})
})
}
关键在于:defineProperty 是针对已经存在的某个属性做劫持的。它改造的是属性,不是对象。这一点决定了后面所有的限制。
第二步:把 defineProperty 的坑一个个试出来
写几行代码就能复现:
const state = reactive({ a: 1 })
state.b = 2 // 新增属性:没有 getter/setter,视图不更新
delete state.a // 删除属性:拦不到
const list = reactive([1, 2, 3])
list[0] = 99 // 索引赋值:拦不到
list.length = 0 // 改长度:拦不到
所以在 Vue 2 里你必须记着 this.$set(obj, 'b', 2)、this.$delete,数组还得靠重写 push/pop/shift/unshift/splice/sort/reverse 七个方法兜住。另外两个隐性成本:
- 初始化开销大:深层大对象在
new Vue() 时就被递归走完,哪怕你根本没用到某个子字段。
- 拦截面太窄:
in 判断、for...in、Object.keys() 这些操作它管不了。
注意:数组那几个方法之所以要重写,是因为它们内部改的是索引和 length,而这两类操作 defineProperty 监听不到,只能靠替换方法本身来手动 trigger。
第三步:换成 Proxy,拦截对象本身
Proxy 是在对象外面套一层壳,你对该对象的任何操作都会先经过 handler:
function reactive(target) {
if (typeof target !== 'object' || target === null) return target
const proxy = new Proxy(target, {
get(t, k, receiver) {
track(t, k)
const res = Reflect.get(t, k, receiver)
return reactive(res) // 惰性:访问到才代理
},
set(t, k, v, receiver) {
const old = t[k]
const ok = Reflect.set(t, k, v, receiver)
if (!Object.is(old, v)) trigger(t, k)
return ok
},
deleteProperty(t, k) {
const ok = Reflect.deleteProperty(t, k)
trigger(t, k)
return ok
},
has(t, k) { track(t, k); return Reflect.has(t, k) },
ownKeys(t) { track(t, ITERATE); return Reflect.ownKeys(t) }
})
return proxy
}
对照第二步的四个坑,现在:新增属性走 set、删除走 deleteProperty、数组索引赋值和改 length 也都走 set,全部天然可拦截,$set 这套 API 在 Vue 3 里直接没了。
第四步:吃透「惰性代理」带来的性能差异
注意上面 get 里的 reactive(res) —— 它只在真正读到这个嵌套对象时才创建代理,不是初始化时递归到底。Vue 3 内部还用一个 WeakMap 缓存「原对象 → 代理对象」,同一个对象反复读不会重复创建代理,也不阻止垃圾回收。
这就是为什么大表单、深层配置对象在 Vue 3 里首屏初始化明显更轻:没用到的字段一直没被代理。
注意:Proxy 只能代理对象,原始值(数字、字符串)代理不了,所以 Vue 3 额外用 ref() 包一层,取值要写 .value;reactive() 则只能传对象。
第五步:还要记住两个实际约束
- 代理对象不等于原对象。把原对象另存一份再改它,更新不会触发:
const raw = { count: 0 }
const state = reactive(raw)
raw.count++ // 不会触发更新
state.count++ // 才会
写组合式函数时,导出的一律是 reactive 的返回值,别把 raw 泄漏出去。需要拿原始对象,用 toRaw(state)。
- 不支持 IE11,且
Proxy 的 handler 无法被 polyfill 补齐。这是 Vue 3 放弃 IE 的直接原因;项目若还有 IE 需求,只能留在 Vue 2。
至于 Map / Set 这类集合,Proxy 也需要专门的一组 handler(Vue 3 内部就有 collectionHandlers),普通 get/set 拿不到 size、add 的语义——这是用 Proxy 写库时要额外补的功课。
小结
Object.defineProperty 劫持的是属性,所以只能管住初始化时就存在的键;新增、删除、数组索引、length 全在视野之外,才需要 $set 和重写数组方法。
Proxy 劫持的是对象,用 get / set / deleteProperty / has / ownKeys 一次性覆盖各类操作,API 更干净。
- Vue 3 在
get 里做惰性递归 + WeakMap 缓存,把初始化成本从「全量递归」变成「用到才算」。
- 代价是:
Proxy 不能代理原始值(要 ref)、代理对象与原始对象不等价(改动原始对象无效)、且没有 IE 兼容。