响应式系统核心难题:数组与集合

4 阅读6分钟

在前面的文章中,我们已经实现了对象类型的响应式代理。但当面对数组、Map、Set 这些特殊的数据结构时,普通的 Proxy 代理会暴露出各种问题:无限递归、方法重写、内部插槽等。本文将深入探讨这些难题,并给出完整的解决方案。

前言:为什么数组和集合是特殊的存在?

当我们用 reactive 包装一个数组时:

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)和普通对象完全不同。

数组的特殊性

为什么数组代理会死循环?

让我们先看一个看似完美的数组代理实现:

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);

运行这段代码,我们会看到类似这样的输出:

读取属性: push
读取属性: length
设置属性: 3 = 4
设置属性: length = 4
读取属性: push
读取属性: length
设置属性: 3 = 4
设置属性: length = 4
... (无限循环)

为什么会死循环? 关键在于 push 方法的内部机制:

  1. proxy.push → 触发 get,返回数组原生的 push 方法。
  2. push 方法内部会读取 length → 触发 get('length')。
  3. push 方法会设置索引 arr[3] = 4 → 触发 set(3, 4)。
  4. 设置索引后,push 内部会自动更新 length → 触发 set('length', 4)。

那么问题来了:在 set('length') 触发时,数组内部机制会导致重新读取 push 方法的某些元数据,于是又回到步骤 1,形成死循环。

Vue3 的解决方案:重写数组方法

Vue3 采用了巧妙的方式:拦截数组的变异方法,用自定义实现替代原生方法:

// 需要拦截的数组变异方法
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 的响应式处理

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;
}

追踪数组变化的关键点

数组的响应式追踪有三个核心:

  1. 追踪索引访问:arr[0] = 100; 触发 set(0, 100);
  2. 追踪 length 变化:arr.length = 0; 触发 set('length', 0);
  3. 追踪变异方法:arr.push(4); 触发 push 方法拦截

数组代理的完整实现

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 的操作方式与普通对象完全不同:

const map = new Map();
map.set('key', 'value'); // 不是通过属性赋值
map.get('key'); // 不是通过属性读取
map.delete('key'); // 不是通过 delete 操作符

普通的 Proxy 无法拦截这些方法调用,我们必须重写这些方法。

拦截集合方法的思路

Vue3 通过创建自定义的集合处理器,重写所有会修改集合的方法:

// 需要拦截的 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 实现了完整的集合代理逻辑。其核心思想是:

// 创建集合代理
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);
    }
  };
}

实战:解决数组代理的无限递归

问题复现

让我们重现一个真实的无限递归场景:

// 问题代码
const arr = reactive([1, 2, 3]);

arr.push(4); // 死循环!

// 另一个容易忽略的场景
arr.splice(0, 1); // 也可能死循环

解决方案:标记和缓存

Vue3 的解决方案是结合标记缓存

// 防止重复拦截
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;
}

最终实现:安全的数组代理

结合所有优化,最终的数组代理实现:

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());
}

性能优化与最佳实践

避免不必要的数组代理开销

// 不推荐:大数组频繁操作
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));

集合类型的使用建议

// 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 如何解决这些技术难题,更重要的是学会了如何避免在实际开发中踩坑。这些知识将帮助你在构建复杂应用时,能够更加得心应手地处理各种数据结构的响应式需求。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!