为什么 Vue3 用 Proxy,而不是 defineProperty?
不想废话了,直接进入主题。这不是一个“API 升级”的问题,而是响应式模型发生了变化。
一、从一个最小需求开始
假设你要实现一个能力:
const state = {
user: { name: '张三' }
}
state.user.name = '李四'
// 页面自动更新
本质需求只有一个:
监听数据变化,并触发副作用(更新视图)
二、Vue2 的做法:defineProperty
核心实现方式:
Object.defineProperty(obj, 'name', {
get() {
// 收集依赖
},
set(val) {
// 触发更新
}
})
这里有一个关键点:
defineProperty 只能作用在“属性”上
也就是说,它的模型是:
基于属性的劫持(property-based)
三、问题不是功能不够,而是模型不对
下面这些问题,其实都可以归因到这一点。
1. 无法监听新增 / 删除属性
const obj = {}
obj.a = 1 // 不会触发更新
必须这样:
Vue.set(obj, 'a', 1)
原因很简单:
defineProperty 只能拦截“已存在的属性”
2. 数组无法统一处理
arr[0] = 100 // 无法监听
arr.length = 0 // 无法监听
Vue2 的做法是:
- 重写 push / splice / pop 等方法
- 做一层“补丁逻辑”
本质是:
不是统一拦截,而是针对特殊结构做兼容处理
3. 初始化成本高(核心问题)
defineProperty 的实现必须:
deepObject → 递归遍历 → 每个属性都 defineProperty
这意味着:
- 初始化成本高
- 深层对象全部劫持(即使不会访问)
本质问题:
一次性全量劫持
四、关键结论
到这里应该得出一个判断:
defineProperty 的问题不是“能力弱”,而是“抽象层级错误”
它试图:
监听“数据本身”
但前端真正需要的是:
监听“对数据的操作”
五、Vue3 的做法:Proxy
const proxy = new Proxy(target, {
get(target, key) {},
set(target, key, value) {},
deleteProperty(target, key) {}
})
关键变化:
从“劫持属性” → “劫持对象行为”
也就是:
基于操作的拦截(operation-based)
六、Proxy 如何解决这些问题
1. 动态属性问题
obj.a = 1 // 可以监听
delete obj.a // 可以监听
因为拦截的是:
- set
- deleteProperty
2. 数组问题
arr[0] = 100
arr.length = 0
全部可以监听。
原因:
Proxy 可以拦截多种操作,而不仅仅是 get / set
3. 性能问题(最关键)
Proxy 的策略是:
访问到哪一层 → 才代理哪一层
示例:
state.user // 才对 user 做 proxy
这叫:
惰性代理(lazy reactive)
相比 Vue2:
- 不需要初始化递归
- 按需代理
这一点是性能提升的核心
七、一个更本质的对比
| 方案 | 本质 |
|---|---|
| defineProperty | 劫持“数据” |
| Proxy | 劫持“行为” |
换句话说:
Vue2 是“数据驱动”
Vue3 是“操作驱动”
八、工程层面的差异
Vue2
this.list[0] = 100 // 不更新
this.list.splice(0,1,100) // 才更新
Vue3
this.list[0] = 100 // 正常更新
说明:
Vue2 需要开发者配合框架
Vue3 框架适配开发者
九、Proxy 的代价
1. 兼容性问题
Proxy 无法被 polyfill:
- IE 完全不支持
这也是 Vue3 放弃 IE 的原因之一。
2. 调试复杂
console.log(state)
输出的是 Proxy,而不是原始对象。
3. 与某些库存在兼容问题
例如:
1. 深拷贝(丢失代理或报错)
深拷贝通常会针对原始对象的数据进行拷贝。如果直接对一个 Proxy 对象进行深拷贝,拷贝出来的新对象会丢失代理(Proxy)特性,变成一个普通对象。
示例代码:
const target = { name: "Alice" };
const proxy = new Proxy(target, {
set(obj, prop, val) {
console.log(`拦截:设置 ${prop} = ${val}`);
obj[prop] = val;
return true;
}
});
// 1. 测试原始代理
proxy.name = "Bob"; // 控制台输出:拦截:设置 name = Bob
// 2. 使用 JSON 深拷贝
const clone1 = JSON.parse(JSON.stringify(proxy));
clone1.name = "Charlie"; // 没有任何输出,Proxy 拦截器丢失了!
// 3. 使用原生 structuredClone 深拷贝
const clone2 = structuredClone(proxy);
clone2.name = "David"; // 同样没有任何输出,变成了一个普通对象
代价分析: 当你在 Vue3 中把一个 reactive 对象放进深拷贝函数中,拷贝出来的对象将失去响应式。
2. 类实例(原生对象/内部槽 This 指向异常)
JavaScript 中的许多内置对象(如 Map、Set、Date、Promise)依赖底层的内部槽(Internal Slots),例如 Map 依赖 [[MapData]]。Proxy 是无法代理内部槽的。当通过 Proxy 调用这些对象的方法时,方法内部的 this 指向了 Proxy,导致找不到内部槽而报错。
示例代码:
const map = new Map();
const proxyMap = new Proxy(map, {});
// 直接操作 target 没问题
map.set('key1', 'value1');
// 通过 proxy 操作会直接崩溃
proxyMap.set('key2', 'value2');
// 报错:TypeError: Method Map.prototype.set called on incompatible receiver [object Object]
代价分析: 这就是为什么 Vue3 的 reactive 在处理 Map、Set 时,必须要在内部重写它们的 get、set、add 等方法,手动将 this 绑定回原始对象(target)。
3. 私有字段(# 语法报错)
ES2022 引入了类的私有属性(以 # 开头)。私有属性是严格绑定到类的具体实例上的。如果你对一个包含私有属性的类实例进行代理,当调用实例方法访问私有属性时,会因为 this 指向 Proxy 而导致报错。
示例代码:
class User {
#password = "123456"; // 私有字段
checkPassword() {
// 这里的 this 必须是 User 的真实实例
return this.#password;
}
}
const user = new User();
const proxyUser = new Proxy(user, {});
// 直接调用没问题
console.log(user.checkPassword()); // "123456"
// 通过 Proxy 调用崩溃
console.log(proxyUser.checkPassword());
// 报错:TypeError: Cannot read private member #password from an object whose class did not declare it
解决方法/代价: 如果必须在使用私有字段的类上使用 Proxy,你必须在 Proxy 的 get 拦截器中,将方法的 this 强行 bind 回目标对象:
const proxyUserFixed = new Proxy(user, {
get(target, prop, receiver) {
const value = Reflect.get(...arguments);
// 如果是函数,强行绑定 this 为原始对象 target
return typeof value === 'function' ? value.bind(target) : value;
}
});
console.log(proxyUserFixed.checkPassword()); // "123456" (正常工作了)
十、结论
Vue2 使用 defineProperty,本质是在:
用“属性劫持”模拟响应式
Vue3 使用 Proxy,本质是在:
用“语言级能力”表达响应式
最终变化不是优化,而是:
响应式系统的建模方式改变
如果有什么问题或者可以改进的地方,欢迎评论区留言,大家一起讨论。

浙公网安备 33010602011771号