
ES6新标准发布已经有年头了Map和Set这两个数据结构始终没拿到该有的存在感。前几天帮人排查一段代码想用普通对象做缓存键是动态拼接出来的字符串结果有个值是undefined却显示有缓存查了好久才意识到是键名覆盖的锅。这两兄弟不是花活是真能解决实际问题的。这篇不讲语法手册我想把 Map 和 Set 拆开揉碎聊聊说清楚它们的原理、边界、和 Object/Array 的选型关系最后一节给一个直接能抄的实战改造。无论你是刚掌握 ES6 的几个新语法还是写了好几年业务想提升代码质量这篇都值得读完。1. 对象当字典、数组当集合这两张旧牌打了好几年的狭窄1.1 用普通对象存键值对坑都在哪儿很多同学写业务代码时第一反应就是用普通对象{}当字典。遇到几千条数据还好一旦键是动态的、或者键本身不是字符串问题就来了。最经典的坑就是把对象当键。普通对象会把键隐式转成字符串对象转出来都是[object Object]两个不同对象的键直接互相覆盖。比如下面这段代码const cache {} const keyA { id: 1 } const keyB { id: 2 } cache[keyA] A的数据 cache[keyB] B的数据 console.log(cache) // { [object Object]: B的数据 }A的数据就莫名其妙没了。这还不算完普通对象自带原型链像toString、hasOwnProperty、constructor这些名字天生存在。如果业务里的键恰好叫hasOwnProperty赋值后可能把内置方法顶掉后续调用obj.hasOwnProperty(...)直接报错。想避开这个问题还得用Object.create(null)生成无原型对象麻烦。另外两个小问题也很烦。第一普通对象没有直接的size属性想知道存了多少键值对得Object.keys(obj).length绕一下。第二遍历顺序存在“隐藏规则”整数字符串键按数字升序排列而不是按插入顺序排。写代码时觉得没问题数据一变顺序就乱排查起来相当难受。1.2 数组做“集合”的代价去重和查询都绕路数组和集合是两回事。数组允许重复元素也没有“判断是否包含”的高效接口。传统去重写法是这样的const arr [1, 2, 2, 3, 3, 4] const unique arr.filter((v, i) arr.indexOf(v) i)indexOf每次都是从头线性扫描整体复杂度是 O(n²)。数组一长页面就能感觉出卡顿。再比如频繁判断某个对象是否处理过很多人写list.includes(target)这也是 O(n) 全表扫描。业务里“判存在”这个动作非常高频数组一次两次没事循环里跑几千次就非常不划算。这些场景积累多了就知道为什么 ES6 新标准要把 Map 和 Set 单独拎出来了一是解决“键必须是字符串”的限制二是解决“唯一性和成员判断”的原生缺失。2. Map把键从“字符串”解放出来的键值对容器2.1 最基础的增删查改Map 的基本操作非常直观一个方法一个动作const map new Map() map.set(name, 测试任务) map.set(123, 数字键) map.set(undefined, undefined 也能当键) map.get(name) // 测试任务 map.has(123) // true map.size // 2 map.delete(undefined) // true map.clear() // 清空所有键值对有几个容易被新手绊倒的细节。第一set方法返回的是 Map 本身所以可以链式调用map.set(a, 1).set(b, 2).set(c, 3)第二size是属性不是方法写成map.size()会直接报错。第三判断键是否存在一定要用has不要用get返回值判断因为值可能是undefined也可能是null还可能就是假值0容易误判。我习惯把 Map 当成“状态字典”用尤其是“某个 ID 对应某项配置”这种模式。读取数据用get判断存在用has删除用delete语义清晰比obj[key] undefined的判断方式稳得多。2.2 键的任意类型与内部比较规则“键可以是任何类型”这句话要落到细节上。Map 的键可以是对象、函数、Symbol甚至NaN。普通对象做不到这一点因为对象键最终都会被转成字符串。键的比较规则是 SameValueZero。简单理解NaN自己等于自己0和-0视为同一个值其他情况基本和一致对象必须引用相同才算同一个键。const map new Map() map.set(NaN, NaN 键) map.get(NaN) // NaN 键 const obj { id: 1 } map.set(obj, 引用对象) map.get(obj) // 引用对象 map.get({ id: 1 }) // undefined因为引用不同这里有一个非常实用的点NaN NaN在 JavaScript 里是false但 Map 能正确识别。如果你以前用普通对象存NaN相关状态键会被转成字符串NaN逻辑上容易出怪问题换成 Map 之后这类边界情况直接被容器处理掉了。2.3 new Map(iterable) 初始化妙用Map 构造函数接受一个可迭代对象最常见的是二维数组每个数组项是一个“双元素数组”第一项是键第二项是值const entries [ [title, 周报], [status, 进行中] ] const map new Map(entries) map.get(title) // 周报因为 Map 本身也是可迭代的所以还可以直接复制另一个 Mapconst copy new Map(map)这在“需要基于原表创建新表但不污染原表”的场景里特别方便。后面会讲到的new Map(Object.entries(obj))也是初始化时的高频用法把普通对象快速转成 Map。数组项格式如果不符控制台会直接报错“Iterator value X is not an entry object”。所以传参前要确认每一项都是双元素数组。3. 遍历顺序、迭代协议和 Object 互转Map 的“正规军”体验3.1 插入顺序的真实含义Map 的遍历顺序严格按插入顺序输出这一点和普通对象完全不一样。普通对象有整数键自动升序等规则Map 没有这些弯弯绕你按什么顺序set进去遍历就按什么顺序出来const map new Map() map.set(b, 1) map.set(a, 2) for (const [key, value] of map) { console.log(key, value) } // b 1 // a 2这个特性在“需要顺序的字典”场景里非常值钱。比如要做最近使用列表普通对象保存顺序需要额外维护一个数组用 Map 直接天然有序插入、更新、按序读取一步到位。3.2 keys、values、entries 和扩展运算符Map 提供了三种迭代器方法const map new Map([ [x, 1], [y, 2] ]) map.keys() // 键的迭代器 map.values() // 值的迭代器 map.entries() // 键值对的迭代器还有一个等价关系map[Symbol.iterator] map.entries这意味着默认的for...of遍历拿到的就是[key, value]数组。扩展运算符配合使用非常顺手console.log([...map]) // [[x, 1], [y, 2]]把 Map 展开成二维数组再交给new Map(...)或者Object.fromEntries(...)整套转换链路就通了。3.3 与普通对象互转的正反操作Map 和普通对象的互转在项目里几乎是必用技能const obj { name: 任务一, priority: high } // 对象转 Map const map new Map(Object.entries(obj)) // Map 转回对象 const plainObj Object.fromEntries(map) console.log(plainObj) // { name: 任务一, priority: high }Object.fromEntries是反向操作它接受一个可迭代对象把二维数组或 Map 转成普通对象。需要注意的是如果同一个键出现多次后面的值会覆盖前面的值。另外这只是浅转换嵌套对象不会被深拷贝该有的拷贝逻辑还是要自己处理。3.4 JSON 序列化这个坑先记下这是我见过最多人踩的坑JSON.stringify处理 Map 会返回空对象。const map new Map([[title, 测试]]) console.log(JSON.stringify(map)) // {}原因是 JSON 序列化只读取可枚举的普通属性Map 的内部存储并不以这种形式暴露于是默认就给了一个空壳。解决办法很简单序列化前先转成二维数组。const map new Map([[title, 测试]]) const jsonString JSON.stringify([...map]) // [[title,测试]] const restored new Map(JSON.parse(jsonString))如果担心结构不清晰可以定义成固定格式对象比如{ type: Map, entries: [...] }接收端再按对应格式还原。实际项目里我一般直接在网络传输层封装一个serializeMap函数统一处理这种事免得每个调用点都重复踩坑。4. WeakMap键是弱引用的“隐形数据袋”4.1 弱引用到底弱在哪里普通 Map 只要没被手动删除键值对就会一直存在。哪怕业务对象在外面已经没有别的引用了Map 里还握着这个键垃圾回收就不能收走它。久而久之容易形成内存泄漏。WeakMap 不一样它对外部的引用对象只保留“弱引用”只要没有其他强引用指向这个对象GC 就可以把对象和 WeakMap 里的对应记录一起收掉。打个比方普通 Map 像宾馆前台一直保留着退房客人的房卡客人走了房卡还挂着WeakMap 像临时寄存柜人离开之后工作人员自动清空柜子不需要你多交代一句。注意GC 什么时候发生是不确定的所以不要依赖“马上消失”这种假设。你只需要理解WeakMap 不会阻止对象被回收这部分内存压力更低。4.2 典型应用DOM关联数据、缓存、对象附加信息WeakMap 最常见的应用场景是给 DOM 节点附加状态。比如标记某个节点是否已经处理过const statusMap new WeakMap() function markProcessed(el) { statusMap.set(el, { processed: true, time: Date.now() }) } function isProcessed(el) { return statusMap.has(el) statusMap.get(el).processed }节点被移除或引用消失后statusMap里的记录也会被垃圾回收不会造成节点已删但数据残留的问题。如果用普通 Map你就得手动监听移除、手动删除记录代码立刻变复杂。另一个典型用途是给对象附加“私有数据”。比如在类外部用一个 WeakMap 存储内部字段外部拿不到这个 WeakMap 就读取不到数据const privateStore new WeakMap() class User { constructor(name) { privateStore.set(this, { name }) } getName() { return privateStore.get(this).name } }现代类语法已经开始支持真正的私有字段了但很多底层库仍然在内部使用 WeakMap 做数据隔离原因就是它不污染对象自身属性也不会阻止对象释放。4.3 WeakMap 的限制和使用体验WeakMap 的三个限制每个都和它的弱引用机制直接相关。第一键必须是对象基本类型当键会直接报错。第二不能遍历没有keys、values、entries、forEach等方法。第三没有size属性也没有clear方法。原因在于垃圾回收后成员数量不确定遍历和统计在语义上就不成立。所以 WeakMap 适合做“附加数据袋”不适合做“主体数据结构”。如果需要一个能排序、能统计、能完整遍历的映射表老老实实用 Map。5. Set自带唯一性约束的集合容器5.1 基本用法add、has、delete、sizeSet 是一个不包含重复值的集合基本操作和 Map 很像const set new Set() set.add(1) set.add(2) set.add(1) // 重复忽略 set.has(1) // true set.size // 2 set.delete(2) set.clear()has方法用来判断成员是否在集合里这是使用频率最高的方法。判断的复杂度在引擎优化下通常接近 O(1)和Array.includes的 O(n) 线性扫描完全不同。Set 自带唯一性约束所以“add 一个已经存在的值”不会报错也不会让集合变大。这个语义比数组靠filter保证唯一性要可靠得多。5.2 数组去重、字符串去重和对象去重Set 最常见的出场方式就是去重const arr [1, 2, 2, 3, 3, 4] const unique [...new Set(arr)] // [1, 2, 3, 4]字符串去重也是同一个套路const s hello const uniqueStr [...new Set(s)].join() // helo但要注意Set 去重用的是引用比较和 SameValueZero不是深比较。两个对象就算内容一模一样引用不同也会被当成不同成员const a { id: 1 } const b { id: 1 } const set new Set([a, b]) console.log(set.size) // 2如果想按对象里的某个字段去重还得自己写函数。另外有两个小细节值得记住NaN在 Set 里只保留一个0和-0会被视为同一个值。5.3 集合运算并集、交集、差集、对称差集有了 Set 的has方法集合运算写起来非常舒服const a new Set([1, 2, 3]) const b new Set([3, 4, 5]) const union new Set([...a, ...b]) // {1, 2, 3, 4, 5} const intersection new Set([...a].filter(x b.has(x))) // {3} const difference new Set([...a].filter(x !b.has(x))) // {1, 2} const symmetricDifference new Set( [...a, ...b].filter(x a.has(x) ! b.has(x)) ) // {1, 2, 4, 5}用数组写法实现的话filter includes的时间复杂度是 O(n²)换成filter has之后整体复杂度降到近似 O(n)。数据量一大差距会非常明显。这里还有一个潜在优势集合运算之后的结果仍然是 Set天然去重后续继续做运算也方便。5.4 WeakSet只做“标记”不添麻烦WeakSet 和 Set 的关系同 WeakMap 与 Map 的关系一样成员只能是对象对象没有其他引用时会被回收。WeakSet 不支持遍历没有size只能add、has、delete。它最适合的用途是“标记”比如标记一个对象是否被处理过const visited new WeakSet() function visit(node) { if (visited.has(node)) return visited.add(node) // 处理 node }如果用普通 Set 存这些已访问节点对象释放后 Set 里还留着引用会阻止 GC 回收。用 WeakSet 就没有这个负担对象消失标记一起消失。这个模式在遍历树结构、防止循环引用、事件去重等场景里很有用。6. 选型三板斧Map、Set、Object、Array 到底怎么分6.1 一张表对比四种容器先给一张总览表方便按需查容器键/值类型顺序长度唯一性JSON 序列化适合场景Object键为字符串或 Symbol值为任意类型整数键自动升序字符串按插入序无直接 length键唯一默认支持固定结构、接口数据、JSON 互操作Map键为任意类型值为任意类型严格插入序size 属性键唯一需手动转换动态键、频繁增删、对象作键、缓存Array数字索引值为任意类型索引顺序length不限制默认支持有序列表、批量数据Set成员为任意类型WeakSet 限定对象插入序size 属性成员唯一需手动转换去重、成员判断、集合运算注意 Object 的键顺序并不是一句“按插入序”能概括的规范里整数样式的键会优先按数字大小排序。业务中一旦发现顺序诡异先想想是不是普通对象在作怪。6.2 按场景拆解字典、缓存、白名单、去重遇到具体需求可以这样判断需要一个“根据 ID 查对象”的接口键可能是数字、字符串也可能直接是对象用 Map。需要一个固定结构的数据体要传给后端要用 JSON 序列化用 Object。需要频繁push、频繁按位置删除数组的splice是 O(n)Map 的set/delete是 O(1)。如果顺序无所谓把数组换成 Map 或 Set 往往更合适。需要维护一个不重复的收藏列表且要快速判断一个 ID 是否在列表里用 Set。需要给大量 DOM 节点挂临时状态节点删除后状态不残留用 WeakMap。只需要给对象打“见过/没见过”的标记用 WeakSet。这些规则不是硬性规定但按这套思路选型代码基本不会出现结构性大坑。6.3 基于实测的避坑清单把实操里最容易出问题的地方汇总成清单比读文档要有用得多JSON.stringify对 Map 和 Set 都默认返回{}传输前务必手动转换。普通对象当键会被转成字符串多个对象互相覆盖这类 BUG 非常隐蔽。Set 去重只做引用比较和 SameValueZero不要指望它深比较对象。WeakMap 和 WeakSet 都不能遍历也不适合用for...of一用就报错。Object.fromEntries转换时后面重复键会覆盖前面注意数据源的键是否可能出现重复。正在用for...of遍历 Map 时不要边遍历边继续set或delete会让迭代结果变得不好预测。最好先收集要改的键遍历结束后再统一操作。7. 实战用 Map 和 Set 重构一个“任务清单管理器”7.1 原始实现的憋屈写法假设某团队内部工具里有一个任务清单模块最初是这样写的const tasks [] function addTask(id, title) { tasks.push({ id, title, done: false }) } function getTask(id) { return tasks.find(t t.id id) } function toggleFavorite(id) { const task tasks.find(t t.id id) if (task) task.favorite !task.favorite } function getFavoriteTasks() { return tasks.filter(t t.favorite) }功能能跑但毛病很明显。第一每次getTask都要线性扫描整个数组任务多了之后性能肉眼可见下滑。第二收藏状态是任务对象上的一个字段不是一个独立集合统计收藏数量得filter一次语义也不直接。第三addTask没有检查 ID 是否重复同一个 ID 能 push 两条数据后面查找会拿到第一个容易产生脏数据。7.2 Map Set 的改造过程改成 Map 和 Set 之后整个模块的职责一下就分开了Map 负责“按 ID 查任务”Set 负责“收藏 ID 的集合”。class TaskManager { constructor() { this.tasks new Map() this.favorites new Set() } addTask(id, title) { if (this.tasks.has(id)) return false // 重复 ID 直接拒绝 this.tasks.set(id, { id, title, done: false }) return true } getTask(id) { return this.tasks.get(id) } toggleDone(id) { const task this.tasks.get(id) if (!task) return task.done !task.done } toggleFavorite(id) { if (!this.tasks.has(id)) return if (this.favorites.has(id)) { this.favorites.delete(id) } else { this.favorites.add(id) } } get favoriteCount() { return this.favorites.size } getFavoriteTasks() { return [...this.favorites] .filter(id this.tasks.has(id)) .map(id this.tasks.get(id)) } removeTask(id) { this.tasks.delete(id) this.favorites.delete(id) } }核心变化有三处。getTask从find变成get查询复杂度从 O(n) 变成 O(1)。toggleFavorite变成对 Set 的增删收藏数量直接读size不需要额外遍历。removeTask同时清理 Map 和 Set 中对应 ID避免脏数据残留。7.3 改造后的收益与复盘改造完再看最初的问题重复 ID 会被has拦住查询任务固定走get收藏状态被独立表达。Semantics 清晰之后代码读起来不再需要“在数组里筛一下”的绕弯而是“取具体项”“判断是否在集合中”意图直接传递。收藏顺序用 Set 天然保留按收藏时间倒序展示只需要改一下展示层。以后如果再增加标签功能可以再用一个Map标签名, Set任务ID来组织整体扩展性也好很多。我个人经过多次类似改造后的体会是对象解决的是“结构描述”数组解决的是“有序数据”Map 解决的是“动态键的映射”Set 解决的是“唯一成员的集合”。你不需要背选型表遇到问题先问一句“我要靠什么查、查完了要不要保证唯一、顺序重不重要”答案基本就自己跑出来了。这个习惯比记住某个 API 的用法重要得多。