☰
ES6 Map与Set实战指南:告别对象键覆盖与数组去重痛点
2026/10/14 8:52:33 网站建设 项目流程

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 本身也是可迭代的,所以还可以直接复制另一个 Map:

const 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、size

Set 是一个不包含重复值的集合,基本操作和 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 基于实测的避坑清单

把实操里最容易出问题的地方汇总成清单,比读文档要有用得多:

  1. JSON.stringify对 Map 和 Set 都默认返回{},传输前务必手动转换。
  2. 普通对象当键会被转成字符串,多个对象互相覆盖,这类 BUG 非常隐蔽。
  3. Set 去重只做引用比较和 SameValueZero,不要指望它深比较对象。
  4. WeakMap 和 WeakSet 都不能遍历,也不适合用for...of,一用就报错。
  5. Object.fromEntries转换时,后面重复键会覆盖前面,注意数据源的键是否可能出现重复。
  6. 正在用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 的用法重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询