在前端开发中,响应式系统是 Vue 的核心能力。Vue2 通过 Object.defineProperty 实现数据劫持,而 Vue3 则引入了 Proxy,大幅提升了灵活性和性能。本篇文章将带你从原理到实践,解析 Vue3 的响应式机制,并对比 Vue2 的实现差异。
一、前端响应式回顾
响应式的本质是:当数据变化时,自动更新视图。
Vue2 利用 Object.defineProperty 对对象每个属性设置 getter 和 setter,实现依赖收集与派发更新。
局限性:
- 必须遍历对象属性,新增属性无法被劫持
- 对数组的索引和长度操作无法直接监听
- 性能开销较大,尤其是嵌套对象
详细分析:
- 必须遍历对象属性,新增属性无法被劫持
Vue2 的响应式通过 Object.defineProperty 将对象每个已有属性转为 getter/setter 来实现依赖收集和更新。它只能“劫持”对象在实例化时已经存在的属性。如果在对象创建后新增属性,Vue2 并不知道这个属性的存在,所以无法自动触发视图更新。
const obj = { name: 'Alice' };
Vue.observable(obj); // 或 new Vue({ data: obj })
obj.name = 'Bob'; // ✅ 响应式更新
obj.age = 25; // ❌ 新增属性,视图不会更新
- 对数组的索引和长度操作无法直接监听
Object.defineProperty 可以劫持对象的属性,但数组索引(如 arr[0] = 1)本质上是对象的“数字属性”,Vue2 并没有为每个可能的索引做 getter/setter,因此无法检测数组直接赋值或修改长度的变化。
const arr = [1, 2, 3];
Vue.observable(arr);
arr[0] = 10; // ❌ 视图不会更新
arr.length = 0; // ❌ 视图不会更新
Vue2 的解决方式:
Vue2 会覆盖部分 数组变异方法(push、pop、shift、unshift、splice、sort、reverse),这些方法内部会触发更新。
arr.push(4); // ✅ 会触发视图更新
arr.splice(1, 1, 20); // ✅ 会触发视图更新
原因总结:
- 数组的索引和长度操作属于动态属性,无法静态劫持;
- Vue2 通过劫持数组方法做变通。
- 性能开销较大,尤其是嵌套对象
Vue2 在初始化对象时,会递归遍历对象所有属性,把每个属性都转换成 getter/setter。如果对象嵌套层级多或者属性数量大,递归会消耗大量性能。
const data = {
user: {
name: 'Alice',
age: 25,
address: {
city: 'Shanghai',
zip: 200000
}
},
items: Array(1000).fill(0)
};
Vue.observable(data);
// Vue 会递归遍历 user, address, items 的每个元素
// 数千个属性会导致初始化很慢
原因总结:
- 每个对象属性都需要定义 getter/setter;
- 嵌套越深,初始化时间和内存开销越大;
- 对大数据量的对象和数组不友好。
二、Vue3 的 Proxy 响应式
Vue3 使用 Proxy 拦截对象的 所有操作,包括读取、写入、删除、枚举等。无需预先遍历属性,也能响应新增属性。
优势:
- 动态拦截,不用预先遍历
- 支持数组索引、长度变化
- 对嵌套对象天然支持
- 性能更优,减少初始化开销
详细分析:
- 动态拦截,不用预先遍历
Vue3 使用 Proxy 对整个对象进行代理,不需要事先知道对象的属性名,也不需要递归遍历每个属性。所有对对象属性的访问、设置、删除都会被动态拦截,从而实现响应式。
const obj = reactive({ name: 'Alice' });
obj.name = 'Bob'; // ✅ 响应式更新
obj.age = 25; // ✅ 新增属性也会触发响应式
原因总结:
- 不依赖 Object.defineProperty 的静态劫持;
- 新增、删除属性都能被自动拦截;
- 初始化时无需遍历全部属性,减少性能开销。
- 支持数组索引、长度变化
Proxy 可以拦截对象的任意操作,包括数组的索引访问和长度修改。Vue3 不再依赖覆盖数组方法,而是通过 Proxy 捕获 get/set 操作,所以直接修改数组索引或长度也能触发更新。
const arr = reactive([1, 2, 3]);
arr[0] = 10; // ✅ 响应式更新
arr.length = 1; // ✅ 响应式更新
原因总结:
- 对数组操作天然支持,不再依赖特殊方法;
- 支持动态添加或删除数组元素;
- 更符合 JS 原生数组使用习惯。
- 对嵌套对象天然支持
Vue3 的 reactive 是惰性递归(lazy reactive),只有在访问嵌套对象时,才会自动将其转换为响应式。这意味着嵌套对象天然支持响应式,而不需要提前递归遍历所有属性。
const state = reactive({
user: { name: 'Alice', address: { city: 'Shanghai' } }
});
state.user.name = 'Bob'; // ✅ 响应式更新
state.user.address.city = 'Beijing'; // ✅ 响应式更新
原因总结:
- 嵌套层级不再影响初始化性能;
- 嵌套对象的属性访问才会被代理,提高性能;
- 自然支持深层对象的响应式更新,无需手动 Vue.set。
- 性能更优,减少初始化开销
Vue3 使用 Proxy + 惰性递归策略,不会在创建时递归整个对象的每个属性,而是按需代理。只有访问到的属性才会被转换为响应式,大大降低初始化开销,尤其是大对象或深层嵌套对象。
const largeData = reactive({
items: Array(10000).fill({ value: 0, nested: { id: 1 } })
});
// 并不会一次性递归 10000 个对象
// 只有访问某个元素时,才会进行代理
console.log(largeData.items[0].value); // ✅ 此时代理生效
原因总结:
- 初始化无需递归整个对象;
- 大型对象和数组性能友好;
- 动态访问时按需代理,减少内存开销。
三、核心差异总结
| 特性 | Vue2 (Object.defineProperty) | Vue3 (Proxy) |
|---|---|---|
| 新增属性响应式 | 不支持,需要 Vue.set | 支持 |
| 删除属性响应式 | 不支持 | 支持 |
| 数组操作 | 需要重写数组方法 | 原生支持所有数组操作 |
| 对象遍历 | 需预先遍历属性 | 动态拦截 |
| 性能 | 嵌套多层对象性能差 | 性能优化,开销小 |
| 代码实现复杂度 | 高,需要递归处理 | 低,统一拦截 |
四、Reflect
你可以把 Proxy 理解成一个 隐形的保镖 + 记事本:
-
保镖拦截一切动作
-
当你去读对象的属性(state.count),保镖会拦住说:
“好的,我让你读,但我顺便记一下:现在有人在用这个属性。”(依赖收集)
-
当你去改属性(state.count++),保镖又会拦住说:
“好的,我帮你改完,但我也要通知所有依赖它的人:喂,数据变了,你们要更新啦!”(派发更新)
-
-
记事本(依赖桶)
- Proxy 自己不会记谁在用,而是有个全局“记事本”(WeakMap + Map + Set)。
- 每个属性都有一份听众名单,谁在用它就写进去。
- 当属性变化时,就从名单里找到这些听众(effect 函数),一个个重新执行。