深入理解 Vue 响应式原理:从 Object.defineProperty 到 Proxy 的全面进化
从 Vue2 到 Vue3,响应式系统经历了脱胎换骨的变化。本文将带你深入源码,彻底搞懂
Object.defineProperty的局限、Proxy的强大,以及为什么Reflect是Proxy的最佳搭档。
目录
- [Object.defineProperty:Vue2 的基石](#Object.defineProperty:Vue2 的基石 "#1-objectdefinepropertyvue2-%E7%9A%84%E5%9F%BA%E7%9F%B3")
- [Vue2 的妥协:为什么只重写 7 个数组方法?](#Vue2 的妥协:为什么只重写 7 个数组方法? "#2-vue2-%E7%9A%84%E5%A6%A5%E5%8D%8F%E4%B8%BA%E4%BB%80%E4%B9%88%E5%8F%AA%E9%87%8D%E5%86%99-7-%E4%B8%AA%E6%95%B0%E7%BB%84%E6%96%B9%E6%B3%95")
- [Proxy:ES6 的代理对象](#Proxy:ES6 的代理对象 "#3-proxyes6-%E7%9A%84%E4%BB%A3%E7%90%86%E5%AF%B9%E8%B1%A1")
- [Reflect:Proxy 的最佳搭档](#Reflect:Proxy 的最佳搭档 "#4-reflectproxy-%E7%9A%84%E6%9C%80%E4%BD%B3%E6%90%AD%E6%A1%A3")
- [Vue3 的响应式革命:Proxy + Reflect](#Vue3 的响应式革命:Proxy + Reflect "#5-vue3-%E7%9A%84%E5%93%8D%E5%BA%94%E5%BC%8F%E9%9D%A9%E5%91%BDproxy--reflect")
- [Object.defineProperty vs Proxy:全面对比](#Object.defineProperty vs Proxy:全面对比 "#6-objectdefineproperty-vs-proxy%E5%85%A8%E9%9D%A2%E5%AF%B9%E6%AF%94")
- 总结
1. Object.defineProperty:Vue2 的基石
Object.defineProperty 是 ES5 引入的方法,允许精确添加或修改对象上的属性,并控制该属性的行为。
基本用法
js
const obj = {};
let internalValue = 'hello';
Object.defineProperty(obj, 'name', {
get() {
console.log('读取 name');
return internalValue;
},
set(newVal) {
console.log('设置 name 为', newVal);
internalValue = newVal;
}
});
obj.name; // 读取 name → "hello"
obj.name = 'hi'; // 设置 name 为 hi
在 Vue2 中的应用
Vue2 在初始化数据时,会递归遍历 数据对象的所有属性,用 Object.defineProperty 把它们转换成 getter/setter:
js
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) {
val = newVal;
dep.notify(); // 通知所有 Watcher 更新
}
}
});
}
致命缺陷
Object.defineProperty 只能拦截已有属性的读取和赋值,对以下操作无能为力:
| 操作 | 能否监听 | 解决方案 |
|---|---|---|
| 已有属性的读写 | ✅ | 自动处理 |
新增属性 obj.newKey = 1 |
❌ | Vue.set(obj, 'newKey', 1) |
删除属性 delete obj.key |
❌ | Vue.delete(obj, 'key') |
数组索引修改 arr[0] = 1 |
❌ | 无 |
数组长度修改 arr.length = 0 |
❌ | arr.splice(0, arr.length) |
根本原因 :Object.defineProperty 是对属性 的劫持,而不是对对象的劫持。它必须预先知道属性名,一次性配置好后才能拦截。
2. Vue2 的妥协:为什么只重写 7 个数组方法?
既然无法监听数组索引,Vue2 采取了一个巧妙的折中方案:拦截会改变数组自身的 7 个方法。
具体是哪 7 个?
js
const methodsToPatch = [
'push', // 尾部添加
'pop', // 尾部删除
'shift', // 头部删除
'unshift', // 头部添加
'splice', // 删除/添加
'sort', // 排序
'reverse' // 反转
];
这 7 个都是变异方法(Mutator Methods) ,它们的共同特点是会修改原数组自身 。而 map、filter、slice 等方法返回新数组,不会修改原数组,因此不需要拦截。
源码实现
Vue2 并没有直接修改 Array.prototype(避免污染全局),而是创建了一个中间对象:
js
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
methodsToPatch.forEach(method => {
const original = arrayProto[method];
Object.defineProperty(arrayMethods, method, {
value: function mutator(...args) {
// ① 执行原始方法
const result = original.apply(this, args);
// ② 获取 Observer 实例
const ob = this.__ob__;
// ③ 对新增元素进行响应式处理
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice':
inserted = args.slice(2);
break;
}
if (inserted) ob.observeArray(inserted);
// ④ 触发更新
ob.dep.notify();
return result;
},
enumerable: false,
writable: true,
configurable: true
});
});
当数组被观测时,Vue2 会将其原型指向 arrayMethods:
js
if (Array.isArray(value)) {
value.__proto__ = arrayMethods; // 或遍历覆盖
this.observeArray(value);
}
关键细节 :push/unshift/splice 可能插入新元素,这些新元素需要递归调用 observeArray 变成响应式;而 pop/shift 只是删除,sort/reverse 只是重排,元素本身已经是响应式的,无需额外处理。
3. Proxy:ES6 的代理对象
Proxy 是 ES6 引入的代理对象,它可以在目标对象前设置一个"拦截层",对外界的访问进行过滤和改写。
什么是代理?
代理是一种设计模式:你不直接操作目标,而是通过一个"中间人"来间接操作。
js
const person = { name: '张三' };
const proxy = new Proxy(person, {
get(target, key) {
console.log(`读取了 ${key}`);
return target[key];
},
set(target, key, value) {
console.log(`设置了 ${key} = ${value}`);
target[key] = value;
return true;
}
});
proxy.name; // 读取了 name → "张三"
proxy.age = 20; // 设置了 age = 20
Proxy 的语法
js
const proxy = new Proxy(target, handler);
| 参数 | 说明 |
|---|---|
target |
被代理的原始对象 |
handler |
包含**捕获器(trap)**的对象,定义拦截逻辑 |
Proxy 是构造函数吗?
是的,但它非常特殊:
js
typeof Proxy; // "function"
new Proxy({}, {}); // 正常工作
Proxy.prototype; // undefined(没有固定的原型对象)
Proxy(); // TypeError: Constructor Proxy requires 'new'
Proxy可以用new调用,所以是构造函数- 但它没有
prototype属性 ,因为代理实例的原型继承自目标对象 - 不能直接调用 (不加
new会报错)
常用捕获器(trap)
Proxy 可以拦截 JavaScript 对象的底层基本操作:
| 操作 | 捕获器 |
|---|---|
obj.prop |
get |
obj.prop = value |
set |
'prop' in obj |
has |
delete obj.prop |
deleteProperty |
Object.keys(obj) |
ownKeys |
for...in |
ownKeys + getOwnPropertyDescriptor |
这意味着,Proxy 可以监听到对象上的几乎所有操作,而不仅仅是属性的读写。
4. Reflect:Proxy 的最佳搭档
Reflect 是 ES6 引入的内置对象,提供了一组与对象底层操作一一对应的静态方法。
设计目的
- 统一规范 :把所有"对象基本操作"集中到
Reflect命名空间下 - 标准化返回值 :操作失败时返回
boolean,而不是抛错或静默失败 - 配合 Proxy :每个
Reflect方法都对应一个 Proxy 的 trap
为什么 Vue3 用 Reflect 而不是直接操作 target?
核心原因:Reflect 可以正确传递 receiver,保证 this 指向不出错。
反例:不用 Reflect
js
const parent = {
name: '父亲',
get getterName() {
return this.name;
}
};
const proxy = new Proxy(parent, {
get(target, key) {
return target[key]; // this 会指向 parent
}
});
const child = { name: '孩子' };
Object.setPrototypeOf(child, proxy);
console.log(child.getterName); // "父亲" ✗
正例:用 Reflect
js
const proxy = new Proxy(parent, {
get(target, key, receiver) {
// receiver 是实际触发读取的对象(child)
return Reflect.get(target, key, receiver);
}
});
console.log(child.getterName); // "孩子" ✓
Reflect 与 Object 的返回值差异
| 操作 | Object 版本 | Reflect 版本 |
|---|---|---|
| 删除属性 | delete obj.x |
Reflect.deleteProperty(obj, 'x')(返回 boolean) |
| 定义属性 | Object.defineProperty(...)(失败抛 TypeError) |
Reflect.defineProperty(...)(失败返回 false) |
| 设置属性 | obj.x = 1 |
Reflect.set(obj, 'x', 1)(返回 boolean) |
5. Vue3 的响应式革命:Proxy + Reflect
Vue3 使用 Proxy 替代了 Object.defineProperty,配合 Reflect 实现了真正的深度响应式。
简化版源码
js
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
// 依赖收集
track(target, key);
const result = Reflect.get(target, key, receiver);
// 懒代理:访问到哪层代理到哪层
if (isObject(result)) {
return reactive(result);
}
return result;
},
set(target, key, value, receiver) {
const hadKey = hasOwn(target, key);
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (!hadKey) {
trigger(target, key, 'add'); // 新增属性
} else if (hasChanged(value, oldValue)) {
trigger(target, key, 'set'); // 修改属性
}
return result;
},
deleteProperty(target, key) {
const hadKey = hasOwn(target, key);
const result = Reflect.deleteProperty(target, key);
if (hadKey) {
trigger(target, key, 'delete'); // 删除属性
}
return result;
},
has(target, key) {
track(target, key);
return Reflect.has(target, key);
},
ownKeys(target) {
track(target, ITERATE_KEY);
return Reflect.ownKeys(target);
}
});
}
相比 Vue2 的改进
| 能力 | Vue2 (defineProperty) | Vue3 (Proxy) |
|---|---|---|
| 新增属性 | ❌ 需 Vue.set |
✅ 自动监听 |
| 删除属性 | ❌ 需 Vue.delete |
✅ 自动监听 |
| 数组索引修改 | ❌ 无法监听 | ✅ 自动监听 |
| 数组长度修改 | ❌ 无法监听 | ✅ 自动监听 |
in 操作符 |
❌ 不监听 | ✅ 可监听(has) |
Object.keys() |
❌ 不监听 | ✅ 可监听(ownKeys) |
| 初始化性能 | 慢(深度递归) | 快(懒代理) |
| 兼容性 | IE9+ | 不支持 IE |
懒代理机制
Vue3 采用懒代理策略:只有在访问到某个属性时,才会判断它是否为对象并进行递归代理。
js
const state = reactive({
user: {
profile: {
name: '张三'
}
}
});
// 初始化时,只代理了最外层 state
// 只有当访问 state.user 时,才会把 user 变成响应式
// 只有当访问 state.user.profile 时,才会把 profile 变成响应式
这与 Vue2 的"初始化时一次性递归到底"相比,大大提升了性能。
6. Object.defineProperty vs Proxy:全面对比
| 维度 | Object.defineProperty | Proxy |
|---|---|---|
| 本质 | 劫持对象的属性 | 代理整个对象 |
| 拦截范围 | 仅 get/set | get、set、has、delete、ownKeys 等 |
| 新增属性 | ❌ 无法监听 | ✅ 自动监听 |
| 删除属性 | ❌ 无法监听 | ✅ 自动监听 |
| 数组索引 | ❌ 无法监听 | ✅ 自动监听 |
| 数组长度 | ❌ 无法监听 | ✅ 自动监听 |
| in 操作符 | ❌ 无法监听 | ✅ 可监听 |
| Object.keys | ❌ 无法监听 | ✅ 可监听 |
| 初始化方式 | 深度递归,预先转换 | 懒代理,访问时转换 |
| 性能特点 | 初始化慢,运行时快 | 初始化快,有轻微代理开销 |
| 兼容性 | IE9+ | 不支持 IE,无法 Polyfill |
7. 总结
Vue2 的 Object.defineProperty 是一次伟大的实践,它让我们理解了响应式的本质------依赖收集 + 触发更新。但由于 API 本身的局限,Vue2 不得不做出各种妥协:无法监听新增属性、无法监听数组索引、只能重写 7 个数组方法...
Vue3 的 Proxy 则彻底解决了这些问题。Proxy 站在更高的抽象层级上------它代理的是对象本身 ,而不是对象的某个属性。配合 Reflect 保证 this 指向的正确性,Vue3 的响应式系统更加完整、强大和高效。
理解 Object.defineProperty 到 Proxy 的演进,不仅是理解 Vue 框架的关键,更是理解 JavaScript 元编程能力(Meta-programming)的绝佳窗口。
如果本文对你有帮助,欢迎点赞收藏。如有疑问,欢迎在评论区讨论!