非原始值的响应式方案 - 集合

下面将介绍集合类型数据的响应式方案。集合类型包括 Map/Set 以及 WeakMap/WeakSet。使用 Proxy 代理集合类型的数据不同于代理普通对象,因为集合类型数据的操作与普通对象存在很大的不通。下面使 Set 和 Map 这两个数据类型的原型属性和方法。

Set 类型的原型属性和方法:

  1. size:返回集合中元素的数量;
  2. add(value):向集合中添加给定的值;
  3. clear():清空集合;
  4. delete(value):从集合中删除给定的值;
  5. has(value):判断集合中是否存在给定的值;
  6. keys():返回一个迭代器对象。可以用于 for…of 循环,迭代器对象产生的值为集合中的元素值;
  7. values():对于 Set 集合类型来说,keys() 和 values() 等价;
  8. entries():返回一个迭代器对象。迭代过程中为集合中的每一个元素产生一个数组值 [value, value];
  9. forEach(callback[, thisArg]):forEach 函数会遍历集合中的所有元素,并对每一个元素调用 callback 函数。forEach 函数接收可选的第二个参数 thisArg,用于执行 callback 函数执行时的 this 值。

Map 类型的原型属性和方法:

  1. size:返回 Map 数据中的键值对数量;;
  2. clear():清空 Map;
  3. delete(key):删除指定 key 的键值对;
  4. has(key):判断 Map 中是否存在指定 key 的键值对;
  5. get(key):读取指定 key 对应的值;
  6. set(key, value):为 Map 设置新的键值对;
  7. keys():返回一个迭代器对象。迭代过程中会产生键值对的 key 值;
  8. values():返回一个迭代器对象。迭代过程中会产生键值对的 value 值;
  9. entries():返回一个迭代器对象。迭代过程中会产生由 [key, value] 组成数组值;
  10. forEach(callback[, thisArg]):forEach 函数会遍历集合中的所有键值对,并对每一个元素调用 callback 函数。forEach 函数接收可选的第二个参数 thisArg,用于执行 callback 函数执行时的 this 值。

Map 和 Set 这两个数据类型的操作方法相似。它们之间最大的不同体现在,Set 类型使用 add(value) 方法添加严肃,Map 类型使用 set(key, value) 方法设置键值对,并且 Map 类型可以使用 get(key) 方法读取相应的值。这意味着我们可以用相同的处理方法来实现对它们的代理。

如何代理 Set 和 Map

Set 和 Map 类型的数据有特定的属性和方法用来操作自身。这一点与普通对象不同。

// 普通对象的读取和设置操作 const obj = { foo: 1 }; obj.foo; obj.foo = 2; // 用 get/set 方法操作 map 数据 const map = new Map(); map.set('key', 1); map.get('key');

正是因为有这些差异的存在,我们不能像代理普通对象那样代理 Set 和 Map 类型的数据。但整体思路不变,即当读取操作发生时,调用 track 函数建立响应关系;当设置操作发生时,调用 trigger 函数触发响应。

const proxy = reactive(new Map([['key', 1]])); effect(() => { console.log(proxy.get('key')); }); proxy.set('key', 2);

这段代码展示的效果是我们最终要实现的目标。实现之前,我们需要先了解使用 Proxy 代理 Set 或 Map 类型数据的注意事项。

const s = new Set([1, 2, 3]); const p = new Proxy(s, {}); console.log(p.size); // Method get Set.prototype.size called on incompatible receiver #<Set>

这段代码中,我们定义了一个 Set 类型的数据 s,接着为它创建一个代理对象 p。由于代理的目标对象是 Set 类型,因此我们可以通过读取它的 p.size 属性获取元素的数量。但是,执行代码时我们会得到一个错误。错误信息的大意是 “在不兼容的 receiver 上调用了 get Set.prototype.size方法”。size 属性应该是一个访问器属性,所以它作为方法被调用了。

https://tc39.es/ecma262/#sec-get-set.prototype.size

24.2.3.9 get Set.prototype.size Set.prototype.size is an accessor property whose set accessor function is undefined. Its get accessor function performs the following steps: 1. Let S be the this value. 2. Perform ? RequireInternalSlot(S, [[SetData]]). 3. Let entries be the List that is S.[[SetData]]. 4. Let count be 0. 5. For each element e of entries, do a. If e is not empty, set count to count + 1. 6. Return 𝔽(count).

Set.prototype.size 是一个访问器属性,它的 set 访问器函数是 undefined,它的 get 访问器函数会执行以下步骤。

  1. 让 S 的值为 this;
  2. 执行 ? RequireInternalSlot(S, [[SetData]])
  3. 让 entries 的值为 List,即 S.[[SetData]]
  4. 让 count 的值为 0
  5. 对于 entries 中的每个元素 e,执行:
    1. 如何 e 不是空的,则将 count 设置为 count + 1
  6. 𝔽(count)

由此可知,Set.prototype.size 是一个访问器属性。关键点在第 1 步和第 2 步。根据第 1 步的描述:让 S 的值为 this。这里的 this 是代理对象 p,因为我们是通过代理对象 p 来访问 size 属性的。在第 2 步中,调用抽象方法 RequireInternalSlot(S. [[SetData]]) 来检查 S 是否存在内部槽 [[SetData]] 。很显然,代理对象 S 不存在 [SetData] 这个内部槽,于是会抛出错误。

为了修复这个问题,我们需要修正访问器属性的 getter 函数执行的 this 指向。

const s = new Set([1, 2, 3]); const p = new Proxy(s, { get (target, key, receiver) { if (key === 'size') { // 如果读取的时 size 属性 // 通过指定第三个参数 receiver 为原始对象 target 从而修复问题 return Reflect.get(target, key, target); } // 读取其他属性的默认行为 return Reflect.get(target, key, receiver); } }); console.log(p.size);

我们在创建代理对象时增加了 get 拦截函数。然后检查读取的属性名称是不是 size,如果是,则在调用 Reflect.get 函数时指定第三个参数为原始 Set 对象,这样访问器属性 size 的 getter 函数在执行时,其 this 指向的就是原始 Set 对象而非代理对象。由于原始 Set 对象上存在 [[SetData]] 内部槽,因此程序得以正确运行。

接着,我们再来尝试从 Set 中删除数据。

const s = new Set([1, 2, 3]); const p = new Proxy(s, { get (target, key, receiver) { if (key === 'size') { return Reflect.get(target, key, target); } return Reflect.get(target, key, receiver); } }); p.delete(1); // Method Set.prototype.delete called on incompatible receiver #<Set>

可以看到,调用 p.delete 方法时会得到一个错误,这个错误与前文讲解的访问 p.size 属性发生的错误相似。

访问 p.size 与访问 p.delete 是不同的。因为 size 是属性,是一个访问器属性,而 delete 是一个方法。当访问 p.size 时,访问器的 getter 函数会立即执行,此时我们可以通过修改 receiver 来改变 getter 函数的 this 指向。而当访问 p.delete 时,delete 方法并没有执行,真正使其执行的语句是 p.delete(1) 这句函数调用。因此,无论如何修改 receiver,delete 方法执行时的 this 都会指向代理对象 p,而不会指向原始 Set 对象。想要修复这个问题也不难,只需要把 delete 方法与原始数据对象绑定即可。

const s = new Set([1, 2, 3]); const p = new Proxy(s, { get (target, key, receiver) { if (key === 'size') { return Reflect.get(target, key, target); } // 将方法与原始数据对象 target 绑定后返回 return target[key].bind(target); } }); p.delete(1);

上面这段代码中,我们使用 target[key].bind(target) 代替了 Reflect.get(target, key, receiver) 。可以看到,我们使用 bind 函数将用于操作数据的方法与原始数据对i选哪个 target 做了绑定。这样当 p.delete 语句执行时,delete 函数的 this 总是指向原始数据对象而非代理对象,于是代码可以正确执行。

const isPlainSet = (obj) => Object.prototype.toString.call(obj) === '[object Set]'; const isPlainMap = (obj) => Object.prototype.toString.call(obj) === '[object Map]'; function crateReactive (obj, isShallow = false, isReadonly = false) { return new Proxy(obj, { get (target, key, receiver) { // 针对 Set,Map 特殊处理 if (isPlainMap(obj) || isPlainSet(obj)) { if (key === 'size') { return Reflect.get(target, key, target); } return target[key].bind(target); } if (key === 'raw') { return target; } if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) { return Reflect.get(arrayInstrumentations, key, receiver); } if (!isReadonly && typeof key !== 'symbol') { track(target, key); } const res = Reflect.get(target, key, receiver); if (isShallow) { return res; } if (isPlainObject(res)) { return isReadonly ? readonly(res) : reactive(res); } return res; } }); }

这样,我们就饿可以很简单地创建代理数据。

const p = reactive(new Set([1, 2, 3])); console.log(p.size); p.delete(1); console.log(p.size);

建立响应关系

了解如何为 Set 和 Map 类型数据创建代理后,我们就可以着手实现 Set 类型数据的响应式方案了了。

const p = reactive(new Set([1, 2, 3])); effect(() => { console.log(p.size); }); p.add(1);

首先,在副作用函数内访问了 p.size 属性;接着,调用 p.add 函数想集合中添加数据。由于这个行为会间接改变集合中的 size 属性值,我们我们期望副作用函数会重新执行。我们需要在访问 size 属性时调用 track 函数进行依赖追踪,然后在 add 方法执行时调用 trigger 函数触发响应。

const isPlainSet = (obj) => Object.prototype.toString.call(obj) === '[object Set]'; const isPlainMap = (obj) => Object.prototype.toString.call(obj) === '[object Map]'; function crateReactive (obj, isShallow = false, isReadonly = false) { return new Proxy(obj, { get (target, key, receiver) { // 针对 Set,Map 特殊处理 if (isPlainMap(obj) || isPlainSet(obj)) { if (key === 'size') { // 调用 track 函数建立响应关系 track(target, ITERATE_KEY); return Reflect.get(target, key, target); } return target[key].bind(target); } // ... return res; } }); }

当读取 size 属性是,只需要调用 track 函数建立响应关系即可。这里需要注意,响应联系需要建立在 ITERATE_KEY 与副作用函数之间,这是因为任何新增、删除操作都会影响 size 属性。接下来,我们来看如何触发响应。当调用 add 方法向集中添加新元素时,应该怎么触发响应呢?我们需要实现一个自定义 add 方法。

const mutableInstrumentations = {}; const mutableInstrumentations = { add () {} }; function crateReactive (obj, isShallow = false, isReadonly = false) { return new Proxy(obj, { get (target, key, receiver) { if (key === 'raw') { return target; } // Set,Map 特殊处理 if (isPlainMap(obj) || isPlainSet(obj)) { if (key === 'size') { // 调用 track 函数建立响应关系 track(target, ITERATE_KEY); return Reflect.get(target, key, target); } // return target[key].bind(target); // 返回定义在 mutableInstrumentations 对象下的方法 return mutableInstrumentations[key]; } // 如果操作的目标对象是数组,并且 key 存在于 arrayInstrumentations 上 // 那么返回定义在 arrayInstrumentations 上的值 if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) { return Reflect.get(arrayInstrumentations, key, receiver); } if (!isReadonly && typeof key !== 'symbol') { track(target, key); } const res = Reflect.get(target, key, receiver); if (isShallow) { return res; } if (isPlainObject(res)) { return isReadonly ? readonly(res) : reactive(res); } return res; } }); }

首先,定义一个对象 mutableInstrumentations , 我们会将所有自定义实现的方法都定义到该对象下。例如 mutableInstrumentations.add 方法,然后,在 get 拦截函数内返回定义在 mutableInstrumentations 对象中的方法。这样,当通过 p.add 获取方法时,得到的就是我们自定义的 mutableInstrumentations.add 方法。

const mutableInstrumentations = { add (key) { // this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象 const target = this.raw; // 通过原始对象对象执行 add 方法删除具体的值 // 这里不再徐亚 .bind 了,因为是直接通过 target 调用并执行的 const res = target.add(key); // 调用 trigger 函数触发响应,并指定操作类型为 ADD trigger(target, key, TRIGGER_TYPE.ADD); // 返回操作结果 return res; } };

自定义的 add 函数内 this 仍然指向代理对象,所以需要通过 this.raw 获取获取数据对象。有了原始数据对象后,就可以通过它调用 target.add 方法,这样就不再需要 .bind 绑定了。代添加操作完成后,调用 trigger 函数触发响应。需要注意的时,我们指定了操作类型为 ADD,这一点很重要。

function trigger (target, key, type, newVal) { // 使用 target 从 bucket 中获取 depsMap,key -> effects const depsMap = bucket.get(target); if (!depsMap) return; // 根据 key 从 depsMap 中获取 effects const effects = depsMap.get(key); const effectsToRun = new Set(); // ... // 操作类型为 ADD 或 DELETE 时,需要触发与 ITERATE_KEY 相关联的副作用函数执行 if (type === TRIGGER_TYPE.ADD || type === TRIGGER_TYPE.DELETE) { // 获取与 ITERATE_KEY 相关联的副作用函数 const iterateEffects = depsMap.get(ITERATE_KEY); // 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun iterateEffects && iterateEffects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); } // ... // effects && effects.forEach(fn => fn()); 避免与 cleanup 产生死循环 effectsToRun.forEach(effectFn => { // 如果存在调度器,则调用该调度器,并将副作用函数作为参数传递 if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { effectFn(); } }); }

当操作类型是 ADDDELETE ,会取出与 ITERATE_KEY 相关联的副作用函数并执行,这样就可以通过访问 size 属性所收集的副作用函数来执行了。

当然,如果调用 add 方法添加的元素已经存在于 set 集合中,就不再需要触发响应,这样做对性能更加友好。

const mutableInstrumentations = { add (key) { // this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象 const target = this.raw; // 先判断值是否已经存在 const hadKey = target.has(key); // 只有再值不存在情况下,才需要触发响应 if (!hadKey) { // 通过原始对象对象执行 add 方法删除具体的值 // 这里不再徐亚 .bind 了,因为是直接通过 target 调用并执行的 const res = target.add(key); // 调用 trigger 函数触发响应,并指定操作类型为 ADD trigger(target, key, TRIGGER_TYPE.ADD); // 返回操作结果 return res; } return target; } };

这段代码中,我们先调用 target.has 方法判断值是否已经存在,只有在值不存在的情况下才需要触发响应。

在此基础上,我们可以按照类似的思路轻松地实现 delete 方法。

const mutableInstrumentations = { add (key) { // this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象 const target = this.raw; // 先判断值是否已经存在 const hadKey = target.has(key); // 只有再值不存在情况下,才需要触发响应 if (!hadKey) { // 通过原始对象对象执行 add 方法删除具体的值 // 这里不再徐亚 .bind 了,因为是直接通过 target 调用并执行的 const res = target.add(key); // 调用 trigger 函数触发响应,并指定操作类型为 ADD trigger(target, key, TRIGGER_TYPE.ADD); // 返回操作结果 return res; } return target; }, delete (key) { const target = this.raw; const hadKey = target.has(key); const res = target.delete(key); if (hadKey) { trigger(target, key, TRIGGER_TYPE.DELETE); } return res; } };
const p = reactive(new Set([1, 2, 3])); effect(() => { console.log(p.size, p); }); p.add(1); p.add(4); // 4 Set(4) { 1, 2, 3, 4 } p.delete(5); p.delete(2); // 3 Set(3) { 1, 3, 4 }

如上面的代码所示,与 add 方法的区别在于,delete 方法只有在要删除的元素确实在集合中存在时,才需要触发响应,这一点恰好与 add 方法相反。

避免污染原始数据

这一节中,我们借助 Map 类型数据的 set 和 get 来讲解什么是 “避免污染原始数据” 及其原因。

Map 数据类型拥有 get 和 set 这两个方法,当调用 get 方法读取数据时,需要调用 track 函数追踪依赖建立响应关系;当调用 set 方法设置数据时,需要调用 trigger 方法触发响应。

const p = reactive(new Map([['key', 1]])); effect(() => { console.log(p.get('key')); }); p.set('key', 2);

其实想要实现上面这段代码所展示的功能并不难,因为我们已经有了实现 add、delete 等方法的经验。

const mutableInstrumentations = { // ... get (key) { // 获取原始对象 const target = this.raw; // 判断读取的 key 是否存在 const hadKey = target.has(key); // 追踪依赖,建立响应联系 track(target, key); // 如果存在,则返回结果。如果得到的结果 res 仍然是可代理的数据,则要返回使用 reactive 包装后的响应式数据 if (hadKey) { const res = target.get(key); return isPlainObject(res) ? reactive(res) : res; } } };

在非浅响应的情况下,如果得到的数据仍然可以被代理,那么要调用 reactive(res) 将数据转换成响应式数据后返回。在浅响应模式下,就不需要这一步了。我们可以在 crateReactive get 函数中定义 this.isShallow 属性,在 mutableInstrumentations 中获取 isShallow 属性进行判断。

接着,我们来讨论 set 方法的实现。简单来说,当 set 方法被调用后,需要调用 trigger 方法触发响应。只不过在触发响应的时候,需要区分操作的类型时 SET 还是 ADD。

const mutableInstrumentations = { add (key) { // this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象 const target = this.raw; // 先判断值是否已经存在 const hadKey = target.has(key); // 只有再值不存在情况下,才需要触发响应 if (!hadKey) { // 通过原始对象对象执行 add 方法删除具体的值 // 这里不再徐亚 .bind 了,因为是直接通过 target 调用并执行的 const res = target.add(key); // 调用 trigger 函数触发响应,并指定操作类型为 ADD trigger(target, key, TRIGGER_TYPE.ADD); // 返回操作结果 return res; } return target; }, delete (key) { const target = this.raw; const hadKey = target.has(key); const res = target.delete(key); if (hadKey) { trigger(target, key, TRIGGER_TYPE.DELETE); } return res; }, get (key) { // 获取原始对象 const target = this.raw; // 判断读取的 key 是否存在 const hadKey = target.has(key); // 追踪依赖,建立响应联系 track(target, key); // 如果存在,则返回结果。如果得到的结果 res 仍然是可代理的数据,则要返回使用 reactive 包装后的响应式数据 if (hadKey) { const res = target.get(key); return isPlainObject(res) ? reactive(res) : res; } }, set (key, value) { const target = this.raw; const hadKey = target.has(key); // 获取旧值 const oldVal = target.get(key); // 设置新值 target.set(key, value); // 如果不存在,则说明是 ADD 类型的操作 if (!hadKey) { trigger(target, key, TRIGGER_TYPE.ADD); } else if (oldVal !== value && (oldVal === oldVal || value === value)) { // 如果存在,并且值变化,则是 SET 操作 trigger(target, key, TRIGGER_TYPE.SET); } } };

这段代码的关键点在于,我们需要判断设置的 key 是否存在,以便区分不同的操作类型。我们知道,对于 SET 类型和 ADD 类型的操作来说,它们最终触发的副作用函数是不同的。因为 ADD 类型的操作会对数据的 size 属性产生影响,所以依赖 size 属性的副作用函数都需要在 ADD 类型的操作发生时重新执行。

上面给出的 set 函数实现可以正常工作,但它仍然存在问题,set 方法会污染原始数据。

const m = new Map(); const p1 = reactive(m); const p2 = reactive(new Map()); p1.set('p2', p2); effect(() => { console.log(m.get('p2').size, m.get('p2')); }); m.get('p2').set('foo', 1);

这段代码中我们创建了一个原始 Map 对象 m,p1 是对象 m 的代理对象,接着创建另外一个代理对象 p2,并将其作为值设置给 p1,即 p1.set('p2', p2)。接下来问题出现了,在副作用函数中,我们通过原始数据 m 来读取数据值,然后又通过原始数据 m 来设置数据值,此时发现副作用函数重新执行了。这其实并不符合我们的预期,因为原始数据不应该具有响应式数据据的能力,否则就意味着用户既可以操作原始数据,又能够操作响应式数据,这样一来代码就乱套了。

导致问题出现的原因就是我们实现的 set 方法。

const mutableInstrumentations = { // ... set (key, value) { const target = this.raw; const hadKey = target.has(key); // 获取旧值 const oldVal = target.get(key); // 设置新值 target.set(key, value); // 如果不存在,则说明是 ADD 类型的操作 if (!hadKey) { trigger(target, key, TRIGGER_TYPE.ADD); } else if (oldVal !== value && (oldVal === oldVal || value === value)) { // 如果存在,并且值变化,则是 SET 操作 trigger(target, key, TRIGGER_TYPE.SET); } } };

在 set 方法内,我们把 value 设置到了原始数据 target 上。如果 value 是响应式数据,就意味着设置到原始对象上的也是响应式数据,我们把响应式数据设置到原始数据上的行为称为数据污染

要解决数据污染也不难,只需要在调用 target.set 函数设置值之前对值进行检查即可:只要发现即将要设置的值是响应式数据,那么就通过 raw 属性获取原始数据,再把原始数据设置到 target 上。

const mutableInstrumentations = { // ... set (key, value) { const target = this.raw; const hadKey = target.has(key); // 获取旧值 const oldVal = target.get(key); // 获取原始数据据,由于 value 本身可能已经是原始数据,所以此时 value.raw 不存在,则直接使用 value const rawValue = value.raw || value; // 设置新值 target.set(key, rawValue); // 如果不存在,则说明是 ADD 类型的操作 if (!hadKey) { trigger(target, key, TRIGGER_TYPE.ADD); } else if (oldVal !== value && (oldVal === oldVal || value === value)) { // 如果存在,并且值变化,则是 SET 操作 trigger(target, key, TRIGGER_TYPE.SET); } } };

现在的实现已经不会造成数据污染了。不过,观察上面的代码,会发现新的问题。我们一直使用 raw 属性来访问原始数据是由缺陷的,因为它可能与用户自定义的 raw 属性冲突,错译在一个严谨的实现中,我们需要使用唯一的标识来作为原始数据的键,例如使用 Symbol 类型来代替。

除了 set 方法需要避免污染原始数据之外,Set 类型的 add 方法、普通对象的写值操作,还有为数组添加元素的方法等,都需要做类似的处理。

处理 forEach

集合类型的 forEach 方法类似于数组的 forEach 方法。

const m = new Map([ [{ key: 1 }, { value: 1 }] ]); effect(() => { m.forEach((value, key, m) => { console.log(value); // { value: 1 } console.log(key); // key: 1 } }) });

以 Map 为例,forEach 方法接收一个回调函数作为参数,该回调函数会在 Map 的每个键值对上被调用。回调函数接收三个参数,分别是值、键以及原始对象。

遍历操作与键值对的数量有关,因此会修改 Map 对象键值对数量的操作都应该触发副作用函数重新执行,例如 delete 和 add 方法等。所以当 forEach 函数被停用时,我们应该让副作用函数与 ITERATE_KEY 建立响应联系。

const mutableInstrumentations = { // ... forEach (callback) { // 取得原始数据对象 const target = this.raw; // 与 ITERATE_KEY 建立响应关系 track(target, ITERATE_KEY); // 通过原始数据对象调用 forEach 方法,并把 callback 传递过去 target.forEach(callback); } };
const m = reactive(new Map([ [{ key: 1 }, { value: 1 }] ])); effect(() => { m.forEach((value, key, m) => { console.log(value); console.log(key); }) }); m.set({ key: 2 }, { value: 2 });

上述代码可以按照预期工作,但是给出的 forEach 函数仍然存在缺陷,我们在自定义实现的 forEach 方法内,通过原始数据对象调用了原生的 forEach 方法。

// 通过原始数据对象调用 forEach 方法,并把 callback 传递过去 target.forEach(callback);

这意味着,传递给 callback 回调函数的参数都是非响应式数据。

const key = { key: 1 }; const value = new Set([1, 2, 3]); const p = reactive(new Map([ [key, value] ])); effect(() => { p.forEach((value, key) => { console.log(value.size); }) }); p.get(key).delete(1);

在上面这段代码中,响应式数据 p 有一个键值对,其中键是普通对象 { key: 1 },值是 Set 类型的原始数据 new Set([1, 2, 3]) 。接着,我们在副作用函数中使用 forEach 方法遍历 p,并在回调函数中访问 value.size 。最后,我们尝试删除 Set 类型数据中为 1 的元素,会发现不能触发副作用函数执行。导致问题的原因就是上面提到的,当通过 value.size 访问 size 属性时,这里的 value 是原始数据对象,即 new Set([1, 2, 3]) ,而非响应式数据对象,因此无法建立响应联系。但这其实并不符合我们的直觉,因为 reactive 本身是深响应,forEach 方法的回调函数所接收到的参数也应该是响应式数据才对。为了解决这个问题,我们需要修改一下实现。

const mutableInstrumentations = { // ... forEach (callback) { // wrap 函数用来把可代理的值转换为响应式数据 const wrap = (val) => typeof val === 'object' ? reactive(val) : val; // 取得原始数据对象 const target = this.raw; // 与 ITERATE_KEY 建立响应关系 track(target, ITERATE_KEY); // 通过原始数据对象调用 forEach 方法,并把 callback 传递过去 target.forEach((v, k) => { // 手动调用 callback,用 wrap 函数包裹 vlaue 和 key 再传给 callback,这样就实现了深响应 callback(wrap(v), wrap(k), this); }); } };

思路很简单,既然 callback 函数的参数不是响应式的,那就将它转换成响应式的。所以在上面的代码中,我们又对 callback 函数的参数做了一层包装,即把传递给 callback 函数的参数包装成响应式的。此时,如果再次尝试运行前文的例子,会发现它能够按预期工作。

出于严谨性,我们还需要做一些补充。因为 forEach 函数除了接收 callback 作为参数,还可以接收第二个参数,该参数可以用来指定 callback 函数执行时的 this 值。

const mutableInstrumentations = { // ... forEach (callback, thisArg) { // wrap 函数用来把可代理的值转换为响应式数据 const wrap = (val) => typeof val === 'object' ? reactive(val) : val; // 取得原始数据对象 const target = this.raw; // 与 ITERATE_KEY 建立响应关系 track(target, ITERATE_KEY); // 通过原始数据对象调用 forEach 方法,并把 callback 传递过去 target.forEach((v, k) => { // 手动调用 callback,用 wrap 函数包裹 vlaue 和 key 再传给 callback,这样就实现了深响应 callback.call(thisArg, wrap(v), wrap(k), this); }); } };

解决上述问题之后,我们的工作还没有完成。无论是使用 for...in 循环遍历一个对象,还是使用 forEach 循环遍历一个集合,它们的响应联系都是建立在 ITERATE_KEY 与副作用函数之间的。然而,使用 for...in 来遍历对象与使用 forEach 遍历集合之间存在本质的不同。具体体现在,当使用 for...in 循环遍历对象时,它只关心对象的键,而不关心对象的值。

effect(() => { for (const key in obj) { console.log(key); } });

只有当新增、删除对象的 key 时,才需要重新执行副作用函数。所以我们在 trigger 函数内判断操作类型是否是 ADDDELETE ,进而知道是否需要触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。对于 SET 类型的操作来说,因为它不会改变一个对象的键的数量,所以当 SET 类型的操作发生时,不需要触发副作用函数重新执行。

但这个规则不适用与 Map 类型的 forEach 遍历。

const m = reactive(new Map([ ['key', 1] ])); effect(() => { m.forEach((value, key) => { // forEach 循环不仅关心集合的键,还关心集合的值 console.log(value); }) }); m.set('key', 2);

当使用 forEach 遍历 Map 类型的数据时,它既关心键,又关心值。这意味着,当调用 p.set('key', 2) 修改值的时候,也应该触发副作用函数重新执行,即使它的操作类型是 SET 。因此,我们应该修改 trigger 函数的代码来弥补这个缺陷。

function track (target, key) { // 禁止追踪时,直接返回 if (!activeEffect || !shouldTrack) return; // 使用 target 在 bucket 中获取 depsMap,key -> effects let depsMap = bucket.get(target); // 如果不存在 depsMap,新建 map 与 target 关联 if (!depsMap) { bucket.set(target, (depsMap = new Map())); } // 使用 key 在 depsMap 中获取 deps,deps 是一个 set 类型 let deps = depsMap.get(key); // 如果 deps 不存在,新建 set 与 key 关联 if (!deps) { depsMap.set(key, (deps = new Set())); } // 将激活的副作用函数添加到 deps 中 deps.add(activeEffect); // 将依赖添加到 activeEffect.deps 数组中 activeEffect.deps.push(deps); } function trigger (target, key, type, newVal) { // 使用 target 从 bucket 中获取 depsMap,key -> effects const depsMap = bucket.get(target); if (!depsMap) return; // 根据 key 从 depsMap 中获取 effects const effects = depsMap.get(key); const effectsToRun = new Set(); // 将与 key 相关联的副作用函数添加到 effctesToRun effects && effects.forEach(effectFn => { // 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行 if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }) // 操作类型为 ADD 或 DELETE 时,需要触发与 ITERATE_KEY 相关联的副作用函数执行 // 如果操作类型是 Set,并且目标对象是 Map 类型的数据,也应该触发那些与 ITERATE_KEY 相关联的函数执行 if ( type === TRIGGER_TYPE.ADD || type === TRIGGER_TYPE.DELETE || (type === TRIGGER_TYPE.SET || isPlainMap(target)) ) { // 获取与 ITERATE_KEY 相关联的副作用函数 const iterateEffects = depsMap.get(ITERATE_KEY); // 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun iterateEffects && iterateEffects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); } // ... // effects && effects.forEach(fn => fn()); 避免与 cleanup 产生死循环 effectsToRun.forEach(effectFn => { // 如果存在调度器,则调用该调度器,并将副作用函数作为参数传递 if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { effectFn(); } }); }

我们增加了一个判断条件:如果操作的目标对象是 Map 类型的,则 SET 类型的操作也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。

迭代器方法

接下来,我们讨论关于集合类型的迭代器方法。集合类型有三个迭代器方法:

  • entries
  • keys
  • value

调用这些方法会得到相应的迭代器,并且可以使用 for...of 进行循环迭代。

const m = new Map([ ['key1', 'value1'], ['key2', 'value2'] ]); for (const [key, value] of m.entries()) { console.log(key, value); } // key1 value1 // key2 value2

我们也可以调用迭代器函数取得迭代器对象后,手动调用迭代器对象的 next 方法获取对应的值:

const itr = m[Symbol.iterator](); console.log(itr.next()); // { value: [ 'key1', 'value1' ], done: false } console.log(itr.next()); // { value: [ 'key2', 'value2' ], done: false } console.log(itr.next()); // { value: undefined, done: true }

m[Symbol.iterator]()m.entries 是等价的:

console.log(m[Symbol.iterator] === m.entries); // true

理解了这些内容后,我们就可以尝试实现对迭代器方法的代理。在此之前,不妨多做些尝试,看看会发生什么。

const p = reactive(new Map([ ['key1', 'value1'], ['key2', 'value2'] ])); effect(() => { // TypeError: p is not iterable for (const [key, value] of p) { console.log(key, value); } }); p.set('key3', 'value3');

在这段代码中,我们首先创建一个代理对象 p,接着尝试使用 for...of 循环遍历它,会得到一个错误:“p 是不可迭代的”。一个对象能否迭代,取决于该对象是否实现了迭代协议,如果一个对象正确地实现了 Symbol.iterator 方法,那么它就是可迭代的。很显然,代理对象 p 没有实现 Symbol.iterator 方法,因此我们得到了上面的错误。

实际上,当我们使用 for...of 循环迭代一个代理对象时,内部会试图从代理对象 p 上读取 p[Symbol.iterator] 属性,这个操作会触发 get 拦截函数,所以我们仍然可以把 Symbol.iterator 方法的实现放到 mutableInstrumentations 中。

const mutableInstrumentations = { // ... [Symbol.iterator] () { // 获取原始数据对象 target const target = this.raw; // 获取原始迭代器方法 const itr = target[Symbol.iterator](); // 将其返回 return itr; } };

实现很简单,不过是把原始的迭代器对象返回,这样就能够使用 for...of 循环迭代代理对象 p。但是事情不可能这么简单,之前我们在讲解 forEach 方法时提到过,传递给 callback 的参数时包装后的响应式数据。

p.forEach((value, key) => { // value 和 key 如果可以被代理,那么它们就是代理对象,即响应式数据 });

同时,使用 for...of 循环迭代集合时,如果迭代产生的值也是可以被代理的,那么也应该将其包装成响应式数据。

for (const [key, value] of p) { // 期望 key 和 value 是响应式数据 }

因此,我们需要修改代码:

const mutableInstrumentations = { // ... [Symbol.iterator] () { // 获取原始数据对象 target const target = this.raw; // 获取原始迭代器方法 const itr = target[Symbol.iterator](); const wrap = (val) => isPlainObject(val) ? reactive(val) : val; // 返回自定义迭代器 return { next () { // 调用原始迭代器的 next 方法获取 value 和 done const { value, done } = itr.next(); return { // 如果 value 不是 undefined,对其进行包裹 value: value ? [wrap(value[0]), wrap(value[1])] : value, done } } }; } };

为了实现对 key 和 value 的包装,我们需要自定义实现的迭代器,在其中调用原始迭代器获取值 value 以及代表是否结束的 done。如果值 value 不为 undefined,则对其进行包装,最后返回包装后的代理对象,这样当使用 for...of 循环迭代时,得到的值就会是响应式数据了。

最后,为了追踪 for...of 对数据的迭代操作,我们还需要调用 track 函数,让副作用与 ITERATE_KEY 建立联系。

const isPlainObject = (data) => typeof data === 'object' && data !== null; const mutableInstrumentations = { // ... [Symbol.iterator] () { // 获取原始数据对象 target const target = this.raw; // 获取原始迭代器方法 const itr = target[Symbol.iterator](); const wrap = (val) => isPlainObject(val) ? reactive(val) : val; // 调用 track 函数建立响应联系 track(target, ITERATE_KEY); // 返回自定义迭代器 return { next () { // 调用原始迭代器的 next 方法获取 value 和 done const { value, done } = itr.next(); return { // 如果 value 不是 undefined,对其进行包裹 value: value ? [wrap(value[0]), wrap(value[1])] : value, done } } }; } };

由于迭代操作与集合中中元素的数量有关,所以只要集合的 size 发生变化,就应该触发迭代操作重新执行。因此,我们在调用 track 函数时让 ITERATE_KEY 与副作用函数建立联系。完成这一步后,集合的响应式数据功能就相对完整了。

const p = reactive(new Map([ ['key1', 'value1'], ['key2', 'value2'] ])); effect(() => { // TypeError: p is not iterable for (const [key, value] of p) { console.log(key, value); } }); p.set('key3', 'value3'); // 能够触发响应

由于 p.entriesp[Symbol.iterator] 等价,所以我们可以使用同样的代码来实现对 p.entries 函数的拦截。

const mutableInstrumentations = { // ... [Symbol.iterator]: iterationMethod, entries: iterationMethod }; // 抽离为独立的函数,便于复用 function iterationMethod () { // 获取原始数据对象 target const target = this.raw; // 获取原始迭代器方法 const itr = target[Symbol.iterator](); const wrap = (val) => isPlainObject(val) ? reactive(val) : val; // 调用 track 函数建立响应联系 track(target, ITERATE_KEY); // 返回自定义迭代器 return { next () { // 调用原始迭代器的 next 方法获取 value 和 done const { value, done } = itr.next(); return { // 如果 value 不是 undefined,对其进行包裹 value: value ? [wrap(value[0]), wrap(value[1])] : value, done } } }; }

但当你尝试运行代码使用 for...of 进行迭代时,会得到一个错误。

// TypeError: p.entries is not a function or its return value is not iterable for (const [key, value] of p.entries()) { console.log(key, value); }

错误的大意是 p.entries 的返回值不是一个可迭代对象。很显然,p.entries 函数的返回值是一个对象,该对象带有 next 方法,但不具有 Symbol.iterator 方法,因此它确实不是一个可迭代对象。这也是经常出错的地方,可迭代协议与迭代器协议并不一致。可迭代协议指的是一个对象实现了 Symbol.iterator 方法,而迭代器协议指的是一个对象实现了 next 方法,单一个对象可以同时实现可迭代协议和迭代器协议。

const obj = { // 迭代器协议 next () {}, // 可迭代协议 [Symbol.iterator] () { return this; } }

所以我们可以这样修改代码。

// 抽离为独立的函数,便于复用 function iterationMethod () { // 获取原始数据对象 target const target = this.raw; // 获取原始迭代器方法 const itr = target[Symbol.iterator](); const wrap = (val) => isPlainObject(val) ? reactive(val) : val; // 调用 track 函数建立响应联系 track(target, ITERATE_KEY); // 返回自定义迭代器 return { next () { // 调用原始迭代器的 next 方法获取 value 和 done const { value, done } = itr.next(); return { // 如果 value 不是 undefined,对其进行包裹 value: value ? [wrap(value[0]), wrap(value[1])] : value, done } }, [Symbol.iterator] () { return this; } }; }

现在一切就可以正常工作了。

values 和 keys 方法

values 方法的实现与 entries 方法类似,不同的是,当使用 for...of 迭代 values 时,得到的仅仅是 Map 数据的值,而非键值对。

const p = reactive(new Map([ ['key1', 'value1'], ['key2', 'value2'] ])); for (const value of p.values()) { console.log(value); }

values 方法的实现如下:

const mutableInstrumentations = { // ... [Symbol.iterator]: iterationMethod, entries: iterationMethod, values: valuesIterationMethod }; function valuesIterationMethod () { // 获取原始数据对象 target const target = this.raw; // 通过 target.values 获取原始迭代器方法 const itr = target.values(); const wrap = (val) => isPlainObject(val) ? reactive(val) : val; // 调用 track 函数建立响应联系 track(target, ITERATE_KEY); // 返回自定义迭代器 return { next () { // 调用原始迭代器的 next 方法获取 value 和 done const { value, done } = itr.next(); return { // value 是值,而非键值对,所以只需要包裹 value 即可 value: wrap(value), done } }, [Symbol.iterator] () { return this; } }; }

其中,valuesIterationMethoditerationMethod 这两个方法有两点区别:

  • iterationMethod 通过 target[Symbol.iterator] 获取迭代器对象,而 valuesIterationMethod 通过 target.values 获取迭代器对象;
  • iterationMethod 处理的是键值对,即 [wrap(value[0]), wrap(value[1])], 而 valuesIterationMethod 只处理值,即 wrap(value)

由于它们的大部分逻辑相同,所以我们还可以将它们封装到一个可复用的函数中。

keys 方法与 values 方法非常类似,不同点在于,前者处理的是键而非值。因此,我们需要修改 valuesIterationMethod 方法中的一行代码,即可实现对 keys 方法的代理。

const itr = target.values(); // => const itr = target.keys();
const p = reactive(new Map([ ['key1', 'value1'], ['key2', 'value2'] ])); for (const value of p.keys()) { console.log(value); }

这么做确实可以得到目的,但如果运行如下代码用例,就会发现存在缺陷。

const p = reactive(new Map([ ['key1', 'value1'], ['key2', 'value2'] ])); effect(() => { for (const value of p.keys()) { console.log(value); } }); p.set('key2', 'value3');

在这段代码中,我们使用 for...of 循环来遍历 p.keys,然后调用 p.set('key2', 'value3') 修改键为 key2 的值。在这个过程中,Map 类型数据的所有键都没有发生变化,仍然是 key1key2,所以在理想情况下,副作用函数不应该执行。但是如果你运行上例,会发现副作用函数仍然重新执行。

这时因为,我们对 Map 类型的数据进行了特殊处理。即使操作类型为 SET ,也会触发那些与 ITERATE_KEY 相关联的副作用函数执行。

function trigger (target, key, type, newVal) { // 使用 target 从 bucket 中获取 depsMap,key -> effects const depsMap = bucket.get(target); if (!depsMap) return; // 根据 key 从 depsMap 中获取 effects const effects = depsMap.get(key); const effectsToRun = new Set(); // ... // 操作类型为 ADD 或 DELETE 时,需要触发与 ITERATE_KEY 相关联的副作用函数执行 // 如果操作类型是 Set,并且目标对象是 Map 类型的数据,也应该触发那些与 ITERATE_KEY 相关联的函数执行 if ( type === TRIGGER_TYPE.ADD || type === TRIGGER_TYPE.DELETE || (type === TRIGGER_TYPE.SET || isPlainMap(target)) ) { // 获取与 ITERATE_KEY 相关联的副作用函数 const iterateEffects = depsMap.get(ITERATE_KEY); // 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun iterateEffects && iterateEffects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); } // ... }

这对于 values 或 entries 等方法来说是必需的,但对于 keys 方法来说则没有必要,因为 keys 方法只关心 Map 类型数据的键的变化,不需要关心值的变化。

解决方法很简单,代码如下:

const MAP_KEY_ITERATE_KEY = Symbol(); function keysIterationMethod () { // 获取原始数据对象 target const target = this.raw; // 通过 target.keys 获取原始迭代器方法 const itr = target.keys(); const wrap = (val) => isPlainObject(val) ? reactive(val) : val; // 调用 track 函数建立响应联系,在副作用函数与 MAP_KEY_ITERATE_KEY 之间建立响应联系 track(target, MAP_KEY_ITERATE_KEY); // 返回自定义迭代器 return { next () { // 调用原始迭代器的 next 方法获取 value 和 done const { value, done } = itr.next(); return { // value 是值,而非键值对,所以只需要包裹 value 即可 value: wrap(value), done } }, [Symbol.iterator] () { return this; } }; }

当调用 track 函数追踪依赖时,我们使用 MAP_KEY_ITERATE_KEY 代替 ITERATE_KEY。其中 MAP_KEY_ITERATE_KEYITERATE_KEY 类似,是一个新的 Symbol 类型,用来作为抽象的键。这样就实现了依赖收集的分析,即 values 和 entries 等方法依然依赖于 ITERATE_KEY,而 keys 方法依赖 MAP_KEY_ITERATE_KEY 。当 set 类型的操作只会触发与 ITERATE_KEY 相关联的副作用函数重新执行时,不会触发 MAP_KEY_ITERATE_KEY 相关联的副作用函数。但是当 ADD 和 DELETE 类型的操作发生时,除了触发与 ITERATE_KEY 相关联的副作用函数执行,还需要触发与 MAP_KEY_ITERATE_KEY 相关联的副作用函数重新执行,因此我们需要修改 trigger 函数的代码。

function trigger (target, key, type, newVal) { // 使用 target 从 bucket 中获取 depsMap,key -> effects const depsMap = bucket.get(target); if (!depsMap) return; // 根据 key 从 depsMap 中获取 effects const effects = depsMap.get(key); const effectsToRun = new Set(); // ... // 操作类型为 ADD 或 DELETE 时,需要触发与 ITERATE_KEY 相关联的副作用函数执行 // 如果操作类型是 Set,并且目标对象是 Map 类型的数据,也应该触发那些与 ITERATE_KEY 相关联的函数执行 if ( type === TRIGGER_TYPE.ADD || type === TRIGGER_TYPE.DELETE || (type === TRIGGER_TYPE.SET || isPlainMap(target)) ) { // 获取与 ITERATE_KEY 相关联的副作用函数 const iterateEffects = depsMap.get(ITERATE_KEY); // 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun iterateEffects && iterateEffects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); } // 操作类型为 ADD 或 DELETE 时,需要触发与 MAP_KEY_ITERATE_KEY 相关联的副作用函数执行 if ( (type === TRIGGER_TYPE.ADD || type === TRIGGER_TYPE.DELETE) && isPlainMap(target) ) { // 获取与 ITERATE_KEY 相关联的副作用函数 const iterateEffects = depsMap.get(MAP_KEY_ITERATE_KEY); // 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun iterateEffects && iterateEffects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); } // ... }

这样就可以避免不必要的更新了。

const p = reactive(new Map([ ['key1', 'value1'], ['key2', 'value2'] ])); effect(() => { for (const value of p.keys()) { console.log(value); } }); p.set('key2', 'value3'); // 不会触发响应 p.set('key3', 'value3'); // 能够触发响应

总结

本章中,我们首先介绍了 Proxy 与 Reflect。Vue.js 3 的响应式数据是基于 Proxy 实现的,Proxy 可以为其他对象创建一个代理对象。所谓代理,指的是对一个对象的基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。在实现代理的过程中,我们遇到了访问器属性的 this 指向问题,这需要使用 Refelct.* 方法并指定正确的 receiver 来解决。

我们详细讨论了 JavaScript 中对象的概念,以及 Proxy 的工作原理。在 ECMAScript 规范中,JavaScript 有两种对象,其中一种叫做常规对象,另一种叫做异质对象。一个对象是函数还是其他对象,是由部署在该对象上的内部方法和内部槽决定的。

我们讨论了关于对象 Object 的代理。代理对象的本质,就是查阅规范并找到可拦截的基本操作的方法。有一些操作并不是基本操作,而是复合操作,这需要我们查询规范了解它们都依赖哪些基本操作,从而通过基本操作的拦截方法间接地处理复合操作。我们还详细分析了添加、 修改、删除属性对 for...in 操作的影响,其中添加和删除属性都会影响 for...in 循环的执行次数,所以当这些操作发生时,需要触发与对 ITERATE_KEY 相关联的副作用函数重新执行。而修改属性值则不影响 for...in 循环的执行次数,因此无需处理。我们还讨论了如何合理地触发副作用函数重新执行,包括对 NaN 的处理,以及访问原型链上的属性导致的副作用函数重新执行两次的问题。对于 NaN ,我们主要注意的时 NaN === NaN 用于等于 false。对于原型链属性问题,需要我们查阅规范定位问题的原因。由此可见,想要基于 Proxy 实现一个相对完善的响应系统,免不了去了解 ECMAScript 规范。

我们讨论了深响应和浅响应,以及深只读和浅只读。这里的深和浅指的是对象的层级,浅响应代表仅代理一个对象的第一层属性,即只有对象的第一层属性值是响应的。深响应则恰恰相反,为了实现深响应,我们需要在返回属性值之前,对值做一层包装,将其包装为响应式数据后再返回。

我们讨论了关于数组的代理。数组是一个异质对象,因为数组对象部署的内部方法 [[DefineOwnProperty]] 不同于常规对象。通过索引为数组设置新的元素,可能会隐式地改变数组 length 属性的值。对应地,修改数组 length 数组的值,也可能会间接数组中的已有元素。所以在触发响应的时候需要额外注意。我们还讨论了如何拦截 for...infor...of 对数组的遍历操作。使用 for...of 循环遍历数组与遍历普通对象区别不大,唯一需要注意的是,当追踪 for...in 操作时,应该使用数组的 length 作为追踪的 key。for...of 基于迭代协议工作,数组内建了 Symbol.iterator 方法。数组迭代器执行时,会读取数组的 length 属性或数组的索引。因此,我们不需要做额外的处理,就能够实现对 for...of 迭代的响应式支持。

我们讨论了数组的查找方法,如 includesindexOf 以及 lastIndexof 等。对于数组元素的查找,需要注意的一点是,用户既可能使用代理对象进行查找,也可能使用原始对象进行查找。为了支持这两种形式,我们需要重写数组的查找方法。原理很简单,当用户使用这些方法查找元素时,我们可以先去代理对象中查找,如果找不到,再去原始数组中查找。

我们介绍了会隐式修改数组长度的原型方法,即 pushpopshiftunshift 以及 splice 等方法。调用这些方法会间接地读取和设置数组的 length 属性,因此,在不同的副作用函数内对同一个数组执行上述方法,会导致多个副作用函数之间循环调用,最终导致调用栈溢出。为了解决这个问题,我们使用一个标记标量 shouldTrack 来代表是否允许进行追踪,然后重写了上述这些方法,目的是,当这些方法间接读取 length 属性值时,我们会先将 shouldTrack 的值设置为 false,即禁止追踪。这样就可以断开 length 属性与副作用函数之间的响应联系,从而避免循环调用导致的栈溢出。

最后,我们讨论了关于集合类型数据的响应式方案。集合类型指 SetMapWeakSetWeakMap。我们讨论了使用 Proxy 为集合类型创建代理对象的一些注意事项。集合类型不同于普通对象,它有特定的数据操作方法。当使用 Proxy 代理集合类型的数据时要格外注意。例如,集合类型的 size 属性是一个访问器属性,当通过代理对象访问 size 属性时,由于代理对象本身并没有部署 [[SetData]] 这样的内部槽,所以会发生错误。另外,通过代理对象执行集合类型的操作方法时,要注意这些方法执行时的 this 指向,我们需要在 get 拦截函数内通过 .bind 函数为这些方法绑定正确的 this 值。我们还讨论了集合类型响应式数据的实现。需要通过 “重写” 集合方法的方式来实现自定义的能力,当 Set 集合 add 方法执行时,需要调用 trigger 函数触发响应。我们还讨论了关于 “数据污染” 的问题。数据污染指的是不小心将响应式数据添加到原始数组中,它导致用户可以通过原始数据执行响应式相关操作,这不是我们所期望的。为了避免这类问题发生,我们通过响应数据对象的 raw 属性来访问对应的原始数据对象,后续操作使用原始数据对象就可以了。我们还讨论了关于集合类型的遍历,即 forEach 方法。集合中的 forEach 方法与对象的 for...in 遍历类似,最大的不同体现在,当使用 for...in 遍历对象时,我们只关心对象的键是否变化,而不关心值;但当使用 forEach 遍历集合时,我们即关心键的变化,也关心值的的变化。