对象和数组之外的两种集合
普通对象和数组能满足 JavaScript 程序的大部分需求,但它们并不是为所有场景设计的。Map 和 Set 是内置的集合类型,填补了两个特定的空白:键不是字符串的键值查找,以及去重后的成员检查。
它们从 ES2015 起就是语言的一部分。两者都是可迭代对象,都有 .size 属性,也都能很好地配合展开运算符使用。思维模型很简单:
Map:类似对象,但键可以是任何东西,并且保留顺序。Set:类似数组,但值是唯一的,查找也很快。
创建和使用 Map
Map 保存键值对。用 new Map() 创建它,并使用 .set()、.get()、.has() 和 .delete():
你也可以向构造函数传入一个由 [key, value] 对组成的数组来初始化它:
只要涉及 Map,这种包含两个元素的数组形式就随处可见:遍历时,条目就是以这种形式表示的。
Map 与对象:何必多此一举?
普通对象看起来也能做同样的事,大多数时候确实如此。但 Map 解决了几个特定的毛病:
对象继承自 Object.prototype,所以 toString、constructor 和 hasOwnProperty 这些键在每个对象上都已经存在。Map 没有这种包袱:你设置的键就是仅有的键。
其他值得了解的区别:
- 任何类型的键。 Map 接受对象、函数、数字、布尔值作为键。对象会悄悄地把非字符串键转换成字符串:
obj[1]和obj["1"]是同一个位置。 - 保证插入顺序。 Map 按条目添加的顺序遍历。对象大多也是如此,但看起来像数字的字符串键会被排到最前面,这是一个不易察觉的陷阱。
- 内置大小。
map.size是 O(1) 的。对于对象,你得写Object.keys(obj).length,这会重新构建一个数组。 - 为频繁变动而优化。 引擎针对频繁增删对 Map 做了优化,而对象是针对结构稳定的记录优化的。
当你要表示一条键是已知字符串的记录({ name, email, age })时,用对象。当键是动态的、不是字符串,或者你会频繁增删条目时,用 Map。
遍历 Map
Map 是可迭代对象,所以可以直接用 for...of,解构每个条目也很自然:
如果只需要键或只需要值,就调用 .keys() 或 .values()。如果你喜欢,也可以用 .forEach():
要把 Map 转回普通对象或数组,展开它即可:
创建和使用 Set
Set 保存唯一的值。添加一个已经存在的值不会有任何效果:
唯一性的判断规则与 === 相同,但有一个特殊之处:在 Set 中,NaN 被认为等于它自己,尽管在其他任何地方 NaN === NaN 都是 false。
向构造函数传入一个可迭代对象来初始化 Set,去重技巧正是由此而来:
一行代码,适用于任何原始类型。对于对象数组,这样做不起作用(两个字段相同的不同对象仍然是两个不同的值),但对于字符串、数字和布尔值,这是惯用的去重写法。
Set 与数组:什么时候该换
数组和 Set 都保存一组值,那么该选哪个?
以下情况用 Set:
- 值必须唯一,并且你希望由运行时来保证这一点。
- 你要做大量成员检查。
set.has(x)是 O(1),array.includes(x)是 O(n)。在循环中,这个差距会迅速放大。 - 你只需要插入顺序。 Set 按插入顺序遍历,但不支持索引访问。
以下情况继续用数组:
- 你需要按位置访问:
arr[0]、切片、排序。 - 重复项有意义:购物车里有两件同样的商品。
- 你会大量使用数组方法,比如
.map、.filter、.reduce。Set 没有这些方法,你得先把它展开成数组。
一个关于性能的小例子:
如果 banned 是数组,每次 filter 回调都要扫描整个列表。换成 Set,每次查找都是常数时间。
遍历 Set
和 Map 一样:for...of 直接可用,展开它就能得到一个数组:
为了与 Map 保持一致,Set 也提供了 .keys()、.values() 和 .entries(),尽管对于 Set 来说,键和值是同一个东西。大多数时候你直接遍历它就行。
完整示例:统计每个页面的独立访客
把两者结合起来:一个 Map,把页面路径映射到一个由访客 ID 组成的 Set:
Map 负责从路径到分组的映射,Set 负责每个分组内部的去重。用普通对象和数组也能做到同样的事,但你得一路写额外的 indexOf 检查和 hasOwnProperty 防护。
WeakMap 和 WeakSet 简介
还有两种相关的集合,用于一个很窄的场景:WeakMap 和 WeakSet。它们以弱引用的方式持有数据,也就是说,如果某个条目的键(对于 WeakMap)或值(对于 WeakSet)没有其他引用,它就会被自动垃圾回收。
它们只接受对象作为键,不可迭代,也没有 .size。这是有意为之的:如果可以遍历它们,垃圾回收的过程就会被观察到。它们适合用来缓存不属于你的对象的元数据,在日常代码中很少见。
下一步:JSON
Map 和 Set 在内存中很好用,但两者都无法经过 JSON.stringify 完好保留:Map 会变成 {},Set 也会变成 {}。下一页讲 JSON:如何序列化和解析数据,以及当本页介绍的这些集合需要跨越网络或文件边界时,该用什么模式处理。
常见问题
JavaScript 中 Map 和 Object 有什么区别?
Map 可以用任何值作为键:对象、函数、数字,什么都行;而对象会把键转换成字符串(或 symbol)。Map 还通过 .size 记录自己的大小,按插入顺序遍历,并且不会从原型继承键,所以不会和 toString 或 constructor 冲突。当键不是字符串,或者需要频繁增删条目时,就用 Map。
JavaScript 中 Set 有什么用?
Set 存储唯一的值,会悄悄忽略重复项。数组去重最快的方法是 [...new Set(arr)]。Set 的 .has() 检查是 O(1) 的,在循环中判断是否包含某个值时,比 array.includes() 更快。
如何遍历 Map?
直接用 for...of 就行:for (const [key, value] of myMap) 会解构每个条目。你也可以遍历 myMap.keys()、myMap.values() 或 myMap.entries()。遍历顺序保证与插入顺序一致,而对于看起来像数字的键,普通对象并不总能做出这样的保证。