写业务代码写久了,你会发现很多团队里还在用Object当普通键值表、用array.includes检查元素在不在。ES6 之后,Map和Set早就给出了更顺手、更可靠的方案,但不少人对它们的印象只停留在“可以用”的层面,真到选型时又说不出区别。这篇文章就从实际使用角度把Map和Set的核心知识完整过一遍,包括基本 API、内部比较机制、与Object/Array的横向对比,以及WeakMap/WeakSet的进阶玩法,适合正在夯实 JavaScript 基础的开发者,也适合准备面试前想系统梳理数据结构的同学。内容不追求面面俱到,但保证每个结论都能落到代码上。
1. 从需求出发:Map和Set到底解决了什么问题
很多人第一次接触Map和Set是在面试题或者刷题网站里,平时业务代码很少主动用。这其实是好事说明你常用写法已经能对付日常需求,但如果你想优化代码的语义和性能,或者处理一些稍微复杂的数据状态,Map和Set的价值立刻就会体现出来。
先说一个最典型的场景:状态映射。比如你维护一个用户列表,需要根据用户 id 快速找到对应的昵称、年龄、权限。多数人第一反应是写一个对象{ userId: userInfo },这没错,对象本来就是这种场景的原生解法。但当你开始遇到“键不一定是字符串”“需要保证插入顺序”“得频繁查看有多少个键值对”这些需求时,Object就开始别扭了。至于去重和存在性判断,array.includes在数据量小的时候毫无感觉,一旦列表上到几千几万条,嵌套循环带来的性能压力马上就能把你拖垮。
下面我从痛点出发,看看Map和Set到底是冲着哪些问题来的。
1.1 Object当字典用的三个痛点
第一个痛点是键的类型限制。Object的键只能是字符串或者Symbol,其他类型会被自动转成字符串。比如obj[1]和obj["1"]实际访问的是同一个键,这在动态拼键时很容易踩坑。有时候你从接口拿到的是数字 id,写代码时可能没注意类型,最后 debug 半天发现是字符串和数字打架。
第二个痛点是原型链。普通对象字面量{}继承自Object.prototype,所以当你用obj"toString"这类键时,可能取到的是原型上的方法,而不是你自己存的数据。虽然很多人说“我怎么会用 toString 当键”,但业务里用户输入的字段名可不一定听你指挥。更稳妥的做法是用Object.create(null)创建完全干净的对象,或者直接换Map,后者天然没有这个烦恼。
第三个痛点是顺序和长度的获取。Object的键顺序规则比较绕:整数键会按升序排在最前,字符串键按插入顺序,Symbol键最后处理。当你需要按照入参顺序遍历配置项时,Object很容易给出意料之外的结果。同时,想拿数量只能Object.keys(obj).length,每次都要创建一次数组,既啰嗦又有微小的性能开销。Map和Set直接用size属性就能拿到数量,遍历顺序也稳定等于插入顺序,这几个设计让日常开发舒服很多。
1.2 数组查重的低效困境
如果你用数组做“某个元素是否已经存在”的判断,第一反应是arr.includes(item)或者arr.indexOf(item) > -1。这两个方法本质都是线性遍历,复杂度是 O(n)。如果这段代码放在循环里,复杂度直接变成 O(n²),数据量一旦过千,浏览器 UI 线程就开始卡顿。
更麻烦的是,数组的语义里本身没有“唯一性”这个保证。你想实现去重,必须自己写循环、用indexOf过滤或者依赖第三方库。可是去重这个需求太常见了,应该由一个专门的数据结构来承担,这就是Set存在的原因。Map也类似,它的价值不是“让你能存东西”,而是“让你在频繁读写和判断存在性时不需要手动做类型转换和遍历”。
1.3 Map/Set的设计目标
简单来说,Map和Set是 JavaScript 语言给自己的数据结构拼图上补上的两块关键板子。Map解决“键值集合”问题,键可以是任意类型,读写、删除、判断存在都接近 O(1) 复杂度,遍历顺序保持插入序,还有内置的size属性。Set解决“唯一值集合”问题,成员天然不重复,适合去重、存在性判断、集合运算。
理解它们的目标之后,你就不会再把Map当作一个“升级版 Object”来看待,而是把它看作一个独立的、面向动态键值场景的数据容器。Set也不是“只能去重”的小工具,它其实是一套集合运算的基础设施,和数学里的交集并集概念一一对应。带着这种视角去用,代码结构会清晰很多。
2. Map核心知识速查:常用操作与原理拆解
Map是 JavaScript 里一个真正意义上的“字典结构”:key-value配对,key不限制类型,提供了一套完整的读写接口。下面我把最常用的操作拆开来讲,顺便解释一些细节设计背后的原因。
2.1 构造与存储:Map里到底有什么
创建Map有三种常用方式。第一种是空构造:
const map = new Map();第二种是传入一个二维数组,数组的每个元素都是[key, value]:
const map = new Map([ ["name", "张三"], ["age", 30], ]);第三种是从另一个Map或者其他可迭代对象拷贝:
const map1 = new Map([["a", 1]]); const map2 = new Map(map1);需要注意,Map构造时会把所有键值对按插入顺序保存。你传进去的二维数组如果包含重复键,后面的值会覆盖前面的值,但插入的位置保留第一次出现的位置。
Map的键可以是任意类型,这是它和Object最本质的区别:
const map = new Map(); const keyObj = { id: 1 }; map.set(keyObj, "对象键"); map.set(function () {}, "函数键"); map.set(NaN, "NaN键");这里的键引用非常关键:Map存的是键的引用,只有同一个引用才能命中同一个键。两个内容完全一样的对象,在Map看来是两个不同的键,这也是面试里经常出现的考点。
2.2 set/get/has/delete/clear与size
Map的 API 不多,但每个都有细节。
set(key, value)返回Map实例本身,所以可以链式调用:
const map = new Map(); map.set("a", 1).set("b", 2).set("c", 3);get(key)返回对应值,没有则返回undefined。这里有个隐蔽点:如果键存在但值本身就是undefined,get也会返回undefined,所以判断键是否存在要优先用has:
map.set("x", undefined); console.log(map.get("x")); // undefined,但键其实存在 console.log(map.has("x")); // truedelete(key)返回布尔值,表示是否真的删掉了键。clear()清空所有键值对。size是一个属性,不是方法,直接读取即可,这点和数组的length一样自然。
2.3 遍历顺序与迭代能力,这是Map的隐藏优点
Map最大的隐藏优点之一是遍历顺序:它就是插入顺序。这一点对于配置管理、状态回放这类场景特别重要。你可以用for...of直接遍历,拿到的是[key, value]数组:
const map = new Map([ ["a", 1], ["b", 2], ]); for (const [key, value] of map) { console.log(key, value); }Map还提供了三个迭代器方法:keys()、values()、entries()。entries()是默认迭代器,所以上面直接遍历map等价于遍历map.entries()。forEach的回调签名是(value, key, map),注意顺序是值在前、键在后,这点和数组的forEach习惯相反,很容易写反。
map.forEach((value, key) => { console.log(`${key}: ${value}`); });2.4 几个容易翻车的细节
第一个坑是NaN。在Map中,NaN可以被当作键,而且多次set(NaN, ...)命中同一个键。原因是Map内部使用SameValueZero算法比较键,它认为NaN等于自身,但NaN === NaN却是false。如果你用对象模拟字典,就很难处理好这种边界。
第二个坑是引用类型键。Map比较两个对象时,比的是引用地址,不是深度比较:
const map = new Map(); map.set({ id: 1 }, "hello"); console.log(map.get({ id: 1 })); // undefined这里两次{ id: 1 }是新创建的两个对象,引用不同,所以取不到。需要取同一个引用才能命中。
第三个坑是Map转Object时可能丢数据。如果键是字符串:
const map = new Map([["name", "张三"]]); const obj = Object.fromEntries(map); // { name: "张三" }但如果键是对象或者数字,Object.fromEntries会把键转成字符串,语义就变了。反过来,Object转Map倒是很顺畅:new Map(Object.entries(obj))。
3. Set核心知识速查:唯一性保证与集合思维
如果说Map是字典,那Set就是数学意义上的集合:元素不重复、没有索引、不提供“取第几个”的接口。它的核心价值在于唯一性保证和集合运算。
3.1 Set的构造与唯一性机制
创建Set可以直接传一个可迭代对象进去,数组、字符串、Map、另一个Set都行:
const set1 = new Set([1, 2, 3, 3, 2]); // {1, 2, 3} const set2 = new Set("hello"); // {'h', 'e', 'l', 'o'} const set3 = new Set(set1); // 拷贝一个新SetSet内部的唯一性判断同样使用SameValueZero算法。这意味着:
- 两个
NaN会合并成一个元素。 +0和-0会合并成一个元素。- 两个内容相同但引用不同的对象,会被视为两个不同元素。
用代码验证:
const set = new Set([NaN, NaN]); console.log(set.size); // 1 const objSet = new Set([{ id: 1 }, { id: 1 }]); console.log(objSet.size); // 23.2 add/has/delete/clear/size
add(value)返回Set本身,可以链式调用。has(value)是关键方法,它的复杂度接近 O(1),替代数组的includes时性能提升非常明显。delete(value)返回布尔值,表示是否删除了元素。clear()清空所有元素。size属性直接给出元素个数。
Set没有get方法,因为它的语义里没有“根据键取对应值”这一说,只有“在不在、有多少、怎么遍历”。如果你确实需要从Set中取某个元素,通常是用迭代器遍历或者先转成数组:
const set = new Set(["a", "b", "c"]); const arr = [...set]; const last = arr[arr.length - 1];3.3 数组去重:一行代码背后的边界情况
数组去重已经是Set最出名的应用之一,写法非常简单:
const arr = [1, 2, 3, 3, 2, 1]; const unique = [...new Set(arr)]; // [1, 2, 3]但这个写法有几个边界需要注意。第一,它去重时不会做类型转换,数字1和字符串"1"是不同元素,所以[...new Set([1, "1"])]结果是[1, "1"],不会合并。第二,NaN会被正确去重,[NaN, NaN]去重后只有一个NaN。第三,对象去重按引用判断,两个结构相同但地址不同的对象不会被合并。
如果你的业务期望“两个对象内容相同就算重复”,直接套Set是行不通的,得先用JSON.stringify之类的方法生成唯一标识,或者使用Map配合规范化 key。
3.4 集合运算:并集、交集、差集、对称差集的写法
Set的好处在于,你可以直接用原生 API 实现数学上的集合运算。我整理了一份可以直接抄的代码:
// 并集 function union(a, b) { return new Set([...a, ...b]); } // 交集 function intersect(a, b) { return new Set([...a].filter((item) => b.has(item))); } // 差集:在a中但不在b中 function difference(a, b) { return new Set([...a].filter((item) => !b.has(item))); } // 对称差集:a、b中各自独有的元素 function symmetricDifference(a, b) { return new Set([ ...[...a].filter((item) => !b.has(item)), ...[...b].filter((item) => !a.has(item)), ]); }这里的核心思想是利用has代替数组的includes。如果两个集合大小分别是 m 和 n,用includes做差集是 O(m×n),用has则在哈希表理想情况下能接近 O(m+n),数据量大时差距极其明显。
实际业务里,求两个用户列表的共同权限、比较两个标签集合的差异、合并配置项,都可以直接用这几个函数。
3.5 有序与迭代
Set的遍历顺序也是插入顺序,这一点很多人容易忽略。它同样支持for...of、forEach、keys()、values()、entries()。由于集合没有独立的键和值概念,keys()和values()返回的内容完全一样,entries()返回的每一项是[value, value]。这个设计是为了让Set和Map的接口保持对称,方便互换使用。
4. Map与Set、Object与Array的横向对比与选型指南
很多人的纠结点不是“某个 API 怎么用”,而是“什么时候该用谁”。这里我整理了一份对照思路,帮你在代码里快速做决定。
4.1 一张表说清核心差异
| 维度 | Map | Object | Set | Array |
|---|---|---|---|---|
| 键类型 | 任意类型 | 字符串或Symbol | 任意值(元素) | 任意值(元素) |
| 键顺序 | 插入顺序 | 整数键优先、规则复杂 | 插入顺序 | 索引顺序 |
| 大小获取 | size属性 | Object.keys().length | size属性 | length属性 |
| 存在性判断 | has(key) | 需自行处理原型链 | has(value) | includes(value) |
| 删除操作 | delete(key) | delete obj[key] | delete(value) | splice或者filter |
| 原型链影响 | 无 | 有 | 无 | 无 |
| JSON序列化 | 需要手动转换 | 原生支持 | 需要手动转换 | 原生支持 |
| 典型场景 | 动态键值映射、缓存 | 结构化数据、DTO | 去重、集合运算 | 有序列表、随机访问 |
这张表里最值得记住的一条是:Object适合结构固定、能直接用字面量表达的“记录”类型数据;Map适合键不确定、需要频繁增删的“映射”类型数据;Set适合只关心“是否存在、是否唯一”的“集合”类型数据。
4.2 什么时候该用Map而不是Object,反过来呢
我自己的选型经验是:只要出现以下任一信号,优先Map:
- 键的类型不是字符串或
Symbol。比如用对象、数组、函数作为键。 - 需要频繁添加、删除键值对,并关心
size。 - 需要保证遍历顺序等于插入顺序。
- 不希望键名意外命中原型链,比如存在用户自定义字段名。
反过来,Object在几个场景仍然不可替代:
- 需要直接放到
JSON.stringify里传输。 - 结构是固定的“记录”,比如员工信息
{ name, age, department }。 - 需要挂在对象上的方法,利用原型链组织行为。
举个现实中的例子,前端经常做“权限点映射”:
const permissionMap = new Map(); permissionMap.set("user:view", ["read", "profile"]); permissionMap.set("user:edit", ["read", "write", "profile"]); if (permissionMap.has("user:edit")) { // 动态判断权限点是否存在 }这里键是字符串,其实Object也能实现,但Map的size和delete语义更直观。更重要的是,如果某天权限点来自用户输入,不用再担心__proto__、constructor这类字段被原型链污染。
4.3 什么时候该用Set而不是数组
Set和Array的共同点是都能装一组数据,但语义差异极大。数组保留重复、支持索引、适合位置敏感的场景;Set自动去重、存在性判断高效、不支持索引。
如果一个列表里业务上不应该出现重复项,那直接使用Set可以从数据结构层面杜绝问题,而不是每次插入前手动查一遍。经典案例是消息列表中的已读记录:
const readSet = new Set(); readSet.add("message-1"); readSet.add("message-2"); function handleMessage(id) { if (readSet.has(id)) { return; // 已处理过,直接跳过 } readSet.add(id); // 处理消息 }用数组的话,每次都得写if (!arr.includes(id)) arr.push(id),又慢又啰嗦。换成Set后,既不用查重,语义也更清晰。
如果你最后需要把Set渲染到表格或者列表里,转数组也很方便:[...readSet]或者Array.from(readSet)。
4.4 性能实测与经验数字
我从实际测试里得到的数据不是绝对标准,但趋势很稳定。在本地跑 10 万条数据,Set.has比Array.includes快好几个数量级;Map.get和Object属性访问的差距没有那么大,但在频繁delete和新增的场景里,Map的优势非常明显,因为Object删除属性会触发引擎的隐藏类优化失效,容易出现性能抖动。
这里补充一个容易忽略的点:Object的delete操作在某些引擎里会导致对象从“快速模式”降级为“字典模式”,后续属性访问变慢。而Map.delete是原生设计,不会有类似副作用。如果你有一段代码会高频增删键值对,直接用Map比在Object上折腾要省心得多。
5. WeakMap与WeakSet:内存不泄漏的进阶玩法
WeakMap和WeakSet是Map/Set的“弱引用版本”,它们的存在主要为了解决内存泄漏问题。这一节是进阶内容,但实际工程里非常有用。
5.1 弱引用到底是什么意思
理解弱引用之前,先看一下普通Map的问题。如果你把一个对象当作Map的键,那么这个对象会一直被Map引用着。即使其他地方都不再使用这个对象了,只要Map还在,垃圾回收器就不会把它回收,导致内存占用一直在涨。
WeakMap对键的引用是弱引用:当对象在其他地方没有引用时,它就可以被垃圾回收,WeakMap里的键值对也会随之消失。用生活类比来说,普通Map像一个记名储物柜,你存进去的东西只要不取就一直占着位置;WeakMap像一个公共寄存处,如果箱子里外的东西都不被需要了,清洁工第二天就自动清走了。
WeakMap的键必须是对象,不能是基本类型。它没有size属性,也不能迭代,因为垃圾回收的时机不固定,任何时刻读取到的内容都可能变少,语言层面就不让你依赖这些信息。
5.2 WeakMap的典型场景:私有字段与缓存
一个经典场景是用WeakMap保存对象的私有数据。比如:
const privateData = new WeakMap(); class Person { constructor(name) { privateData.set(this, { name }); } getName() { return privateData.get(this).name; } } const p = new Person("张三"); console.log(p.getName()); // 张三这段代码的关键在于,外部无法直接拿到privateData,除非同一个作用域里有这个WeakMap。对象实例如果被释放,对应的私有数据也会自动清理,不会内存泄漏。
另一个典型场景是缓存。假设你有一个纯计算函数,根据传入对象计算结果:
const cache = new WeakMap(); function compute(obj) { if (cache.has(obj)) { return cache.get(obj); } const result = expensiveCalculation(obj); cache.set(obj, result); return result; }这里把缓存绑定在传入对象上。对象随后在业务里被丢弃时,缓存条目也会被垃圾回收,不会误导我们以为“缓存必须手动清理”。
5.3 WeakSet的用途与限制
WeakSet和Set的区别在于:它只接受对象,且弱引用,没有size、不能迭代。最常用的场景是标记对象是否已经被处理过,或者记录某个函数是否已经调用过:
const handled = new WeakSet(); function processNode(node) { if (handled.has(node)) { return; } handled.add(node); applyAction(node); }用WeakSet而不是Set的好处很明显:node一旦从 DOM 中移除或不再被业务代码引用,它就会从handled中自动消失,不用担心节点越来越多导致内存膨胀。
使用时要注意,WeakSet不存在“查看全部元素”的说法。如果你需要遍历、统计、排序,就别用弱集合,回到普通Set或数组。
6. 常见问题与日常踩坑实录
这一节把我真实写代码时踩过、见别人踩过的一些问题整理成速查形式,能帮你少走弯路。
6.1 为什么Map/Set的遍历顺序和Object不一样
Object的键顺序并不完全等于插入顺序。整数类型的键会优先按数值升序排列,之后才是字符串键按插入顺序,最后是Symbol键按插入顺序。也就是说,{ "b": 1, 2: 2, "a": 3 }遍历时会先输出2,再输出b和a,这在做配置展示时容易造成困惑。Map和Set完全按插入顺序遍历,如果你需要“看到的就是存进去的顺序”,直接用它们不会出错。
6.2 对象元素内容相同但引用了不同,Set去重失败
常见写法:
const arr = [{ id: 1 }, { id: 1 }]; const unique = [...new Set(arr)]; console.log(unique.length); // 2,去重失败因为Set比较对象时用的是引用地址,不是内容。如果你业务上就是“只要 id 相同就算重复”,可以先用 id 生成唯一键,或者改用Map:
const map = new Map(); for (const item of arr) { map.set(item.id, item); } const unique = [...map.values()];这个方案本质是“以 id 为键做覆盖”,能保证相同 id 只保留最后一个对象,比直接用Set更符合业务直觉。
6.3 JSON.stringify无法直接序列化Map和Set
直接对Map或Set执行JSON.stringify,得到的结果是{},因为序列化机制不认识这两个数据结构。要传输到后台,通常有两种做法。一种是在序列化前转成数组:
const map = new Map([["a", 1]]); const mapJson = JSON.stringify([...map.entries()]); // [["a",1]] const set = new Set([1, 2]); const setJson = JSON.stringify([...set]); // [1,2]另一种是给Map和Set定义toJSON方法,配合JSON.stringify的自动调用机制:
Map.prototype.toJSON = function () { return [...this.entries()]; }; Set.prototype.toJSON = function () { return [...this]; };定义之后,JSON.stringify(map)就会自动输出数组形式。但这会污染原生原型,一般不建议在线上公共代码里这么干,更适合在局部脚本或明确小团队项目里用。
6.4 在React/Vue里操作Map/Set,为什么有时候刷新不更新
现代框架对Map和Set的响应式支持情况不同。Vue 3 的响应式系统原生支持Map和Set,可以直接监听set、add、delete等操作。React 则更需要注意不可变性:直接map.set(key, value)虽然能修改数据,但组件不会感知到引用变化,视图不会更新。更稳妥的做法是生成新实例再 setState:
const nextMap = new Map(this.state.map); nextMap.set("key", "value"); this.setState({ map: nextMap });这里的原理是 React 依赖引用变化触发重渲染。Map和Set的原生修改方法不会改变对象本身引用,所以要靠新建实例的方式模拟不可变更新。这也是很多新人在 React 里用Map后数据改了但视图不动的原因。
6.5 常见错误速查表
| 错误写法 | 问题说明 | 正确写法 |
|---|---|---|
delete map.key | Map没有属性删除方式 | map.delete("key") |
map.forEach((key, value) => ...) | 回调参数顺序是值在前 | map.forEach((value, key) => ...) |
new Set(a).has({ id: 1 }) | 对象字面量每次都是新引用 | 先保存同一个对象引用再判断 |
JSON.stringify(new Map()) | 结果是{} | 先转[...map.entries()]再序列化 |
new Set(arr)[0] | Set不支持索引访问 | 使用[...new Set(arr)][0] |
map.size() | size是属性不是方法 | 直接读map.size |
new Map(object) | 普通对象不是可迭代的键值对集合 | 先Object.entries(object)再传入 |
这些坑未必每个都会遇到,但只要在团队里带过几次新人,基本都能看到它们反复出现。把这些规则记熟,写Map/Set的姿势会标准很多。
我个人在实际项目里的习惯是:需要维护“某条数据是否处理过”的标记时,直接用Set;需要按业务 id 快速定位一条记录时,毫不犹豫选Map;只有面对结构固定的配置对象、需要序列化传输时,才继续用Object。最后再分享一个小技巧:调试Map数据时,用console.table(Array.from(map, ([key, value]) => ({ key, value })))能在一张表格里清楚看到全部键值对,比直接打印Map对象直观得多。这个技巧在你处理几十条配置映射时尤其好用。