原文链接:http://javascript.info/map-set-weakmap-weakset,translate with ❤️ by zhangbao.

到现在为止,我们已经学习下列复杂的数据结构:

  • 用来存储键名集合的对象。

  • 用来存储有序集合的数组。

但在现实使用中还不够。这就是为什么 Map 和 Set 存在的原因。

Map

Map 用于存储键名数据项集合,类似于 Object。不同的是,Map 的键名允许使用任意类型。

主要方法如下:

  • new Map():创建一个 map 实例。

  • map.set(key, value):存储键值。

  • map.get(key):返回指定键名的键值,如果 key 不存在,就返回 undefiend。

  • map.has(key):如果 key 存在,就返回 true;否则返回 false。

  • map.delete(key):删除指定的键。

  • map.clear():清除 map。

  • map.size:返回 map 中存储的元素数量。

例如:

  1. let map = new Map();
  2. map.set('1', 'str1'); // 字符串键名
  3. map.set(1, 'num1'); // 数值键名
  4. map.set(true, 'bool1'); // 布尔键名
  5. // 还记得普通对象吗?它会将所有的 key 都转换成字符串的
  6. // 而 Map 会保持键名的原始类型们,所有下面两个是取不同键名所对应的值的
  7. alert( map.get(1) ); // 'num1'
  8. alert( map.get('1') ); // 'str1'
  9. alert( map.size ); // 3

我们看到,不像对象,这里的键名不会转换为字符串。任何类型的键名都是可能的。

Map 也可以使用对象作为键名。

例如:

  1. let john = { name: "John" };
  2. // 这个用来存储每个用户的访问数量
  3. let visitsCountMap = new Map();
  4. // john 是作为 map 的 key
  5. visitsCountMap.set(john, 123);
  6. alert( visitsCountMap.get(john) ); // 123

可以使用对象作为键名是 Map 最为醒目和重要的特性。Object 能存储字符串 key,OK 没问题,但是它没法存储对象类型的键名。

在远古时代,Map 出现之前。大家以下面的方式添加唯一标识:

  1. // 我们会添加一个 id 字段
  2. let john = { name: "John", id: 1 };
  3. let visitsCounts = {};
  4. // 用用户 id 来标识值
  5. visitsCounts[john.id] = 123;
  6. alert( visitsCounts[john.id] ); // 123

……但使用 Map 的方式更加优雅。

⚠️Map 是怎样比较键值的

在比较两个值是否相等时,Map 使用的算法是 SameValueZero。它几乎等同于严格相等运算符 ===,但不同点是 NaN 会被判定为等于 NaN 本身。因此 NaN 也可以作为键名使用。

⚠️链式调用

map.set 方法 map 对象本身,因此可以使用链式调用:

  1. map.set('1', 'str1')
  2. .set(1, 'num1')
  3. .set(true, 'bool1');

用一个对象创建 Map

使用 Map 构造函数创建实例时,可以为构造函数传递一个数组,数组元素是以键-值对形式表达的数组。像这样:

  1. // [key, value] 对形式的数组
  2. let map = new Map([
  3. ['1', 'str1'],
  4. [1, 'num1'],
  5. [true, 'bool1']
  6. ]);

内置方法 Object.entries(obj) 方法以这种键-值对数组的形式精确返回一个对象的表示形式:

因此,我们用一个对象来初始化一个 Map 实例:

  1. let map = new Map(Object.entries({
  2. name: "John",
  3. age: 30
  4. }));

在这里,Object.entries 会对象这样形式的键值对表示:[ [‘name’, ‘John’], [‘age’, 30] ]。这正是 Map 需要的。

遍历 Map

遍历 Map,一共有 3 中方式:

  • map.keys():返回键名集合迭代对象,

  • map.values():返回键值集合迭代对象,

  • map.entries():返回由 [key, value] 元素形式组成的迭代对象。是 Map 默认使用的遍历器生成函数。

例如:

  1. let recipeMap = new Map([
  2. ['cucumber', 500],
  3. ['tomatoes', 350],
  4. ['onion', 50]
  5. ]);
  6. // 遍历键名
  7. for (let vegetable of recipeMap.keys()) {
  8. alert(vegetable); // cucumber, tomatoes, onion
  9. }
  10. // 遍历键值
  11. for (let amount of recipeMap.values()) {
  12. alert(amount); // 500, 350, 50
  13. }
  14. // 以 [key, value] 形式遍历
  15. for (let entry of recipeMap) { // 等同于 recipeMap.entries()
  16. alert(entry); // cucumber,500 (and so on)
  17. }

⚠️会使用插入顺序

遍历顺序会按照插入顺序列出,Map 保留了这个顺序,而不像普通 Object。

除此之外,Map 还提供了内置的 forEach 方法,类似于 Array:

  1. recipeMap.forEach( (value, key, map) => {
  2. alert(`${key}: ${value}`); // cucumber: 500 等等
  3. });

Set

Set 是一系列值的集合,而且每个值只能出现一次。

主要方法如下:

  • new Set(iterable):创建 Set 实例,可以从一个数组里创建(任何可迭代对象都 OK)。

  • set.add(value):添加一个值,返回 Set 实例本身。

  • set.has(value):如果值在 Set 里存在,就返回 true,否则返回 false。

  • set.delete(value):删除一个值,删除成功返回 true(值存在),否则返回 false。

  • set.clear():清空 Set 实例中的值。

  • set.sizse:返回 Set 实例中的元素数量。

Set 集合里比较两个值是否相等的算法使用的也是 SameValueZero

  1. let set = new Set();
  2. let john = { name: "John" };
  3. let pete = { name: "Pete" };
  4. let mary = { name: "Mary" };
  5. // visits, some users come multiple times
  6. set.add(john);
  7. set.add(pete);
  8. set.add(mary);
  9. set.add(john);
  10. set.add(mary);
  11. // set keeps only unique values
  12. alert( set.size ); // 3
  13. for (let user of set) {
  14. alert(user.name); // John (then Pete and Mary)
  15. }

设置的替代方案可以是一个存储用户列表的数组,加上使用 arr.find 方法在每次插入时检查是否存在。但是性能会更糟糕,因为这个方法遍历整个数组,检查每个元素。Set 在内部针对唯一性检查做了优化。

遍历 Set

我们可以循环遍历 Set 使用 for..of 或者 forEach:

  1. let set = new Set(["oranges", "apples", "bananas"]);
  2. for (let value of set) alert(value);
  3. // 等同于 forEach:
  4. set.forEach((value, valueAgain, set) => {
  5. alert(value);
  6. });

注意有一件有趣的事情,就是 Set 实例的 forEach 方法的回调函数接受 3 个参数:值,值(对,没错)和目标对象。实际上,相同的值出现在两次参数中。

为了达到与 Map 数据结构的兼容,forEach 提供了 3 个参数:

Map 支持的迭代器方法在这里也受到支持:

  • set.values():跟 set.keys() 返回结果一样。

  • set.keys():返回由值组成的迭代器,为了兼容 Map。

  • set.entries():返回由 [value, value] 实体组成的可迭代对象,存在是为了兼容 Map。

WeakMap 和 WeakSet

WeakSet 是一类特殊的 Set,不会阻止 JavaScript 将其从内存中删除。WeakMap 和 Map 关系与此一样。

从《垃圾收集》一章里,我们看到,JavaScript引擎在内存中存储一个值,而它是可访问的(并且可能会被使用)。

例如:

  1. let john = { name: "John" };
  2. // 对象可以被访问,因为 John 这个引用还在
  3. // 重写引用
  4. john = null;
  5. // 对象就会从内存中删除了

通常,当数据结构(对象属性、数组元素或其他数据结构的元素)是可访问的时候,就会保存在内存中。

在普通 Map 结构中,键名可以使用任意类型。即使没有更多的引用,它也被保存在内存中。

例如:

  1. let john = { name: "John" };
  2. let map = new Map();
  3. map.set(john, "...");
  4. john = null; // 重写引用
  5. // john 被存储在 map 中,我们可以使用
  6. // map.keys() 取得它

WeakMap/WeakSet 就不这样。

WeakMap/WeakSet 不会阻止对象从内存中删除。

我们先从 WeakMap 讨论。

与 Map 第一点不同的就是键名只能是对象类型的,而不能是原始类型的:

  1. let weakMap = new WeakMap();
  2. let obj = {};
  3. weakMap.set(obj, "ok"); // works fine (object key)
  4. weakMap.set("test", "Whoops"); // Error, because "test" is a primitive

现在我们使用对象作为键名,并且之后引用也置为空。结果是,这个对象会从内存中删除。

  1. let john = { name: "John" };
  2. let weakMap = new WeakMap();
  3. weakMap.set(john, "...");
  4. john = null; // 重写引用
  5. // john 从内存中删除了

对比上面的 Map 例子,现在 john 只存在于 WeakMap 中——它会被自动删除的。

……当且,WeakMap 不支持 keys(),values() 和 entries() 方法,我们也不能遍历它。所以我们不能获取所有的 key 或者 value。

WeakMap 仅能使用以下的方法:

  • weakMap.get(key)

  • weakMap.set(key, value)

  • weakMap.has(key)

  • weakMap.delete(key)

为什么会有这样的限制呢?这是因为技术原因。如果对象丢失了所有其他引用(像上面的 john),会被自动删除。但是技术上并没有明确指明何时发生清理操作

由 JavaScript 引擎决定。它可能会选择立即执行内存清理,或者等待指导更多删除发生时,才进行清理。所以,从技术上讲,WeakMap 的当前元素计数是未知的。引擎可能已经把它清理干净了,或者部分地清理了。因为这个原因,WeakMap 没有提供访问方法。

现在,哪里会需要这样的数据结构呢?

使用 WeakMap 的地方:只存储存在的对象。如果对象不再被引用了,WeakMap 中的引用不会算数,对象会被垃圾收集器清除。

  1. weakMap.put(john, "secret documents");
  2. // john 不再被引用, secret documents 也被销毁了

这对于我们在某个地方有一个主存储空间的情况很有用,并且需要保留那些只在对象生活时才相关的附加信息。

我们看个例子。

例如,我们有为每个用户提供访问计数的代码。信息存储在 Map 中:用户对象作为键名,访问数是键值。当用户离开时,我们不想再存储他的访问次数了。

一种方法是跟踪用户的离开,并手动清理存储空间:

  1. let john = { name: "John" };
  2. // map: user => visits count
  3. let visitsCountMap = new Map();
  4. // john is the key for the map
  5. visitsCountMap.set(john, 123);
  6. // now john leaves us, we don't need him anymore
  7. john = null;
  8. // but it's still in the map, we need to clean it!
  9. alert( visitsCountMap.size ); // 1
  10. // it's also in the memory, because Map uses it as the key

另一种方法是使用 WeakMap:

  1. let john = { name: "John" };
  2. let visitsCountMap = new WeakMap();
  3. visitsCountMap.set(john, 123);
  4. // now john leaves us, we don't need him anymore
  5. john = null;
  6. // there are no references except WeakMap,
  7. // so the object is removed both from the memory and from visitsCountMap automatically

使用常规 Map,在用户离开后进行清理工作将成为一项单调乏味的任务:我们不仅需要将用户从主存储器中删除(无论是变量还是数组),而且还需要清理像 visitsCountMap 这样的额外存储。在更复杂的情况下,当用户在代码的一个地方进行管理时,它会变得很麻烦,而额外的结构在另一个地方,并且没有关于删除的信息。

WeakMap 可以使事情变得更简单,因为它是自动清理的。在上面的例子中,像访问计数这样的信息只有在关键对象存在的时候才会存在。

WeakSet 的行为于此类似:

  • 它类似于 Set,但是我们只能添加对象(不能是原始类型值)。

  • 在 Set 中的对象,也可以从其他地方访问到。

  • 类似 Set,仅支持 add,has,delete,但不支持 size,keys() 等迭代方法。

例如,我们可以使用它来跟踪检查一个对象是否被检查:

  1. let messages = [
  2. {text: "Hello", from: "John"},
  3. {text: "How goes?", from: "John"},
  4. {text: "See you soon", from: "Alice"}
  5. ];
  6. // fill it with array elements (3 items)
  7. let unreadSet = new WeakSet(messages);
  8. // we can use unreadSet to see whether a message is unread
  9. alert(unreadSet.has(messages[1])); // true
  10. // remove it from the set after reading
  11. unreadSet.delete(messages[1]); // true
  12. // and when we shift our messages history, the set is cleaned up automatically
  13. messages.shift();
  14. // no need to clean unreadSet, it now has 2 items
  15. // unfortunately, there's no method to get the exact count of items, so can't show it

WeakMap 和 WeakSet 最显著的限制是不提供迭代方法,以及无法获得所有当前内容。这可能看起来很不方便,但实际上并不能阻止 WeakMap/WeakSet 完成它们的主要工作——为在另一个地方存储/管理的对象提供一个“额外”的数据存储。

总结

  • Map:是键值对的集合。

不同于普通 Object 的地方是:

  • 键名可以是任意类型,就是说对象也 OK。

  • 迭代顺序就是插入顺序。

  • 额外的便捷方法和 size 属性。

  • Set:存储唯一值的集合。
  • 不像数组,不能够重新排序元素。

  • 保持插入顺序。

  • WeakMap:Map 类型的一个变体,只允许对象类型的键名。一旦包含对象不再引用,就会从集合中自动清除了。

  • 它不支持对整个结构的操作:没有 size,clear(),没有迭代方法。

  • WeakSet:Set 类型的一个变体,只允许对象类型的键名。一旦包含对象不再引用,就会从集合中自动清除了。
  • 同样不支持 size/clear() 和迭代方法。

WeakMap 和 WeakSet 可以被看做是除“主”对象存储之外的“第二”数据结构。一旦对象从主存储中删除了,那么 WeakMap/WeakSet 集合中也 hold 不住这个对象了,对象会自动从内存中删除。

(完)