在前面的文章中,我们已经实现了对象类型的响应式代理。但当面对数组、Map、Set 这些特殊的数据结构时,普通的 Proxy 代理会暴露出各种问题:无限递归、方法重写、内部插槽等。本文将深入探讨这些难题,并给出完整的解决方案。
前言:为什么数组和集合是特殊的存在?
当我们用 reactive 包装一个数组时:
javascript
const arr = reactive([1, 2, 3]);
arr.push(4); // 这到底发生了什么?
arr[0] = 100; // 能触发响应式吗?
arr.length = 0; // 又会发生什么?
表面上看,数组和对象都是"引用类型",用 Proxy 代理应该没什么区别。但实际上,数组有几个让 Proxy 头疼的特性:
- 索引访问:arr[0] 既是属性访问,又可能改变 length,因此可能触发两次更新。
- length 属性:改变 length 会隐式删除元素。
- 变异方法:push、pop 等方法会同时修改数组内容和 length。
更麻烦的是 Map、Set 这类集合,它们的操作方式(set、delete、add)和普通对象完全不同。
数组的特殊性
为什么数组代理会死循环?
让我们先看一个看似完美的数组代理实现:
javascript
const arr = [1, 2, 3];
const proxy = new Proxy(arr, {
get(target, key) {
console.log(`读取属性: ${key}`);
const value = target[key];
// 如果是方法,需要绑定 this
if (typeof value === 'function') {
return value.bind(target);
}
return value;
},
set(target, key, value) {
console.log(`设置属性: ${key} = ${value}`);
target[key] = value;
return true;
}
});
proxy.push(4);
运行这段代码,我们会看到类似这样的输出:
text
读取属性: push
读取属性: length
设置属性: 3 = 4
设置属性: length = 4
读取属性: push
读取属性: length
设置属性: 3 = 4
设置属性: length = 4
... (无限循环)
为什么会死循环? 关键在于 push 方法的内部机制:
- proxy.push → 触发 get,返回数组原生的 push 方法。
- push 方法内部会读取 length → 触发 get('length')。
- push 方法会设置索引 arr[3] = 4 → 触发 set(3, 4)。
- 设置索引后,push 内部会自动更新 length → 触发 set('length', 4)。
那么问题来了:在 set('length') 触发时,数组内部机制会导致重新读取 push 方法的某些元数据,于是又回到步骤 1,形成死循环。
Vue3 的解决方案:重写数组方法
Vue3 采用了巧妙的方式:拦截数组的变异方法,用自定义实现替代原生方法:
javascript
// 需要拦截的数组变异方法
const arrayMethods = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
// 保存原生方法
const arrayProto = Array.prototype;
const arrayMethodsProto = Object.create(arrayProto);
// 重写变异方法
arrayMethods.forEach(method => {
const original = arrayProto[method];
Object.defineProperty(arrayMethodsProto, method, {
value: function(...args) {
console.log(`调用变异方法: ${method}`);
// 先调用原生方法
const result = original.apply(this, args);
// 获取依赖并触发更新
const dep = this.__ob__?.dep;
if (dep) {
dep.notify();
}
return result;
},
enumerable: false,
writable: true,
configurable: true
});
});
深入数组代理的实现
索引访问与 length 的响应式处理
javascript
function createArrayReactive(target) {
const proxy = new Proxy(target, {
get(target, key, receiver) {
// 追踪依赖
track(target, key);
// 如果是数组的变异方法,返回重写后的版本
if (arrayMethods.includes(key)) {
return arrayMethodsProto[key].bind(receiver);
}
// 其他属性正常返回
const value = Reflect.get(target, key, receiver);
// 如果是对象,需要递归响应式
if (isObject(value)) {
return reactive(value);
}
return value;
},
set(target, key, value, receiver) {
const oldLength = target.length;
const oldValue = target[key];
// 设置值
const result = Reflect.set(target, key, value, receiver);
// 判断是否需要触发更新
if (target.length !== oldLength) {
// length 属性改变,需要触发 length 的更新
trigger(target, 'length');
}
if (key !== 'length' && oldValue !== value) {
// 普通索引变化
trigger(target, key);
}
return result;
}
});
return proxy;
}
追踪数组变化的关键点
数组的响应式追踪有三个核心:
- 追踪索引访问:
arr[0] = 100;触发set(0, 100); - 追踪 length 变化:
arr.length = 0;触发set('length', 0); - 追踪变异方法:
arr.push(4);触发 push 方法拦截
数组代理的完整实现
javascript
class ArrayReactiveHandler {
constructor(_isShallow = false) {
this._isShallow = _isShallow;
}
get(target, key, receiver) {
// 追踪依赖
track(target, key);
// 处理数组变异方法
if (arrayMethods.includes(key)) {
return arrayMethodsProto[key].bind(receiver);
}
const value = Reflect.get(target, key, receiver);
// 浅响应式不需要递归
if (this._isShallow) {
return value;
}
// 嵌套对象需要转为响应式
if (isObject(value)) {
return reactive(value);
}
return value;
}
set(target, key, value, receiver) {
const oldLength = target.length;
const oldValue = target[key];
const keyIsArrayIndex = isArrayIndex(key);
// 设置值
const result = Reflect.set(target, key, value, receiver);
// 判断触发更新的类型
if (key === 'length') {
// length 直接变化
trigger(target, 'length');
} else if (keyIsArrayIndex) {
// 索引变化可能影响 length
if (oldValue !== value) {
trigger(target, key);
}
if (target.length !== oldLength) {
trigger(target, 'length');
}
} else {
// 普通属性
if (oldValue !== value) {
trigger(target, key);
}
}
return result;
}
deleteProperty(target, key) {
const hadKey = key in target;
const oldLength = target.length;
const result = Reflect.deleteProperty(target, key);
if (result && hadKey) {
trigger(target, key);
// 删除索引可能改变 length
if (isArrayIndex(key) && target.length !== oldLength) {
trigger(target, 'length');
}
}
return result;
}
}
// 判断是否为数组索引
function isArrayIndex(key) {
const keyAsNumber = Number(key);
return Number.isInteger(keyAsNumber) &&
keyAsNumber >= 0 &&
keyAsNumber < Number.MAX_SAFE_INTEGER;
}
Map 和 Set 的代理
为什么 Map/Set 需要特殊处理?
Map 和 Set 的操作方式与普通对象完全不同:
javascript
const map = new Map();
map.set('key', 'value'); // 不是通过属性赋值
map.get('key'); // 不是通过属性读取
map.delete('key'); // 不是通过 delete 操作符
普通的 Proxy 无法拦截这些方法调用,我们必须重写这些方法。
拦截集合方法的思路
Vue3 通过创建自定义的集合处理器,重写所有会修改集合的方法:
javascript
// 需要拦截的 Map/Set 方法
const mutableInstrumentations = {
// 取值方法
get(key) {
const target = this.__target;
const hadKey = target.has(key);
// 追踪依赖
track(target, key);
if (hadKey) {
const value = target.get(key);
// 嵌套对象响应式
return isObject(value) ? reactive(value) : value;
}
},
// 设值方法
set(key, value) {
const target = this.__target;
const hadKey = target.has(key);
const oldValue = target.get(key);
// 设置值
target.set(key, value);
// 触发更新
if (!hadKey) {
trigger(target, 'add', key);
} else if (oldValue !== value) {
trigger(target, 'set', key);
}
return this;
},
// 添加方法(Set专用)
add(value) {
const target = this.__target;
const hadKey = target.has(value);
target.add(value);
if (!hadKey) {
trigger(target, 'add', value);
}
return this;
},
// 删除方法
delete(key) {
const target = this.__target;
const hadKey = target.has(key);
const result = target.delete(key);
if (hadKey) {
trigger(target, 'delete', key);
}
return result;
},
// 清空方法
clear() {
const target = this.__target;
const hadItems = target.size > 0;
const result = target.clear();
if (hadItems) {
trigger(target, 'clear');
}
return result;
}
};
源码对标:Vue3 的 collectionHandlers
Vue3 源码中的 collectionHandlers.ts 实现了完整的集合代理逻辑。其核心思想是:
javascript
// 创建集合代理
function createCollectionHandler(isReadonly = false, isShallow = false) {
return {
get(target, key, receiver) {
// 拦截 size 属性
if (key === 'size') {
track(target, 'size');
return Reflect.get(target, key, target);
}
// 返回重写的方法
if (key in mutableInstrumentations) {
return mutableInstrumentations[key];
}
// 其他方法(如 keys、values 等)
return Reflect.get(target, key, target);
}
};
}
实战:解决数组代理的无限递归
问题复现
让我们重现一个真实的无限递归场景:
javascript
// 问题代码
const arr = reactive([1, 2, 3]);
arr.push(4); // 死循环!
// 另一个容易忽略的场景
arr.splice(0, 1); // 也可能死循环
解决方案:标记和缓存
Vue3 的解决方案是结合标记 和缓存:
javascript
// 防止重复拦截
function createArrayProxy(arr) {
// 如果已经是响应式数组,直接返回
if (arr.__v_isReactive) {
return arr;
}
const proxy = new Proxy(arr, {
get(target, key, receiver) {
// 标记代理,防止重复代理
if (key === '__v_isReactive') {
return true;
}
// 关键优化:缓存方法调用结果
if (arrayMethods.includes(key)) {
// 使用 weakMap 缓存绑定后的方法
if (!cachedMethods.has(key)) {
const method = arrayMethodsProto[key];
cachedMethods.set(key, method.bind(receiver));
}
return cachedMethods.get(key);
}
// ... 其他逻辑
},
set(target, key, value, receiver) {
// 添加守卫条件,避免递归
if (key === '__v_isReactive') {
return false;
}
// ... 设置逻辑
}
});
return proxy;
}
最终实现:安全的数组代理
结合所有优化,最终的数组代理实现:
javascript
class ArrayHandler {
constructor(isReadonly = false, isShallow = false) {
this.isReadonly = isReadonly;
this.isShallow = isShallow;
// 方法缓存
this.methodCache = new Map();
}
get(target, key, receiver) {
// 跳过内部标记
if (key === '__v_isReactive' || key === '__v_isReadonly') {
return this.isReadonly ? false : true;
}
// 追踪依赖
if (!this.isReadonly && typeof key !== 'symbol') {
track(target, key);
}
// 处理数组方法
if (arrayMethods.includes(key)) {
let method = this.methodCache.get(key);
if (!method) {
method = arrayMethodsProto[key].bind(receiver);
this.methodCache.set(key, method);
}
return method;
}
const value = Reflect.get(target, key, receiver);
// 嵌套响应式
if (!this.isShallow && isObject(value)) {
return this.isReadonly ? readonly(value) : reactive(value);
}
return value;
}
set(target, key, value, receiver) {
if (this.isReadonly) {
console.warn(`Set operation on key "${String(key)}" failed: target is readonly.`);
return true;
}
const oldLength = target.length;
const oldValue = target[key];
const keyIsArrayIndex = isArrayIndex(key);
const result = Reflect.set(target, key, value, receiver);
// 触发更新
if (target.length !== oldLength) {
trigger(target, 'length');
}
if (key !== 'length' && oldValue !== value) {
trigger(target, key);
}
return result;
}
}
// 工厂函数
function reactiveArray(arr) {
if (!Array.isArray(arr)) {
return arr;
}
// 避免重复代理
if (arr.__v_isReactive) {
return arr;
}
return new Proxy(arr, new ArrayHandler());
}
性能优化与最佳实践
避免不必要的数组代理开销
javascript
// 不推荐:大数组频繁操作
const bigArray = reactive(new Array(10000).fill(0));
for (let i = 0; i < bigArray.length; i++) {
bigArray[i] = i; // 触发 10000 次 set
}
// 推荐:批量更新
const bigArray = reactive(new Array(10000).fill(0));
// 使用 splice 一次更新
bigArray.splice(0, bigArray.length, ...new Array(10000).fill(0));
集合类型的使用建议
javascript
// Map 的响应式使用
const map = reactive(new Map());
// 正确:使用 set 方法
map.set('key', 'value');
// 错误:直接赋值属性
map.key = 'value'; // 不会触发响应式
// Set 的响应式使用
const set = reactive(new Set());
// 正确:使用 add
set.add('item');
// 错误:不会触发响应式
set[0] = 'item';
结语
数组和集合的响应式实现是 Vue3 中最复杂但也最精巧的部分。通过本文的深入分析,我们不仅理解了 Vue3 如何解决这些技术难题,更重要的是学会了如何避免在实际开发中踩坑。这些知识将帮助你在构建复杂应用时,能够更加得心应手地处理各种数据结构的响应式需求。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!