我刚工作那会儿,做数据报表页面,后端一次返回两千多条数据,前端要按分类筛选、按状态统计、按 ID 快速回显,我全程用数组filter、find、includes硬写。页面一卡,我就怀疑后端接口慢,直到大佬过来瞄了一眼,说了一句让我记到现在的话:“不是接口慢,是你拿数组当字典用,复杂度全写在脸上了。”
现在回想,当时缺的就是这份“前端别再只用数组对象了”的意识。日常业务里,遇到重复查找、批量去重、频繁增删、以对象为键,用Array和普通Object硬扛,代码能跑,但性能和可读性都吃亏。而 ES6 的Map和Set,恰恰是为这些场景准备的。
所以这篇文章,我不是给你列 API 文档,而是用自己踩过的坑,把Map和Set到底解决什么问题、什么场景下真香、什么场景下别乱用,完整聊一遍。无论你是刚入门的前端,还是已经写了两三年业务代码,读完都能直接在项目里用起来。
1. 为什么你该换换思路:从数组对象到 Map/Set
1.1 先搞清楚 Map 和 Set 到底是什么
很多老铁对Map和Set的第一反应是“不就用Object和Array就够了嘛”。这个想法我太理解了,毕竟我们绝大多数业务场景里,Object存属性、Array存列表,已经成了肌肉记忆。
但Map和Set不是来替代Object和Array的,它们更像是为“特定数据结构需求”准备的专业工具。
Map是什么?一句话:一种真正的键值对集合,而且键可以是任意类型。你拿对象、数组、函数当 key 都行,它不会像Object那样把 key 隐式转成字符串。它内部基于哈希表实现,自带size属性,遍历时保持插入顺序。
Set是什么?一句话:一种值不重复的集合,你可以把它理解成“数学上的集合”。往Set里加重复的值,它只会保留一个。它同样基于哈希表实现,add、has、delete都是 O(1) 时间复杂度的操作。
这里有一个非常容易搞混的点:Array.prototype.map()是数组的遍历方法,它跟集合类型Map完全不是一回事。一个是操作数组的手段,一个是数据结构本身。你平时说“给我 map 一下”,大概率是前者;但你要是说“我建一个 Map 来存”,那就是后者。名字像,但它们是两个物种。
1.2 本质差异:哈希表 vs 线性扫描
要理解为什么Map/Set在某些场景“真香”,必须抛开 API 层面的花活,看底层的数据结构差异。
数组在内存里是线性存储的。你调用arr.find(item => item.id === 5)或者arr.includes(target)时,它的底层逻辑就是从头到尾遍历一遍,一个个比,直到命中。这意味着,数组长度为 10,查找耗时约 10 个单位;数组长度为 10000,查找耗时约 10000 个单位。时间复杂度是 O(n)。
而Map和Set内部是哈希表结构。你调用map.get(key)或者set.has(value)时,底层逻辑是对 key/value 计算哈希值,然后直接定位到存储位置,不用遍历。无论集合里有 10 个元素还是 10000 个元素,单次查找耗时基本恒定。时间复杂度是 O(1)。
用生活类比解释就是:数组查值像你在一个没有标签的抽屉里翻袜子,要从第一只翻到最后一只,运气不好翻到底才能找到。Map/Set查值像你给每只袜子贴上标签并登记了位置,直接跑过去拿就行,抽屉里袜子再多也只是多占点空间,查找速度几乎不变。
所以,当你频繁执行“按 ID 找对象”“判断某个值存在不存在”这类操作时,用数组就是在用 O(n) 的复杂度做 O(1) 就能完成的事。数据量小没问题,一上量就卡。
2. Map 实战:那些我用数组硬撑到想哭的场景
2.1 缓存与取值场景:别再写两层 for 循环了
我印象最深的一个真实场景是:后端返回了一个商品列表,页面里需要根据用户勾选的商品 ID 数组,频繁获取对应商品详情并展示。
我第一版代码长这样:
const goodsList = [/* 几百个商品对象 */]; const selectedIds = [101, 205, 367]; function getGoodsById(id) { return goodsList.find(item => item.id === id); } selectedIds.forEach(id => { const goods = getGoodsById(id); render(goods); });表面看没啥问题,但goodsList.find()每次都是全列表遍历。如果selectedIds也有几百个,那就是几百乘几百的遍历次数,页面交互明显卡顿。
用Map改造后是这种写法:
const goodsMap = new Map(goodsList.map(item => [item.id, item])); selectedIds.forEach(id => { const goods = goodsMap.get(id); render(goods); });这里我先把列表数据“预处理”成一个Map,key 是商品 ID,value 是商品对象。这样后面每次取值都是 O(1) 直接命中,不用一轮轮遍历。
这个模式的本质,是把“多次查询”的成本转换成“一次建表”的成本。如果列表只查一两次,用find没啥问题;但如果要反复查、多次查、不同模块查,先建Map绝对划算。
实际项目里,我甚至会写一个小工具函数来统一处理这种场景:
function toMap(arr, key = 'id') { return new Map(arr.map(item => [item[key], item])); }后续不管谁要按 ID 取数据,一行搞定。现在这工具已经传遍我们小组了。
2.2 以对象为键:Map 真正不可替代的地方
用数组取值这事,用普通对象也能解决一部分,比如 key 是字符串 ID 的时候,obj[id]一样是 O(1)。那Map到底赢在哪?
答案是:当 key 是引用类型的时候,只有Map能做到,普通Object根本做不到。
因为Object的 key 会被强制转成字符串。你把一个对象当 key 传进去,它默默变成"[object Object]",所有对象都撞车。
我曾经在一个可视化项目里,需要给每个图表实例绑定一个状态对象。我一开始想用普通对象:
const stateMap = {}; const chartInstance = initChart(); stateMap[chartInstance] = { zoom: 2, filter: 'ALL' }; console.log(stateMap); // 输出的是 { '[object Object]': { ... } }等你想取出来的时候,只能通过那个被转成字符串的 key 去取,根本没办法用原对象还原对应关系。这种场景下,Object是完全不可用的。
换成Map毫无负担:
const stateMap = new Map(); const chartInstance = initChart(); stateMap.set(chartInstance, { zoom: 2, filter: 'ALL' }); // 之后无论什么时候,用同一个对象引用当 key 取就行 const state = stateMap.get(chartInstance);类似的场景还包括:需要给 DOM 节点存附加数据、给组件实例缓存内部状态、按钮点击事件里关联业务对象。只要 key 是“某个对象”,优先考虑Map。它保存的是对象的引用本身,而不是对象的字符串化结果。
2.3 遍历顺序与频次:Map 的隐藏优势
还有一个很实用但容易被忽略的点:Map严格保持插入顺序。
普通Object的键顺序其实是有规则的,但也有限制:整数型的 key 会被自动排到前面并按升序排列,字符串 key 才保持插入顺序。这个“自动排序”行为在某些场景下会坑人。
我之前做一个动态表单,需要用对象存储字段配置:
const fieldConfig = {}; fieldConfig['name'] = { label: '姓名' }; fieldConfig['age'] = { label: '年龄' }; fieldConfig['1'] = { label: '自定义字段1' }; for (let key in fieldConfig) { console.log(key); // 输出顺序可能是 '1', 'name', 'age' }字段顺序莫名其妙变成了数字优先,渲染出来表单顺序和定义顺序对不上。这个问题的根源就是Object对整数 key 的特殊处理。
换成Map后:
const fieldConfig = new Map(); fieldConfig.set('name', { label: '姓名' }); fieldConfig.set('age', { label: '年龄' }); fieldConfig.set('1', { label: '自定义字段1' }); for (let [key, value] of fieldConfig) { console.log(key); // 输出永远是 'name', 'age', '1' }Map的遍历完全按照set的顺序,没有任何额外规则。对于“顺序敏感”的数据,比如表单字段、步骤配置、缓存淘汰队列,Map是你的可控性更可靠的选择。
2.4 Map 和 Object 到底怎么选:一张表看懂
我见过很多指南试图把Object和Map的优势说得很复杂,其实落到实际选择上,核心就几条判断标准。下面这张表是我根据自己的项目经验整理的,可以直接贴到团队文档里:
| 判断维度 | 推荐选择 | 理由 |
|---|---|---|
| key 是对象、数组等引用类型 | Map | Object会强制转字符串,无法正确识别引用类型 key |
| 需要频繁增删键值对 | Map | Object的增删操作在隐藏类机制下可能触发性能退化,Map增删更稳定 |
| key 需要保持插入顺序 | Map | Object对整数 key 有自动升序规则,顺序不可控 |
| 需要知道键值对总数 | Map | map.size直接可读,Object要Object.keys().length |
| 数据需要被 JSON 序列化 | Object | JSON.stringify不直接支持Map,需要先转换 |
| 只是少量静态配置 | Object | 语法更简洁,可读性好,没必要上Map |
需要通过.语法直接访问 | Object | obj.name写起来确实比map.get('name')方便 |
核心逻辑就是:当数据“需要被当作真正的键值对集合”来操作时,用Map;当数据只是“一组固定的属性”时,用Object。前端老铁别陷入非此即彼的选择困难症,两者是互补关系。
3. Set 实战:去重、集合运算与判存
3.1 数组去重:一行代码搞定的事
前端面试题里,数组去重简直是八股文常客。很多人写filter + indexOf,或者reduce + includes,其实有了Set之后,去重就是一行代码的事:
const arr = [1, 2, 2, 3, 4, 4, 5]; const unique = [...new Set(arr)]; // 输出 [1, 2, 3, 4, 5]原理非常简单:Set天然具有“不能有重复值”的特性,把数组展开成Set构造参数时,重复值自动被去除,再用扩展运算符把Set转回数组。
这是最基础的去重。还有两个进阶点你们一定用得上。
进阶一:对象数组怎么去重?
如果你直接new Set(arr)——一个包含对象的数组——会发现去了个寂寞。因为Set判断是否重复时,对引用类型比较的是引用地址,而不是对象内容。两个结构相同但地址不同的对象,Set认为是两个不同值。
所以对象数组去重要先指定“唯一依据”,比如按id去重:
const list = [ { id: 1, name: '张三' }, { id: 2, name: '李四' }, { id: 1, name: '张三' }, ]; const deduped = [...new Map(list.map(item => [item.id, item])).values()]; // 先用 id 做 Map 的 key,后面出现的重复 id 会覆盖前面的,再取 values这一招其实结合了Map和Set两种结构的思维。先去重,后取值,一行代码解决对象数组按字段去重的问题。
进阶二:数组去重的兼容性问题。
new Set(arr)在绝大多数现代浏览器里都没问题。但如果你的项目要兼容非常老的运行环境(比如某些内置 WebView),需要做降级处理,用filter + indexOf兜底。2026 年再搞前端开发,基本可以无视这个兼容性顾虑,但面试时候能说出降级方案,比只会背一行代码要强。
3.2 交集、并集、差集:Set 的数学天赋
前端开发中处理集合运算,最常见的场景是权限、标签或筛选条件。比如两个角色拥有的权限列表,要算交集、并集、差集,用数组处理非常痛苦,但用Set可以实现得非常优雅。
交集:两个集合中共同存在的值。
const a = new Set([1, 2, 3, 4]); const b = new Set([3, 4, 5, 6]); const intersection = [...a].filter(item => b.has(item)); // [3, 4]并集:合并两个集合,自动去重。
const union = new Set([...a, ...b]); // Set { 1, 2, 3, 4, 5, 6 }差集:在 A 中但不在 B 中的值。
const difference = [...a].filter(item => !b.has(item)); // [1, 2]这几个写法里,核心工具都是Set.prototype.has()。判断一个值在不在集合里,O(1) 复杂度,配合数组的filter遍历,逻辑一行就表达清楚了。
我在实际项目里封装过一个通用工具函数:
function setOperation(setA, setB, type = 'intersection') { const aArr = [...setA]; if (type === 'intersection') { return new Set(aArr.filter(v => setB.has(v))); } if (type === 'difference') { return new Set(aArr.filter(v => !setB.has(v))); } if (type === 'union') { return new Set([...setA, ...setB]); } }前端能碰到的集合运算,绝大多数是这些简单场景。记住这几个模式,比每次现场想逻辑要快得多。
3.3 判断存在性:从 includes 到 Set.has 的飞跃
还有一个高频操作是“判断某个值是否存在”。
以前我们用数组处理:
const allowedRoles = ['admin', 'editor', 'viewer']; if (allowedRoles.includes(userRole)) { // 放行 }功能没问题,但includes底层是遍历比较。如果allowedRoles有几百上千个值,而每次渲染都要判断多次,这个遍历开销会被放大。
更好的写法:
const allowedRoles = new Set(['admin', 'editor', 'viewer']); if (allowedRoles.has(userRole)) { // 放行 }换成Set之后,判断存在性的复杂度从 O(n) 降到 O(1),在大量判断场景下性能提升非常明显。
另一个实际体验很深的点是用户勾选场景。比如页面里有一堆可选项,用户勾选一些,需要记录“哪些被勾选了”,同时要判断某个选项是否已勾选。
数组写法通常是这样:
const selected = []; function toggle(item) { const idx = selected.indexOf(item); if (idx > -1) { selected.splice(idx, 1); } else { selected.push(item); } } function isSelected(item) { return selected.includes(item); }这个写法问题不少:indexOf返回 0 的时候判断要小心、splice改变数组索引、频繁增删导致多次遍历。换成Set之后:
const selectedSet = new Set(); function toggle(item) { if (selectedSet.has(item)) { selectedSet.delete(item); } else { selectedSet.add(item); } } function isSelected(item) { return selectedSet.has(item); }代码量更少,逻辑更直白,而且不管集合多大,判断和增删都是 O(1)。我还经常利用Set的size属性直接取选中数量,不必每次length操心重复项。
4. WeakMap 和 WeakSet:进阶选手的内存管理
4.1 弱引用是什么?为什么有必要
聊完Map和Set,如果只说业务层面就停了,那这文章也就到及格线。真正让我觉得“数据结构思维开窍”的,是WeakMap和WeakSet。
WeakMap和WeakSet是Map/Set的“弱引用版本”。什么叫弱引用?简单说,如果一个对象只被WeakMap或WeakSet引用着,而没有被其他代码引用,那这个对象就可以被垃圾回收机制回收掉。
用生活类比:强引用像你把某人微信好友天天联系,对方一直在你通讯录里;弱引用像你只是在大街上瞥了某人一眼,擦肩而过之后,对方就从你的记忆里消失了,你也不会占用任何“关注名额”。
普通Map持有的是强引用。这意味着:
let user = { name: '张三' }; const userMap = new Map(); userMap.set(user, { visits: 10 }); user = null; // 我把 user 变量置空了 // 但 userMap 里那个 key 仍然持有原对象,它不会被回收在这个例子里,你以为把user置空就能释放内存了,但Map还牢牢攥着那个对象,垃圾回收器无法回收它。如果这种场景很多,就会造成内存泄漏。
WeakMap的 key 必须是对象,而且它不会阻止垃圾回收:
let user = { name: '张三' }; const userWeakMap = new WeakMap(); userWeakMap.set(user, { visits: 10 }); user = null; // 此时 WeakMap 里的 key 不再阻止垃圾回收 // 对象可以被回收了WeakMap对这个对象很“佛系”:你用得上它,它在;你不用了,它就自动消失,不拖泥带水。
4.2 防内存泄漏的实战场景
我自己印象最深的场景是给 DOM 节点存数据。假设页面里有大量列表项,每个列表项节点都关联着一个状态对象。
如果你用Map:
const stateMap = new Map(); listItems.forEach((node, index) => { stateMap.set(node, { index, visible: true }); }); // 当某些节点被删除时 container.innerHTML = ''; // 问题来了:stateMap 仍然持有被删除节点的引用 // 这些节点和状态对象都无法被垃圾回收用WeakMap:
const stateWeakMap = new WeakMap(); listItems.forEach((node, index) => { stateWeakMap.set(node, { index, visible: true }); }); // 当节点被从页面移除、且外部没有其他引用时 // 节点和对应的状态对象会自动被垃圾回收如果用Map存 DOM 节点的数据,节点删除后Map还在占着内存不放。用WeakMap,节点从页面消失,它的数据也跟着消失,干净利落。
WeakSet的应用类似,比如你要记录“哪些对象已经被处理过”,但不希望这个记录本身阻止对象被回收:
const processed = new WeakSet(); function processData(obj) { if (processed.has(obj)) return; // 处理逻辑... processed.add(obj); }这里用WeakSet的好处是:处理完的对象将来不再被使用、引用全部释放后,processed不会阻碍垃圾回收。如果用Set,这些对象会被长期记住,白白占着内存。
不过要注意,WeakMap和WeakSet有个不方便的地方:它们不可遍历,也没有size属性。因为里面的条目可能随时被回收,API 设计上就不允许你去枚举它们。所以它们是“工具型”结构,适合内部辅助记录,不适合当业务数据的主要存储容器。
5. 三个真实场景串讲:组合拳才见真章
5.1 场景一:接口数据分组缓存
有一次做数据看板,后端一次性返回了全量销售记录,前端需要按不同的区域分组展示,并且后续要支持按“区域 + 日期”快速筛选。
用数组硬写的话,每切一次筛选条件,就要filter一遍全量数据。数据量一多,页面直接卡成 PPT。
我用Map做了一个分组缓存器:
const groupCache = new Map(); function getGroupedData(rows, groupKey) { // 缓存 key 由分组字段决定,避免重复分组计算 if (groupCache.has(groupKey)) { return groupCache.get(groupKey); } const grouped = new Map(); rows.forEach(row => { const key = row[groupKey]; if (!grouped.has(key)) { grouped.set(key, []); } grouped.get(key).push(row); }); groupCache.set(groupKey, grouped); return grouped; }第一次调用时做全量分组,之后同样的分组条件直接命中缓存。再看板组件频繁切换筛选条件时,体感完全不一样。
这个场景的核心就是把Map的两个能力叠在一起:分组结果存储 + 按条件缓存。
5.2 场景二:权限管理里的 Set 应用
权限判断是最能体现Set优势的业务场景之一。
业务里有不同角色,每个角色对应一组可操作的按钮编码。判断用户能不能点某个按钮,传统做法是遍历权限数组,验证某个编码在不在里面。
我用Set存用户可见操作权限,判断时直接has:
// 用户登录后,后端返回权限码列表 const permissionList = ['user:create', 'user:edit', 'user:delete']; // 转成 Set,查询效率从 O(n) 变 O(1) const permissionSet = new Set(permissionList); function canOperate(code) { return permissionSet.has(code); } // 模板里用 if (canOperate('user:delete')) { // 显示删除按钮 }这套方案在小权限列表时看不出差别,但权限码一旦多起来,尤其是每次渲染要判断几十个按钮时,区别就出来了。
另外,多个角色合并权限时,Set的并集操作能直接利用起来:
function mergePermissions(roles) { const result = new Set(); roles.forEach(role => { role.permissions.forEach(p => result.add(p)); }); return result; }因为Set天然去重,多角色权限合并后不会出现重复项,不需要额外做去重逻辑。这就是数据结构的“自带属性”帮你少写业务代码。
5.3 场景三:复杂状态联动里的 Map+Set 组合
去年我做了一个配置平台,左侧是组件树,右侧是配置面板,中间还要维护一条“依赖关系”。这个项目我把Map和Set的组合用到了极致。
数据结构设计是这样的:
// 每个组件节点对应一个 Set,记录它依赖了哪些其他组件 const dependencyMap = new Map(); // 添加依赖 function addDependency(componentId, dependsOnId) { if (!dependencyMap.has(componentId)) { dependencyMap.set(componentId, new Set()); } dependencyMap.get(componentId).add(dependsOnId); } // 判断是否已依赖 function hasDependency(componentId, dependsOnId) { return dependencyMap.has(componentId) && dependencyMap.get(componentId).has(dependsOnId); } // 获取某个组件的全部依赖 function getDependencies(componentId) { return dependencyMap.get(componentId) || new Set(); }用Map管理“组件ID -> 依赖集合”的映射,用Set管理“一个组件的依赖集合”。增删查都是 O(1),而且语义非常清晰,你自己过一个月回来看代码也能秒懂。
如果在数组方案里做同样的功能,每次判断是否依赖都要两层遍历,而且要自己处理重复添加的问题。数据结构选对,复杂状态联动直接降维打击。
6. 前端老铁的常见误区与面试高频题
6.1 误区一:把 Map 和 Array.prototype.map 混为一谈
这是我在团队 Code Review 时见过最多的问题。很多刚接触 ES6 的同学,看到“Map”两个字就要愣一下:到底是数据结构还是数组方法?
强烈建议在团队内部统一说法:数据结构Map叫“映射集合”,数组方法map叫“映射遍历”。开会和写注释时都用全称,避免歧义。
另外注意,new Map()的构造参数是“可迭代的键值对列表”,比如二维数组或另一个Map:
const map = new Map([ ['name', '张三'], ['age', 18], ]);而Array.prototype.map()接收一个回调函数,做的是遍历转换:
const numbers = [1, 2, 3].map(n => n * 2);这俩连参数类型都不一样,一起出现时,你要能自动切换到不同的语义模式。
6.2 误区二:任何时候都用 Map/Set,反而更慢
说句公道话,Map和Set不是银弹。在数据量极小(比如几个元素)时,数组和普通对象往往更快,因为它们底层是紧凑的线性存储,缓存友好度高,而且 JIT 引擎会对常规对象做深度优化。
还有一种场景别用Map:你需要直接对数据结构做JSON.stringify序列化。JSON.stringify对Map的支持很尴尬,默认输出{},你要先转成普通对象或数组:
const map = new Map([['name', '张三']]); const obj = Object.fromEntries(map); // 或直接转数组传后端 const arr = [...map];如果你需要把数据存到localStorage、发给后端、或者和其他非前端系统对接,Map的反序列化往往需要额外处理,普通对象反而一路畅通。
所以,选择数据结构前先问自己三个问题:数据量有多大?操作频率多高?要不要序列化?想清楚了再决定用不用Map/Set。
6.3 面试高频考点:Map/Set 面试题怎么答
前端面试里,Map和Set基本是必问常规题。我整理几个最高频的问题和答题思路,你照着这个思路准备,基本覆盖面试官所有出题角度。
问题一:Map 和 Object 有什么区别?
答题框架:先把 key 类型、遍历顺序、size、增删性能、序列化五个维度列清楚,再强调“Map 的 key 可以是任意类型,Object 的 key 只能是字符串或 Symbol”,最后补一句“频繁增删用 Map,固定配置用 Object”。
问题二:Set 怎么实现数组去重?
直接给代码[...new Set(arr)],然后补充“如果是对象数组,需要根据唯一字段去重”,再给[...new Map(list.map(item => [item.id, item])).values()]的方案。面试官如果追问复杂度,你要能说出Set.has是 O(1),比includes的 O(n) 高效。
问题三:WeakMap 的 key 为什么必须是对象?
因为弱引用机制只对对象有意义,基本类型(字符串、数字)不存在“回收”的问题。你可以反问一句:如果有人用WeakMap,说明他关注内存管理,这样回答会加分。
问题四:能遍历 WeakMap 吗?为什么?
不能。因为WeakMap里的元素随时可能被垃圾回收,遍历出来的结果不稳定,所以 API 设计上只提供get、set、has、delete,没有keys()、values()、forEach()这些遍历方法,也没有size属性。
我面试别人的时候,还喜欢加一个“陷阱题”:问Map和普通对象谁的key可以是undefined。其实两者都可以,但Map是真正把它当成一个独立 key,而Object会把undefined转成字符串"undefined"。能答出这个细微差别的,基本是对数据结构有真实理解的候选人。
写在最后
前端开发这个行当,入门靠 API,进阶靠数据结构意识。写业务代码时,多想想“我现在用的数组,到底适不适合这个场景”,你就已经赢过一半人了。
我个人现在写代码有个习惯:凡是出现find、includes、indexOf的地方,先停下来想三秒——这里是不是应该用Map或者Set?这个习惯让我少踩了很多性能坑,也让代码的可读性和自解释性上了一层。
真香不真香,试一次就知道。老铁们赶紧去把项目里那些硬用数组的地方翻一翻,该换的数据结构换起来,早用早舒坦。