在 Vue 2 中,响应式系统的核心是 Object.defineProperty。然而,由于该 API 的设计限制,它无法直接监听数组索引的变化和长度的修改 ,从而导致某些数组操作不能触发视图更新。下面我们将结合 原理 详细说明:
Object.defineProperty本身并不能完全解决数组响应式问题,但 Vue 2 通过"组合策略"在其基础上弥补了这些缺陷。
一、Object.defineProperty 对数组的天然缺陷(原理层面)
1.1 为什么不能监听 arr[i] = val?
Object.defineProperty 只能拦截已知属性名的读写操作。例如:
js
Object.defineProperty(obj, 'a', { get() {}, set() {} });
但对于数组:
- 索引
0,1,2... 是动态的; - 数组长度可变,无法预先为所有可能的索引定义 getter/setter;
- 即使你为
arr[0]定义了 setter,当执行arr[1000] = x时,这个新索引并未被 defineProperty 拦截。
📌 根本原因 :
Object.defineProperty是基于对象属性描述符 工作的,而数组的索引本质上是字符串键(如'0','1'),但它们是动态生成的,无法静态预定义。
1.2 为什么不能监听 arr.length = 0?
length 是数组的一个特殊属性,但 Object.defineProperty 在普通对象上定义 length 并不会自动同步到数组行为(比如截断元素)。更重要的是:
- 即使你用
defineProperty监听length,也无法知道哪些元素被删了; - 而且 Vue 在初始化时并不会对
length做特殊处理。
二、Vue 2 如何在 defineProperty 基础上"弥补"这些缺陷?(核心原理)
虽然 defineProperty 有局限,但 Vue 2 通过以下 三大策略 在其之上构建了一套"看似完整"的数组响应式系统:
✅ 策略 1:重写数组原型方法(变异方法拦截)
原理:
- 创建一个以
Array.prototype为原型的新对象arrayMethods; - 重写 7 个会改变原数组的方法(
push,pop,shift,unshift,splice,sort,reverse); - 在这些方法内部:
- 调用原生方法;
- 手动调用
dep.notify()触发依赖更新; - 对新增的元素递归调用
observe()使其响应式。
关键代码示意:
js
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
const original = arrayProto[method];
Object.defineProperty(arrayMethods, method, {
value: function(...args) {
const result = original.apply(this, args);
const ob = this.__ob__; // 获取 Observer 实例
let inserted;
if (method === 'push' || method === 'unshift') {
inserted = args;
} else if (method === 'splice') {
inserted = args.slice(2);
}
if (inserted) ob.observeArray(inserted); // 新增项也转为响应式
ob.dep.notify(); // 👈 手动触发更新!
return result;
},
enumerable: true,
writable: true,
configurable: true
});
});
如何生效?
当一个数组被 observe() 时,Vue 会将其 __proto__ 指向 arrayMethods:
js
if (hasProto) {
protoAugment(value, arrayMethods); // value.__proto__ = arrayMethods
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
🔍 本质 :这不是靠
defineProperty监听数组本身,而是劫持数组方法,在方法执行时手动通知更新。
✅ 策略 2:递归 observe 数组每一项
原理:
当使用 defineProperty 定义一个属性值为数组时,Vue 不仅处理数组本身,还会遍历其每一项,对对象类型 的元素再次调用 observe()。
js
function observeArray(items) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i]); // 递归响应式
}
}
⚠️ 注意:这只能保证数组内部对象的响应性 ,不能解决
arr[0] = newObj这种赋值不触发更新的问题。
✅ 策略 3:提供 $set / $delete 工具函数(绕过缺陷)
原理:
对于 arr[index] = val 和 arr.length = newLen 这类无法被拦截的操作,Vue 提供了 $set 方法,将其转换为可被拦截的操作。
例如:
js
vm.$set(vm.items, indexOfItem, newValue)
内部实现(简化):
js
function set(target, key, val) {
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key); // 确保长度足够
target.splice(key, 1, val); // 👈 转为 splice 调用!
return val;
}
// ... 处理对象情况
}
💡 关键点 :
splice是被重写的响应式方法,因此能触发更新。这相当于用已支持的响应式操作模拟不支持的操作。
三、整体流程图(原理串联)
用户操作 arr.push(1)
↓
arr.__proto__ → arrayMethods.push()
↓
执行原生 push
↓
调用 ob.observeArray([1]) → 使新元素响应式
↓
调用 ob.dep.notify() → 触发 watcher 更新视图
对于 arr[0] = 100:
用户写 arr[0] = 100
↓
❌ 无 setter 拦截(因为索引未被 defineProperty)
↓
视图不更新 ❌
↓
✅ 正确做法:this.$set(arr, 0, 100)
↓
内部转为 arr.splice(0, 1, 100)
↓
触发重写的 splice → notify → 更新 ✅
四、总结:defineProperty 如何"弥补"数组缺陷?
| 缺陷 | 是否由 defineProperty 直接解决? |
Vue 2 的弥补方式 |
|---|---|---|
arr[i] = val 不响应 |
❌ 否 | 提供 $set,转为 splice |
arr.length = n 不响应 |
❌ 否 | 建议用 splice 替代 |
| 变异方法不触发更新 | ❌ 否 | 重写原型方法 + 手动 notify |
| 数组内对象不响应 | ⚠️ 部分 | 递归 observe 每一项 |
🧠 核心结论 :
Object.defineProperty本身无法解决数组响应式问题 ,但 Vue 2 通过 原型劫持 + 方法重写 + 工具函数封装 + 递归 observe ,在defineProperty的基础上构建了一套兼容性方案,从而"弥补"了其不足。
五、延伸:为什么 Vue 3 改用 Proxy?
Proxy 可以直接拦截:
- 属性访问(包括数字索引
'0','1'); length修改;- 新增/删除属性;
js
const arr = new Proxy([1,2,3], {
set(target, key, value) {
console.log('set', key, value); // key 可以是 '0', 'length' 等
target[key] = value;
triggerUpdate();
return true;
}
});
arr[0] = 10; // ✅ 拦截
arr.length = 0; // ✅ 拦截
因此,Vue 3 彻底解决了数组响应式的底层缺陷,不再需要上述"打补丁"策略。