前端别再只用数组对象了:Map与Set实战指南
我刚工作那会儿做数据报表页面后端一次返回两千多条数据前端要按分类筛选、按状态统计、按 ID 快速回显我全程用数组filter、find、includes硬写。页面一卡我就怀疑后端接口慢直到大佬过来瞄了一眼说了一句让我记到现在的话“不是接口慢是你拿数组当字典用复杂度全写在脸上了。”现在回想当时缺的就是这份“前端别再只用数组对象了”的意识。日常业务里遇到重复查找、批量去重、频繁增删、以对象为键用Array和普通Object硬扛代码能跑但性能和可读性都吃亏。而 ES6 的Map和Set恰恰是为这些场景准备的。所以这篇文章我不是给你列 API 文档而是用自己踩过的坑把Map和Set到底解决什么问题、什么场景下真香、什么场景下别乱用完整聊一遍。无论你是刚入门的前端还是已经写了两三年业务代码读完都能直接在项目里用起来。1. 为什么你该换换思路从数组对象到 Map/Set1.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); });这里我先把列表数据“预处理”成一个Mapkey 是商品 IDvalue 是商品对象。这样后面每次取值都是 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 是对象、数组等引用类型MapObject会强制转字符串无法正确识别引用类型 key需要频繁增删键值对MapObject的增删操作在隐藏类机制下可能触发性能退化Map增删更稳定key 需要保持插入顺序MapObject对整数 key 有自动升序规则顺序不可控需要知道键值对总数Mapmap.size直接可读Object要Object.keys().length数据需要被 JSON 序列化ObjectJSON.stringify不直接支持Map需要先转换只是少量静态配置Object语法更简洁可读性好没必要上Map需要通过.语法直接访问Objectobj.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 节点存数据。假设页面里有大量列表项每个列表项节点都关联着一个状态对象。如果你用Mapconst stateMap new Map(); listItems.forEach((node, index) { stateMap.set(node, { index, visible: true }); }); // 当某些节点被删除时 container.innerHTML ; // 问题来了stateMap 仍然持有被删除节点的引用 // 这些节点和状态对象都无法被垃圾回收用WeakMapconst 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 场景三复杂状态联动里的 MapSet 组合去年我做了一个配置平台左侧是组件树右侧是配置面板中间还要维护一条“依赖关系”。这个项目我把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()的构造参数是“可迭代的键值对列表”比如二维数组或另一个Mapconst 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这个习惯让我少踩了很多性能坑也让代码的可读性和自解释性上了一层。真香不真香试一次就知道。老铁们赶紧去把项目里那些硬用数组的地方翻一翻该换的数据结构换起来早用早舒坦。