ShawDubie

Vue 系列(六):集合的响应式方案

在上一篇文章中,我们介绍了如何去代理数组。本篇我们会介绍集合的响应式方案。在 JavaScript 中,我们将 Map、WeakMap、Set、WeakSet 统称为集合。其实集合和数组类似,在 JavaScript 中也是属于对象。但是我们在代理集合的时候还是跟数组存在着比较大的差异。跟之前一样,我们先来看看集合中有哪些读取和设置操作。

Set 和 Map 中读取操作

  • Map.get(key):读取 Map 中指定 key 对应的值

  • Set.size、Map.size:返回集合的数量

  • Set.has(value)、Map.has(key):判断给定的值是否在 Set 中或者判断 Map 中是否存在指定 key 的键值对

  • Set.values()、Map.values():返回一个迭代器对象,Map 在迭代过程中会产生键值对的 value,Set 则为集合中的元素

  • Set.keys()、Map.keys():返回一个迭代器对象,Map 在迭代过程中会产生键值对的 key,Set.keys() 与 Set.values() 等价,都是返回集合中的元素

  • Set.entries()、Map.entries():返回一个迭代器对象,Map 会产生 [key, value] 的数组,Set 会产生 [value, value] 的数组

  • Map.forEach()、Set.forEach():遍历集合

Set 和 Map 中设置操作

  • Set.add(value):向 Set 中添加元素

  • Map.set(key, value):设置 Map 的键值对

  • Map.clear()、Set.clear():清空集合

  • Map.delete(key)、Set.delete(value):删除 Map 中指定 key 的键值对、删除 Set 中给定的值

通过上面的梳理,我们发现 Set 和 Map 其实有很多方法都是相同的,只有极少的差异。比如 add 是 Set 中特有的方法,而 set 和 get 则是 Map 中特有的。那么我们其实可以用共同的办法来处理她们的相似之处,不同的地方再单独处理即可。

代理 Map 和 Set 的思路


我们前面说过,集合类型也是属于对象,那么我们先用之前实现过的方法来尝试着代理一下集合,看看会发生什么:

const data = reactive(new Set([1, 2, 3]));

console.log('data.size----->', data.size);

在上面的代码中,我们通过 reactive 定义了一个响应式 Set 数据,然后通过 console.log 尝试打印该集合的长度。但是当我们执行上述例子,浏览器会抛出如下错误:

Uncaught (in promise) TypeError: Method get Set.prototype.size called on incompatible receiver #<Set>

意思就是说 size 属性在一个不兼容的类型上面被调用了,再来看看另一个例子:

const data = reactive(new Map([[1, 1], [2, 2]]));

data.set(1, 3);

在该例子中,我们同样也是通过 reactive 定义了一个响应式数据,这不过这次的数据结构是 Map,之后我们尝试通过 set 方法去修改其中某个值。当运行这个例子,同样也会报错:

Uncaught (in promise) TypeError: Method Map.prototype.set called on incompatible receiver #<Map>

报错的内容与上面的非常类似。那么为什么会报这样的错误呢?我们先通过语言规范来看看 Set 类型中的 size 是如何实现的:

主要看红框圈出的部分,当访问 size 属性的时候,会首先调用 RequireInternalSlot 方法来判断该对象是否存在内部槽 [[SetData]],下面是 RequireInternalSlot 的实现:

可以看到,如果没有对应的内部槽(internal slot),就会抛出类型错误的异常。如果你仔细查看语言规范,会发现集合中的所有属性和方法都有这一步判断。再看看上面的例子,当我们执行 data.size 这行代码的时候,这时 data 是代理对象,代理对象中确实不存在内部槽 [[SetData]],所以会抛出上述错误。同样,这句 data.set(1, 3) 代码也类似。我们将代理对象和原生的 Set 打印出来,可以发现一些端倪:

const setData = new Set([1, 2, 3]);
const data = reactive(setData);

console.log('setData------>', setData);
console.log('data------>', data);

上述代码用来打印代理对象和被代理的 Set 数据,打印结果如下:

可以发现代理对象只是普通的 Object,所以访问集合中的属性或者方法的时候必然会抛出异常。那么我们怎么去解决这个问题呢?其实思路也比较简单,就是当代理对象访问相应的属性和方法的时候,我们通过原始数据去访问即可:

查看代码
const getCollectionType = (target: any) => Object.prototype.toString.call(target);

// 判断是否是 Map 类型
const isMap = (target: any) => getCollectionType(target) === '[object Map]' || getCollectionType(target) === '[object WeakMap]';

// 判断是否是 Set 类型
const isSet = (target: any) => getCollectionType(target) === '[object Set]' || getCollectionType(target) === '[object WeakSet]';

const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    get(target: any, key: string | symbol, receiver: any) {
      // 当使用 TARGET_KEY 访问对象时,返回原始数据
      if (key === TARGET_KEY) {
        return target;
      }
      // 如果是数组且key以在arrayInstrumentations中存在,说明在调用我们重写的数组方法
      if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
        // 从arrayInstrumentations返回相应的方法
        return Reflect.get(arrayInstrumentations, key, receiver);
      }

      // 如果是 Set 和 Map,并且访问 size 属性的时候,使用原始值
      if ((isMap(target) || isSet(target)) && key === 'size') {
        // 使用原始值访问size
        return Reflect.get(target, key, target);
      }

      // 非只读的时候才收集
      // 如果 key 的类型是 symbol,也不进行收集
      if (!isReadOnly && typeof key !== 'symbol') {
        track(target, key);
      }
      const res = Reflect.get(target, key, receiver);
      // 浅响应或浅只读直接返回
      if (isShallow) {
        return res;
      }
      if (res && typeof res === 'object') {
        // 深只读和深响应递归调用相应函数
        return isReadOnly ? readOnly(res): reactive(res);
      }
      return res
    },
  });
}

在上面的代码中,我们首先定义了两个工具方法:isMap 和 isSet。以此来判断对象是否为集合,当对象是集合的时候,且访问属性为 size,直接使用原始数据访问该属性:

return Reflect.get(target, key, target);

通过这种方式我们就解决了集合中访问属性或者方法报错的问题了。但是,我们要代理集合,不仅仅只是通过原始数据访问相应的属性和方法,当我们调用相关方法的时候,我们还需要进行副作用方法的收集或者触发关联副作用函数的执行等等。所以我们需要重写集合中的方法。我们前面说过,集合中的所有方法和属性在被访问的时候都会先进行 internal slot 的判断,所以我们需要重写集合中的所有方法。

通过 size 获取集合的长度


我们前面梳理了很多集合中的读取和设置操作,只有 size 是属性,其他都是集合方法。其实我们前面已经实现了一半。接下来只需要补充副作用函数收集的逻辑即可:

查看代码
const getCollectionType = (target: any) => Object.prototype.toString.call(target);

// 判断是否是 Map 类型
const isMap = (target: any) => getCollectionType(target) === '[object Map]' || getCollectionType(target) === '[object WeakMap]';

// 判断是否是 Set 类型
const isSet = (target: any) => getCollectionType(target) === '[object Set]' || getCollectionType(target) === '[object WeakSet]';

const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    get(target: any, key: string | symbol, receiver: any) {
      // 当使用 TARGET_KEY 访问对象时,返回原始数据
      if (key === TARGET_KEY) {
        return target;
      }
      // 如果是数组且key以在arrayInstrumentations中存在,说明在调用我们重写的数组方法
      if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
        // 从arrayInstrumentations返回相应的方法
        return Reflect.get(arrayInstrumentations, key, receiver);
      }

      // 代理集合类型
      if ((isMap(target) || isSet(target))) {
        // 访问 size 属性
        if (key === 'size') {
          // 与 ITERATOR_KEY 建立绑定关系,防止出现 `data.get('size')`,导致误收集
          track(target, ITERATOR_KEY);
          return Reflect.get(target, key, target);
        }
      }

      // 非只读的时候才收集
      // 如果 key 的类型是 symbol,也不进行收集
      if (!isReadOnly && typeof key !== 'symbol') {
        track(target, key);
      }
      const res = Reflect.get(target, key, receiver);
      // 浅响应或浅只读直接返回
      if (isShallow) {
        return res;
      }
      if (res && typeof res === 'object') {
        // 深只读和深响应递归调用相应函数
        return isReadOnly ? readOnly(res): reactive(res);
      }
      return res
    },
  });
}

在上面的代码中,我们实现了对集合 size 属性的拦截,当访问 size 属性的时候,我们通过 ITERATOR_KEY 将 size 与副作用函数进行关联。这是因为:

  • size 是表示集合的长度,添加和删除集合中的元素都会影响该属性

  • Map 中可能会有 size 属性,比如 new Map([['size', 'foo']]),防止重名引发不必要的错误

所以我们这里采用了跟数组和对象一样的 ITERATOR_KEY 来将 size 与副作用函数进行关联。既然进行了副作用函数的收集,那与之对应的就是触发响应。在集合中,要想触发响应,则需要修改集合,那我们就来看看如何拦截集合中的修改操作。

修改集合


通过前面的介绍,我们知道,要想代理集合,需要重写集合中的方法,与上一篇文章中重写数组一样,我们先定义好相关类型:

export interface CollectionInstrumentation {
  set: (key: any, value: any) => void, // 设置 Map
  delete: (value: any) => boolean, // 删除
  clear: () => void, // 清空集合
  add: (value: any) => any, // 向 Set 中添加元素
}

上面 interface 中定义的就是集合中的所有修改操作,那么接下来我们就对其中的方法进行逐个重写。

set 和 add


先来看看 set 方法,我们一般这么去使用她:

const data = new Map([1,1], [2,2]); // 定义 Map
data.set(1, 3); // 将 data 中 key === 1 的值修改为 3
data.set(3, 5); // 向 data 中添加一个新的键值对 [3, 5]

通过上面的示例我们知道,set 方法既可以修改 Map 的值,也可以向 Map 中添加新的值。所以我们这样去重写 set 方法:

// 重写set方法
const collectionSetInstrumentation = () => {
  return function(key: any, value: any) {
    // 获取原始数据,也就是集合本身,这里的 this 指的是代理对象
    const target = this[TARGET_KEY];
    // map 中是否有含有该key的键值对
    const hasKey = target.has(key);
    const oldValue = target.get(key);
    target.set(key, value);
    if (!hasKey) {
      // 新增
      trigger(target, key, TRIGGER_TYPE.ADD, value);
      // 新旧值比较
    } else if (oldValue !== value && (oldValue === oldValue || value === value)) {
      // 修改
      trigger(target, key, TRIGGER_TYPE.SET, value);
    }        
  }
}

上面展示了如何去重写 set 方法,其实主要还是通过 TARGET_KEY 拿到原始数据,然后去调用原始数据的 set 方法。执行完原生的 set 方法之后再触发 trigger 方法的执行即可。这里有个地方是需要注意的,因为 set 既可以修改 Map,也可以向 Map 中增加新的数据,所以我们需要确认本次操作是新增还是修改,也就是确定 trigger 方法中的 triggerType 的值。这样实现之后,我们还需要修改 createReactive 中的 get 函数:

查看代码
const collectionInstrumentation: CollectionInstrumentation = {
  set: collectionSetInstrumentation()
}

const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    get(target: any, key: string | symbol, receiver: any) {
      // 当使用 TARGET_KEY 访问对象时,返回原始数据
      if (key === TARGET_KEY) {
        return target;
      }
      // 如果是数组且key以在arrayInstrumentations中存在,说明在调用我们重写的数组方法
      if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
        // 从arrayInstrumentations返回相应的方法
        return Reflect.get(arrayInstrumentations, key, receiver);
      }

      // 代理集合类型
      if ((isMap(target) || isSet(target))) {
        // 访问 size 属性
        if (key === 'size') {
          // 与 ITERATOR_KEY 建立绑定关系,防止出现 `data.get('size')`,导致误收集
          track(target, ITERATOR_KEY);
          return Reflect.get(target, key, target);
        }

        // 由于我们重写了集合中的所有方法,所以直接从collectionInstrumentation中返回
        return Reflect.get(collectionInstrumentation, key, receiver);
      }

      // 非只读的时候才收集
      // 如果 key 的类型是 symbol,也不进行收集
      if (!isReadOnly && typeof key !== 'symbol') {
        track(target, key);
      }
      const res = Reflect.get(target, key, receiver);
      // 浅响应或浅只读直接返回
      if (isShallow) {
        return res;
      }
      if (res && typeof res === 'object') {
        // 深只读和深响应递归调用相应函数
        return isReadOnly ? readOnly(res): reactive(res);
      }
      return res
    },
  });
}

由于我们重写了集合中的所有方法,所以当访问集合中的其他属性的时候,一律通过 collectionInstrumentation 返回。那我们通过一个例子来看看实现之后的效果:

const data = reactive(new Map([[1,2], [2,2]]));

effect(() => {
  console.log('data.size---->', data.size); // data.size----> 2
});

setTimeout(() => {
  data.set(3,3); // data.size----> 3
}, 1000)

在上面的例子中我们首先定义了一个响应式数据 data,她代理了一个 Map。然后在副作用函数中,我们打印了这个集合的长度。一秒之后,向 data 中添加一个新的键值对:[3,3]。当我们运行上面的代码,运行的结果和我们预期的一样,非常完美。那么我们再来看一个例子:

// 原始数据
const target = new Map();

// 代理数据2
const data2 = reactive(new Map());

// 代理数据
const data = reactive(target);

// 将 data2 添加到 data 中
data.set('data2', data2);

effect(() => {
  // 打印原始数据中 data2 的长度
  console.log('data2.size--->', target.get('data2').size); // data2.size---> 0
})

setTimeout(() => {
  // 修改原始数据
  target.get('data2').set('foo', 1); // data2.size---> 1
}, 1000);

在上面的代码中,我们首先定义了原始数据 target,然后定义了一个响应式数据 data2,接着将原始数据 target 通过 reactive 方法转换为响应式数据 data。定义完这些数据之后,先通过 set 方法将 data2 添加到响应式数据 data 中去。然后在副作用方法中通过原始数据 target 访问 data2 的长度。执行上面的代码,当原始数据 target 中的 data2 被修改的时候依然能触发副作用方法的执行。

其实上面的执行结果不是我们想要的,因为我们修改的是原始数据,不是响应式数据,所以不应该触发副作用函数的执行。原始数据不应该具有响应式数据的能力,如果这样的话代码就乱套了。导致这样的原因是因为:

// 重写set方法
const collectionSetInstrumentation = () => {
  return function(key: any, value: any) {
    // 获取原始数据,也就是集合本身,这里的 this 指的是代理对象
    const target = this[TARGET_KEY];
    // map 中是否有含有该key的键值对
    const hasKey = target.has(key);
    const oldValue = target.get(key);
    target.set(key, value);
    if (!hasKey) {
      // 新增
      trigger(target, key, TRIGGER_TYPE.ADD, value);
      // 新旧值比较
    } else if (oldValue !== value && (oldValue === oldValue || value === value)) {
      // 修改
      trigger(target, key, TRIGGER_TYPE.SET, value);
    }        
  }
}

上面的代码是我们之前重写过的 set 方法,如高亮的代码所示,我们是通过原始数据的 set 方法来对集合进行修改的,这时候修改的是原始数据,如果 value 也为响应式数据,那么我们就将响应式数据添加到原始数据中去了。这样原始数据中就夹杂了响应式数据,这就造成了数据污染。要解决此问题也很简单,我们在调用 target.set 之前先对 value 进行检查,如果是响应式数据,则通过 TARGET_KEY 获取其原始数据即可:

// 重写set方法
const collectionSetInstrumentation = () => {
  return function(key: any, value: any) {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // map 中是否有含有该key的键值对
    const hasKey = target.has(key);
    const oldValue = target.get(key);
    // 修改原始值的时候使用原始数据
    const targetValue = value[TARGET_KEY] || value;
    target.set(key, targetValue);
    if (!hasKey) {
      trigger(target, key, TRIGGER_TYPE.ADD, value);
      // 新旧值比较
    } else if (oldValue !== value && (oldValue === oldValue || value === value)) {
      trigger(target, key, TRIGGER_TYPE.SET, value);
    }        
  }
}

我们在执行 set 之前,先通过 TARGET_KEY 来检查是否是原始数据,这样就避免了数据污染。其实不仅集合需要考虑数据污染,普通对象和数组也要考虑这个问题,所以我们需要修改 createReactive 中的 set 方法:

查看代码
const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    set(target: any, key: string, value: any, receiver: any) {
      if (isReadOnly) {
        console.warn(`属性 ${key} 是只读的`)
        return true;
      }
      // 旧值
      const oldValue = target[key];
      // 如果是数组,判断当前索引是否大于原数组的长度,如果大于则为新增,反之则是修改
      // 判断对象中是否有该属性,如果有,则本次操作为修改,反之则是新增
      const triggerType = Array.isArray(target) ? Number(key) >= target.length ? TRIGGER_TYPE.ADD : TRIGGER_TYPE.SET : target.hasOwnProperty(key) ? TRIGGER_TYPE.SET : TRIGGER_TYPE.ADD;
      const targetValue = value[TARGET_KEY] || value;
      // 修改原始值的时候使用原始数据
      const res = Reflect.set(target, key, targetValue, receiver);
      // 相等说明 receiver 是 target 的代理对象
      if (target === receiver[TARGET_KEY]) {
        // 新旧值不一样才触发副作用函数执行
        // 处理NaN的情况
        if (oldValue !== value && (oldValue === oldValue || value === value)) {
          // 增加一个参数,将value传给trigger
          trigger(target, key, triggerType, value);
        }
      }
      return res;
    }
  });
}

重写完 set 方法,那么重写 add 就简单很多了,我们一般这样去使用 add:

const data = new Set([1, 2, 3]); // 定义一个 Set
data.add(4); // 向 data 中添加一个新元素

可以看到,add 方法是用来向 Set 中添加新元素的,那么我们可以这样去重写 add:

const collectionInstrumentation: CollectionInstrumentation = {
  set: collectionSetInstrumentation(),
  add: collectionAddInstrumentation(),
}

// 重写 add 方法
const collectionAddInstrumentation = () => {
  return function(value: any) {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // 判断是否有该值
    const hasValue = target.has(value);
    // 修改原始值的时候使用原始数据
    const targetValue = value[TARGET_KEY] || value;
    const res = target.add(targetValue)
    // 如果集合中不存在该值,则触发响应
    if (!hasValue) {
      trigger(target, value, TRIGGER_TYPE.ADD);
    }
    return res;
  }
}

可以看到 add 方法的重写非常简单,思路与 set 类似,也是通过原始数据来使用 add。在执行 target.add 之前,同样也是需要通过 TARGET_KEY 来检验是否是响应式数据,这样能避免数据污染。执行完 add 之后再去触发 trigger 的执行。

delete 和 clear


接下来我们看看如何删除集合中的元素,首先看看 delete:

const setData = new Set([1, 2, 3]); // 定义 Set
const mapData = new Map([[1, 1], [2, 2]]); // 定义 Map
setData.delete(1); // 删除 Set 中的元素 1
mapData.delete(1); // 删除 Map 中键为 1 的键值对

有了前面的介绍,现在重写 delete 也变得很简单了:

const collectionInstrumentation: CollectionInstrumentation = {
  set: collectionSetInstrumentation(),
  add: collectionAddInstrumentation(),
  delete: collectionDeleteInstrumentation(),
}
// 重写 delete 方法
const collectionDeleteInstrumentation = () => {
  return function(value: any) {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // 先判断集合中是否有该值,然后进行删除
    if (target.has(value)) {
      // 调用集合原生的 delete 方法进行删除
      const res = target.delete(value);
      trigger(target, value, TRIGGER_TYPE.DELETE);
      return res;
    }
    return false;
  }
}

当集合中拥有某个元素的时候,我们再调用原始数据的 delete 方法进行删除,执行完 delete 之后再去 trigger 方法即可。

接下来我们思考一下怎么去重写 clear,在具体实现之前,我们跟之前一样,看看 clear 的用法:

const setData = new Set([1, 2, 3]); // 定义 Set
const mapData = new Map([[1, 1], [2, 2]]); // 定义 Map
setData.clear(); // 删除 Set 中的所有元素
mapData.clear(); // 删除 Map 中的所有键值对

可以看到,clear 方法的作用就是清空集合,按照之前的思路,我们很容易写出如下代码:

const collectionInstrumentation: CollectionInstrumentation = {
  set: collectionSetInstrumentation(),
  add: collectionAddInstrumentation(),
  delete: collectionDeleteInstrumentation(),
  clear: collectionClearInstrumentation(),
}

// 重写 clear 方法
const collectionClearInstrumentation = () => {
  return function() {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    target.clear();
  }
}

我们依然通过调用原始数据的 clear 方法来清空集合,但是清空完集合之后,同样需要调用 trigger 方法。这里问题就出现了:

trigger(target, key, TRIGGER_TYPE.DELETE);

trigger 方法的第二个参数为 key,但是执行 clear 方法是删除了整个集合,没有一个固定的 key 值。所以我们这里需要重新定义一个新的 key。这里先给出实现,后面再详细介绍:

// 集合中标识clear操作的key
const CLEAR_KEY = Symbol();

// 重写 clear 方法
const collectionClearInstrumentation = () => {
  return function() {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    target.clear();
    trigger(target, CLEAR_KEY, TRIGGER_TYPE.DELETE);
  }
}

在上面的代码中,我们定义了一个新的全局变量 CLEAR_KEY,以此来标识触发了 clear 操作。我们知道删除操作会改变集合的长度,也就是会影响 size 属性,所以肯定会触发与 ITERATOR_KEY 关联的副作用函数的执行。但是 clear 操作同样也影响了集合中的每一个元素,所以我们也应该将与这些元素相关联的所有副作用函数都取出来执行,所以我们还需要修改 trigger 方法:

查看代码
const trigger = (target: any, key: string | symbol, triggerType: TRIGGER_TYPE, value?: any) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effectsToRun: Set<EffectFunction> = new Set();

    // 省略其他代码

    // 当执行了集合中的clear操作时
    if((isMap(target) || isSet(target)) && key === CLEAR_KEY) {
      depsMap.forEach((effects: Set<EffectFunction>) => {
        // 执行clear之后集合中所有的元素都被清空了,所以需要触发与每个元素相关联的副作用函数的执行
        effects.forEach(fn => {
          if (fn !== activeEffect) {
            effectsToRun.add(fn);
          }
        })
      })
    }

    // 省略其他代码


    const effects = depsMap.get(key);
    if (effects) {
      effects.forEach(fn => {
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
    }

    effectsToRun.forEach(fn => fn());
  }
};

如果 trigger 方法的执行是通过 clear 操作触发的,那么我们需要执行所有与集合中元素相关联的副作用函数。

那么至此我们就将集合中所有的修改操作都重写了,接下来我们再看看怎么去实现那些读取操作。

查询


集合中的查询操作有两个,一个是 get,用来读取 Map 中指定 key 对应的值,该方法为 Map 特有。还有一个方法为 has,我们可以通过该方法判断集合中是否含有某个元素,此方法 Map 和 Set 共有。我们先来看看 has:

const setData = new Set([1, 2, 3, 4]);
const mapData = new Map([[1, 1], [2, 2], [3, 3]]);
console.log(setData.has(1)); // true
console.log(setData.has(5)); // false
console.log(mapData.has(1)); // true
console.log(mapData.has(4)); // false

可以看到 has 方法非常简单,就是简单的判断。重写 has 也很简单:

// 重写集合 has 方法
const collectionInstrumentation: CollectionInstrumentation = {
  set: collectionSetInstrumentation(),
  add: collectionAddInstrumentation(),
  delete: collectionDeleteInstrumentation(),
  clear: collectionClearInstrumentation(),
  has: collectionHasInstrumentation()
}

const collectionHasInstrumentation = () => {
  return function(value: any) {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // 调用原生的 has 方法
    const res = target.has(value);
    // 收集副作用函数
    track(target, value);
    // 返回结果
    return res;
  }
}

实现思路还是与上面介绍的一样,这里就不赘述了。接下来我们看看 get 方法:

const mapData = new Map([[1, 1], [2, 2], [3, 3]]);
console.log(mapData.get(1)); // 1
console.log(mapData.get(4)); // undefined

get 方法为 Map 独有,用来获取 Map 中指定 key 对应的值,我们先按照上面的思路对 get 进行重写:

const collectionInstrumentation: CollectionInstrumentation = {
  set: collectionSetInstrumentation(),
  add: collectionAddInstrumentation(),
  delete: collectionDeleteInstrumentation(),
  clear: collectionClearInstrumentation(),
  has: collectionHasInstrumentation(),
  get: collectionGetInstrumentation(),
}

// 重写集合的 get 方法
const collectionGetInstrumentation = () => {
  return function(key: any) {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // 调用原生的 get 方法
    const res = target.get(key);
    // 收集依赖
    track(target, key);
    return res;
  }
}

以上就是我们重写过的 get 方法,我们先通过一个列子来验证一下:

const data = reactive(new Map([[1, 1], [2, 2]]));

effect(() => {
  console.log('data.get(1)--->', data.get(1)); // data.get(1)---> 1
});

setTimeout(() => {
  data.set(1, 5); // data.get(1)---> 5
}, 1000);

上述代码可以如我们预期的那样运行,再来看一个例子:

const data = reactive(new Map([[1, new Set([1, 2, 3])], [2, new Set([4, 5, 6])]]));

effect(() => {
  console.log('data.get(1).size--->', data.get(1).size);
})

setTimeout(() => {
  data.get(1).delete(1);
}, 1000);

在上面的例子中,我们通过 reactive 代理了一个 Map,这个 Map 的键为数字,值为 Set。然后我们在副作用方法中访问 data 中第一个元素的值,也就是 Set([1, 2, 3]),并打印其长度。一秒之后,我们删掉 Set 中的元素 1。按照预期,应该会触发副作用函数的执行。但是当执行完修改操作后,并没有触发副作用函数的执行。

这是因为,我们重写的 get 方法在执行完 target.get(key) 之后,直接返回了结果,而此时的结果并不是响应式数据,所以修改该数据的时候不会触发响应,这也就是我们前面提到过的深响应和浅响应的问题。解决办法也很简单,如果 target.get(key) 返回的数据类型是对象,则调用 reactive 即可:

查看代码
const collectionInstrumentation: CollectionInstrumentation = {
  set: collectionSetInstrumentation(),
  add: collectionAddInstrumentation(),
  delete: collectionDeleteInstrumentation(),
  clear: collectionClearInstrumentation(),
  has: collectionHasInstrumentation(),
  get: (isShallow = false) => collectionGetInstrumentation(isShallow),
}

// 重写集合的 get 方法,该方法接收一个参数用来判断是否是深响应
const collectionGetInstrumentation = (isShallow = false) => {
  return function(key: any) {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // 调用原生的 get 方法
    const res = target.get(key);
    // 收集依赖
    track(target, key);
    // 如果是浅响应,直接返回结果
    if (isShallow) {
      return res;
    }
    // 如果是深响应,递归调用 reactive
    return typeof res === 'object' ? reactive(res) : res;
  }
}

可以看到,当 target.get(key) 返回的数据类型是对象的时候,我们会通过 reactive 将返回结果包装成响应式数据,当然我们也需要考虑浅响应的情况,如果是浅响应数据,则直接返回。在上面的代码中,get 增加了一个参数,所以我们还需要修改 createReactive 方法:

查看代码
const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    get(target: any, key: string | symbol, receiver: any) {
      // 当使用 TARGET_KEY 访问对象时,返回原始数据
      if (key === TARGET_KEY) {
        return target;
      }
      // 如果是数组且key以在arrayInstrumentations中存在,说明在调用我们重写的数组方法
      if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
        // 从arrayInstrumentations返回相应的方法
        return Reflect.get(arrayInstrumentations, key, receiver);
      }

      // 代理集合类型
      if ((isMap(target) || isSet(target))) {
        // 访问 size 属性
        if (key === 'size') {
          // 与 ITERATOR_KEY 建立绑定关系,防止出现 `data.get('size')`,导致误收集
          track(target, ITERATOR_KEY);
          return Reflect.get(target, key, target);
        }
        // get 方法需要考虑深响应和浅响应
        if (key === 'get' && isMap(target)) {
          return collectionInstrumentation[key](isShallow);
        }

        // 由于我们重写了集合中的所有方法,所以直接从collectionInstrumentation中返回
        return Reflect.get(collectionInstrumentation, key, receiver);
      }

      // 非只读的时候才收集
      // 如果 key 的类型是 symbol,也不进行收集
      if (!isReadOnly && typeof key !== 'symbol') {
        track(target, key);
      }
      const res = Reflect.get(target, key, receiver);
      // 浅响应或浅只读直接返回
      if (isShallow) {
        return res;
      }
      if (res && typeof res === 'object') {
        // 深只读和深响应递归调用相应函数
        return isReadOnly ? readOnly(res): reactive(res);
      }
      return res
    },
  })
}

遍历


和之前介绍过的对象与数组一样,遍历集合也是一种读取操作。遍历集合分为两类,一类是通过内置的 forEach 方法,还有一种是使用迭代器遍历,如之前介绍过的通过 for...of 进行遍历。

forEach


我们先来看看 forEach:

const mapData = new Map([['key1', 1], ['key2', 2]]); // 定义 Map
const setData = new Set([1, 2, 3]); // 定义 Set

mapData.forEach((value: number, key: string, map: Map<string, number>) => {
  console.log('key--->', key);
  console.log('value--->', value);
  console.log('map--->', map);
})

setData.forEach((value: number, value2: number, set: Set<number>) => {
  console.log('value--->', value);
  console.log('value2--->', value2);
  console.log('set--->', set);
})

上面就是集合中 forEach 基本用法,我们用她来遍历集合。对于 Map 来说,forEach 方法接收 2 个参数:

Map.prototype.forEach ( callbackfn [ , thisArg ] )
  • callbackfn:遍历的的回调函数,稍后会详细介绍

  • thisArg:可选的参数,如果她存在,则作为调用 callbackfn 的 this 值,如果不存在,则为 undefined

callbackfn 为 forEach 的回调函数,她接收 3 个参数:

callbackfn: (value: any, key: any, map: Map<any, any>) => void
  • value:Map 的值(value)

  • key:Map 的键(key)

  • map:当前遍历的 Map

Set 的 forEach 和 Map 一样,只不过回调函数(callbackfn)的前两个参数都是 Set 中的值:

callbackfn: (value: any, value2: any, set: Set<any>) => void

更多关于 forEach 的介绍可以参见 forEach 的语言规范

与前面介绍过的数组还有对象类似,集合中元素的数量也会影响 forEach,所以我们重写 forEach 的时候依然通过 ITERATOR_KEY 来关联副作用函数,下面是重写后的 forEach 方法:

const collectionInstrumentation: CollectionInstrumentation = {
  forEach: collectionForEachInstrumentation(),
}

// 重写 forEach
const collectionForEachInstrumentation = () => {
  return function(callback: (key: any, value: any, target: any) => any, thisArg: any) {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // 收集依赖,size 改变的时候应该触发 forEach 的响应
    track(target, ITERATOR_KEY);
    target.forEach((value: any, key: any) => {
      // 通过call来调用callback,这样可以指定this值
      callback.call(thisArg, value, key, this)
    });
  }
}

这样我们就完成了对 forEach 的重写,接下来通过一个例子验证一下:

// 定义响应式数据
const data = reactive(new Map([['key1', 1], ['key2', 2]])); 

effect(() => {
  // 遍历集合
  data.forEach((value: number, key: string) => {
    console.log('key--->', key);
    console.log('value--->', value);
  });
});

// 向map中新添加一个键值对['key3', 3]
setTimeout(() => {
  data.set('key3', 3);
}, 1000);

上面的代码能够按照我们的预期执行,当向集合中新增一个键值对的时候,能够触发副作用函数的执行。那么接下来再来看一个例子:

// 定义响应式数据,Map 的值为 Set 类型
const data = reactive(new Map([[
  'key', new Set([1, 2, 3])
]]))

effect(() => {
  // 遍历集合,并打印 Set 的长度
  data.forEach((value: Set<number>) => {
    console.log('value.size--->', value.size);
  });
})

setTimeout(() => {
  // 删除 Set 中的第一个元素
  data.get('key').delete(1);
}, 1000);

在上面的代码中,我们定义了一个响应式 Map 数据,她的值为 Set 类型。然后我们在副作用函数中使用 forEach 对其进行遍历,在遍历的过程中打印 value 的长度,也就是 Set 类型的长度。之后我们删除 data 中 Set 的第一个元素,运行上述代码,发现并没有触发副作用函数的执行。出现这样的原因和我们之前提到过的类似,当运行 value.size 时,这里的 value 是原始数据,不是响应式数据,所以没有建立响应联系,所以当我们修改数据时,不会触发响应。这其实不符合直觉,所以我们需要对之前实现的 forEach 进行修改:

// 对数据进行响应式处理
const wrap = (target: any, isShallow = false) => {
  // 浅响应直接返回
  if (isShallow) {
    return target;
  }
  // 如果是对象则通过 reactive 方法封装
  return typeof target === 'object' ? reactive(target) : target
}

// 重写 forEach
const collectionForEachInstrumentation = (isShallow = false) => {
  return function(callback: (key: any, value: any, target: any) => any, thisArg: any) {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // 收集依赖,size 改变的时候应该触发 forEach 的响应
    track(target, ITERATOR_KEY);
    target.forEach((value: any, key: any) => {
      // 通过call来调用callback,这样可以指定this值
      callback.call(thisArg, wrap(value, isShallow), wrap(key, isShallow), this)
    });
  }
}

在上面的代码中,我们首先定义了一个工具方法 wrap 用来处理响应式数据。当使用原始数据的 forEach 进行遍历的时候,我们通过该方法对 key 和 value 进行包装,这样就把传递给 callback 函数的参数包装成了响应式的。这样当我们再次运行上面的例子,就可以如期工作了。

但是上面的代码还是存在缺陷,我们来看一个例子:

// 定义响应式数据
const data = reactive(new Map([['key', 'value']]))

effect(() => {
  // 遍历集合,并打印 key 和 value
  data.forEach((key: string, value: string) => {
    console.log('key---->', key);
    console.log('value--->', value);
  });
})

setTimeout(() => {
  // 修改集合中的值
  data.set('key', 'newValue');
}, 1000);

在上面的代码中,我们定义了一个响应式 Map。然后在副作用函数中,通过 forEach 来遍历她,并打印 key 和 value。接着我们修改了集合中元素的值。运行上述代码,发现副作用函数并没有重新执行。这是有问题的,因为修改操作应该要触发副作用函数的执行才对。那么为什么会出现这种情况呢?

我们知道,通过 for...in 遍历对象以及通过 forEach 遍历集合,都是将副作用函数与 ITERATOR_KEY 进行关联的。但是 forEach 之于集合与 for...in 之于对象还是有些区别的。我们知道 for...in 在遍历对象的时候只关心集合的键,只有删除和添加操作才会影响集合的键,而修改对象不会。所以只有删除和添加操作才会触发与 for...in 相关副作用函数的执行。但是通过 forEach 遍历集合就不一样的,forEach 既关心集合的键,也关心集合的值。所以修改集合也应该触发与 forEach 相关副作用函数的执行:

查看代码
const trigger = (target: any, key: string | symbol, triggerType: TRIGGER_TYPE, value?: any) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effectsToRun: Set<EffectFunction> = new Set();

    // 省略其他代码

    // 当新增属性和删除属性的时候才触发副作用函数的执行
    // 取出 for...in 的副作用函数集合
    // 如果数据类型是 Map,SET操作也应该执行副作用函数
    const iteratorEffects = depsMap.get(ITERATOR_KEY);
    if (iteratorEffects && 
        (triggerType === TRIGGER_TYPE.ADD || 
          triggerType === TRIGGER_TYPE.DELETE || 
          (triggerType === TRIGGER_TYPE.SET && isMap(target)))) {
      iteratorEffects.forEach(fn => {
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
    }

    // 省略其他代码

    const effects = depsMap.get(key);
    if (effects) {
      effects.forEach(fn => {
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
    }

    effectsToRun.forEach(fn => fn());
  }
};

在上面我们修改了 trigger 方法,当原始数据为 Map 类型时,设置操作也应该触发与 ITERATOR_KEY 相关副作用函数的执行。

迭代器


在上一篇文章中,我们说过,如果某个对象实现了 @@iterator 方法,那么该对象是可迭代对象,并且能通过 for...of 进行遍历。我们先来看一个例子:

const mapData = new Map([[1, 1], [2, 2]]);

for(const value of mapData) {
  console.log('value---->', value);
}

上面的代码能够正常打印,再来看看下面的例子:

const data = reactive(new Map([[1, 1], [2, 2]]));

for(const value of data) {
  console.log('value---->', value);
}

运行上面的代码,浏览器会抛出如下异常:

Uncaught (in promise) TypeError: Method Map.prototype.entries called on incompatible receiver #<Map>

这个异常与我们文章开头遇到的非常类似,可是为什么会出现这个异常呢。在上一篇文章里面,我们直接通过这种方式遍历代理数组并不会报错,为什么遍历代理集合就有问题呢?通过查阅语言规范可以发现:

原来集合是通过 entries 来获取迭代器的,前面我们介绍过调用集合中的方法首先会检查 internal slot,这就能解释为什么会出现上述异常了。我推测集合应该是这样去实现 @@iterator 方法的:

[Symbol.iterator]() {
  return this.entries();
}

如果我们直接实现集合中的 @@iterator 方法,就不会报错了:

查看代码
const collectionInstrumentation: CollectionInstrumentation = {
  [Symbol.iterator]: collectionIteratorInstrumentation()
}

// 重写迭代器
const collectionIteratorInstrumentation = () => {
  return function() {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // 调用原生的迭代器
    const itr = target[Symbol.iterator]();
    // 收集
    track(target, ITERATOR_KEY);
    return {
      // 重写 next 方法
      next() {
        // 调用原生的 next 方法
        const { value, done } = itr.next();
        let wrapValue = wrap(value);
        // 如果是 Map 类型
        if (isMap(target)) {
          wrapValue = value ? [wrap(value[0]), wrap(value[1])] : value;
        }
        return {
          value: wrapValue,
          done,
        }
      },
      [Symbol.iterator]() {
        return this;
      }
    }
  }
}

在上面的代码中,我们重写了集合的迭代器,实现的思路也是通过调用原始数据的迭代器,然后通过 ITERATOR_KEY 进行副作用函数的关联。需要注意的是,在 next() 方法中,我们同样需要像 forEach 那样考虑响应式数据的问题。我们知道,集合中原生的迭代器是通过 entries 方法获取,所以我们可以直接重写 entries:

const collectionInstrumentation: CollectionInstrumentation = {
  [Symbol.iterator]: collectionIteratorInstrumentation(),
  entries: collectionIteratorInstrumentation()
}

keys 与 values


集合中的 keys 和 values 方法也会返回迭代器,对于 Set 来说,这两个方法没有什么区别。但是对于 Map,keys 返回的是键,values 则返回的是值。有了前面的铺垫,我们很容易就能实现对 keys 和 values 的重写:

查看代码
// Map 类型迭代key
const MAP_ITERATOR_KEY = Symbol();

// 重写 keys 和 values 方法,该方法接收一个参数,用来标识是否是 keys
const collectionKeysOrValuesInstrumentation = (isKeys = false) => {
  return function() {
    // 获取原始数据,也就是集合本身
    const target = this[TARGET_KEY];
    // 调用原生的 values
    let itr = target.values();
    // 如果是 keys,则调用原生的 keys 方法
    if (isKeys) {
      itr = target.keys();
    }    
    if (isMap(target) && isKeys) {
      // Map 类型有专门的 key
      track(target, MAP_ITERATOR_KEY);
    } else {
      track(target, ITERATOR_KEY);
    }    
    return {
      next() {
        const { value, done } = itr.next();
        return {
          value: wrap(value),
          done,
        }
      },
      [Symbol.iterator]() {
        return this;
      }
    }
  }
}

我们将 values 和 keys 的重写通过一个方法来实现,当代理数据使用 values 时,我们通过调用原始数据的 values,反之则调用原始数据的 keys。这里有一点不同的是,如果是 Map 调用 keys 我们需要通过 MAP_ITERATOR_KEY 来与副作用函数关联。因为 keys 方法只会影响 Map 的 key,只有向 Map 中添加和删除元素才会影响 Map 的 key,修改操作是不会的。我们还需要修改 trigger 函数:

查看代码
const trigger = (target: any, key: string | symbol, triggerType: TRIGGER_TYPE, value?: any) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effectsToRun: Set<EffectFunction> = new Set();

    // 省略其他代码

    // 如果是 Map 数据类型,取出与 MAP_ITERATOR_KEY 关联的副作用执行
    const mapKeyIteratorEffects = depsMap.get(MAP_ITERATOR_KEY);
    if (isMap(target) && mapKeyIteratorEffects && (triggerType === TRIGGER_TYPE.ADD || triggerType === TRIGGER_TYPE.DELETE)) {
      mapKeyIteratorEffects.forEach(fn => {
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
    }

    // 省略其他代码

    const effects = depsMap.get(key);
    if (effects) {
      effects.forEach(fn => {
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
    }

    effectsToRun.forEach(fn => fn());
  }
};

这样在通过 keys 方法遍历 Map 的时候,就避免了修改操作也会触发响应的情况。

最后


本篇我们介绍了集合的响应式方案。因为集合中的方法和属性在被访问的时候,都会先去判断 internal slot ,所以我们采用的方案就是重写集合中所有的方法。那么至此我们对对象的代理就已经结束了。但是 Vue3 的响应式系统不仅可以代理对象(引用值),还可以代理原始值。我们将在后面的文章中介绍如何代理原始值。本篇代码请戳