Vue 2 的响应式系统基于 Object.defineProperty,这使得它在处理对象的动态新增/删除属性以及数组索引直接赋值时存在天然的盲区。这些缺陷并不是 Vue 设计上的疏忽,而是底层 API 的能力边界所致。
对象:新增属性与删除属性
当一个 Vue 实例的 data 对象被初始化时,Vue 会遍历该对象的所有属性,并通过 Object.defineProperty 将它们转换为 getter/setter。这个过程的本质是劫持已经存在的属性,所以:
data() {
return {
user: {
name: 'Alice'
}
}
}
user.name是响应式的。你修改它,视图会自动更新。- 但如果你后续直接给
user添加一个新属性age:
this.user.age = 25; // 视图不会更新
Vue 完全感知不到这个操作,因为 age 并不是在 user 对象被劫持时就存在的属性。同理,删除一个已有属性也不会触发响应:
delete this.user.name; // 视图无反应
实际的补救办法(Vue 2)
Vue 2 提供了全局方法 Vue.set 或实例方法 this.$set 来手动将新增属性变为响应式:
this.$set(this.user, 'age', 25); // 现在 age 也是响应式的了
对于删除属性,需要使用 Vue.delete 或 this.$delete:
this.$delete(this.user, 'name');
但这种方式要求开发者时刻记得不能直接赋值或 delete,增加了心智负担。尤其在处理来自后端接口的复杂对象时,嵌套层级的动态属性处理很容易踩坑。
数组:索引赋值与长度修改
出于性能考虑,Vue 2 没有对数组的每个索引使用 Object.defineProperty 进行劫持。如果对一个长度为 1000 的数组每一项都设置 getter/setter,初始化成本和内存占用会急剧上升。因此 Vue 2 采用了另一种策略:重写数组的 7 个变异方法(push、pop、shift、unshift、splice、sort、reverse),在这些方法被调用时,Vue 可以拦截到变化并派发更新。
这意味着:
- 通过变异方法修改数组 是 响应式的:
this.list.push('new item')。 - 但直接通过索引设置数组元素,不会触发视图更新:
this.list[0] = 'changed'; // 视图不更新
- 同样,直接修改数组的
length属性也不会触发更新:
this.list.length = 0; // 不会触发响应
补救方式
对于索引赋值,Vue 2 推荐的替代方案是使用 splice 或 $set:
this.$set(this.list, 0, 'changed');
// 或者
this.list.splice(0, 1, 'changed');
然而,这些变通手段让数组操作变得不直观,尤其在处理复杂列表交互(如拖拽排序、动态表单增删)时,代码的可读性和维护性都会打折扣。
这些限制的真实影响
在实际开发中,最常见的翻车现场有三类:
- 后端接口返回的对象有可选字段
比如用户信息可能包含 address,但不是每个用户都有。当你尝试 this.user.address.city = 'Beijing' 时,如果 address 本身是后加的,内部嵌套就不会是响应式的。
- 动态表单的数组操作
一个表单允许用户添加多条记录,你使用 v-for 渲染数组,但新增一行时可能直接 push 可以,但删除某一项后后续索引需要手动处理,稍不注意就出现表单数据与视图不同步。
- 第三方库的纯数据对象
当你把一个从外部库(如地图、图表)返回的普通对象直接赋值给 Vue 实例的数据属性时,Vue 2 无法自动将其内部属性转换为响应式,除非你使用 this.$set 或重新赋值整个对象。
Vue 3 的解决:Proxy 一劳永逸
这些问题的根源是 Object.defineProperty 只能劫持对象已有属性的读写,无法拦截属性增删和数组索引操作。Vue 3 将整个响应式系统重构为基于 Proxy 的实现(见 4.2 节),Proxy 可以代理整个对象,包括:
- 属性读取和赋值(
.name) - 属性新增(
.age = 25) - 属性删除(
delete obj.name) - 数组索引操作(
arr[0] = x) - 数组
length修改 in操作符、for...in遍历等
这意味着在 Vue 3 中,你没有“遗漏劫持”的焦虑,对象和数组的使用方式与原生 JavaScript 完全一致,不再需要 $set 和 $delete。响应式系统对开发者而言变得更加透明,这也是 Vue 3 底层升级中最具解放性的改进之一。