JS Set 与 Map 精讲:从去重、缓存到频次统计实战

发布时间:2026/10/15 23:48:41
JS Set 与 Map 精讲:从去重、缓存到频次统计实战 Set 和 Map 是 ES6 时代就引入的两个集合类型但直到现在我身边还有很多写了好几年 JavaScript 的同事遇到去重缓存频次统计这类需求时第一反应还是开一个空对象或者空数组然后手动遍历。不是说对象和数组不行而是大多数情况下用 Set 和 Map 写出来的代码更短、更直白、更不容易出错。这篇就来把这两个 API 从基本操作到实际场景完整过一遍顺便把我踩过的坑也一并交代清楚。先说清楚这篇适合谁看。如果你刚学完 JavaScript 的基本语法知道forEach和filter怎么用但看到new Set()、new Map()还是一脸懵那这篇就是为你准备的。如果你已经用过这些 API但遇到引用类型去重失效遍历顺序和对象不一样WeakMap 到底什么时候用这类问题没想明白那这篇同样值得花十分钟读完。1. 整体设计与思路拆解为什么需要 Set 和 Map1.1 数组和对象在集合场景下的尴尬之处在 Set 和 Map 出现之前JavaScript 开发者处理集合数据基本只有两个容器数组Array和对象Object。数组擅长有序列表对象擅长键值映射。但真到了实际项目里这两个容器在某些场景下会让人非常难受。比如去重这个需求。用数组的话常规做法是indexOf或者includes先判断再 push。每来一个新元素都要遍历一遍已有数组数据量一大O(n^2) 的时间复杂度就开始教做人了。用对象做去重倒是快但对象的键只能是字符串你存数字1和字符串1会被当成同一个键这显然不是所有场景都想要的结果。而且对象还有原型链constructor、toString这种键会带来各种隐蔽的 bug冷不丁就把你的数据吞了。再比如频次统计。用对象做统计器逻辑上没问题但你不能保证统计的键里不会出现__proto__这种危险的键名。一旦出现轻则数据错误重则直接污染原型链这是真实发生过的事故。你需要一个更纯粹的容器它只存你自己的数据不掺任何私货这正是 Map 的设计动机。1.2 Set 和 Map 的核心设计理念Set 的语义是集合它只关心一个事这个值出现没出现过。所以它天然具备两个特性值唯一、查找高效。Map 的语义是键值对映射它的键可以是任意类型包括对象、函数甚至 NaN这是对象键无法做到的。这两个容器内部都采用了哈希表的实现思路插入、删除、查找的平均时间复杂度是 O(1)。你不需要关心哈希冲突这些底层细节只需要知道在大多数常规操作里它们比数组的includes、indexOf这类线性查找要快得多。还有一个经常被人忽视的设计细节迭代顺序。Set 和 Map 的遍历顺序都是插入顺序这和对象键的遍历顺序规则完全不同。这意味着你可以把 Set 当作一个有序且唯一的列表来用这在某些业务场景里非常实用比如记录用户操作序列但又要去重。1.3 和数组、对象的选型对照我用一个简单粗暴的准则来判断该用哪个容器值列表用数组唯一值列表用 Set任意键的映射用 Map字符串键的映射用对象。但这只是第一步。更实际的经验是如果你要做频繁的增删查操作且数据量超过几百条Set 和 Map 的性能优势会非常明显。如果你只是存几个固定字段对象更直观因为它配合解构赋值和 JSON 序列化都非常顺手。Map 虽然强大但不能直接用JSON.stringify序列化这是个很实际的限制后面我会细说。2. 核心细节解析与实操要点2.1 Set 的基本操作从构造函数到遍历先看 Set 的创建和基本方法。构造函数接收一个可迭代对象作为参数最常见的就是传一个数组。const set new Set([1, 2, 3, 3, 4, 5, 5]); console.log(set); // Set(5) { 1, 2, 3, 4, 5 } console.log(set.size); // 5size 属性对应数组的 length这里有两个容易踩的坑。第一Set去重用的是同值零SameValueZero算法它认为NaN NaN这和的判断不同。第二new Set()传字符串也是合法的因为字符串本身就是可迭代对象它会把每个字符拆开放进集合。const charSet new Set(hello); console.log(charSet); // Set(4) { h, e, l, o }基本操作就四个方法加一个属性set.add(6); // 添加元素返回 Set 自身支持链式调用 set.has(6); // 判断是否存在返回布尔值 set.delete(6); // 删除指定元素返回布尔值表示是否删除成功 set.clear(); // 清空整个 Set set.size; // 元素个数遍历方式我推荐两个forEach和for...of。需要注意forEach的回调参数是(value, value, set)前两个参数都是同一个值这是因为 Set 没有键的概念为了保证和 Map 的forEach参数结构一致才这么设计。很多人第一次写的时候会疑惑第二个参数是什么其实就没啥用。set.forEach((value) { console.log(value); }); for (const value of set) { console.log(value); }2.2 Map 的基本操作任意键的映射Map 的创建方式比 Set 灵活一些。可以直接传一个二维数组每个子数组的第一个元素作为键第二个作为值。const map new Map([ [name, 某开发者], [age, 25], ]); console.log(map); // Map(2) { name 某开发者, age 25 }也可以用set方法一个一个地加set同样支持链式调用。const map new Map(); map.set(name, 某开发者); map.set(1, number key); // 数字作为键 map.set(obj, object key); // 对象作为键核心方法map.get(name); // 根据键获取值不存在返回 undefined map.has(name); // 判断是否存在某个键 map.delete(name); // 删除键值对返回布尔值 map.clear(); // 清空所有键值对 map.size; // 键值对数量Map 的遍历和 Set 类似但回调参数是(value, key, map)值在前键在后写的时候要注意别搞反了。for...of遍历出来的每一项是[key, value]的二维数组配合解构赋值很方便。map.forEach((value, key) { console.log(key: ${key}, value: ${value}); }); for (const [key, value] of map) { console.log(key: ${key}, value: ${value}); }2.3 一个关键选择Map 和 Object 到底差在哪我反复强调 Map 的键可以是任意类型但很多人还是不太理解这点到底意味着什么。我用一个实际场景说明。假设你要缓存一组表格组件的实例用配置对象的某个唯一标识作为索引。这种标识有可能是数字 ID有可能是一个对象引用。用对象做这个缓存你得先把键转成字符串比如cache[id_ id]而且对象引用作为键时会被强制转成[object Object]所有对象全都变成同一个键这就是翻车现场。用 Mapconst componentCache new Map(); function cacheComponent(id, instance) { if (typeof id object) { componentCache.set(id, instance); } else { componentCache.set(id_${id}, instance); } }数字 ID 和对象引用都能作为键不需要额外的序列化逻辑。而且 Map 在频繁增删键值对的时候性能表现更稳定因为对象删除属性的delete操作在引擎里是个相对昂贵的操作Map 的delete方法就专门针对这个场景做了优化。3. 实操过程与核心环节实现3.1 数组去重与从 Set 还原数组数组去重是 Set 最出名的应用代码只有一行const arr [1, 2, 3, 3, 4, 5, 4, 6]; const uniqueArr [...new Set(arr)]; console.log(uniqueArr); // [1, 2, 3, 4, 5, 6]还原数组用展开运算符...或者Array.from两种方式都行。展开运算符更简洁Array.from在需要传入映射函数时更方便const setToArr1 [...set]; const setToArr2 Array.from(set, (item) item * 2);需要提醒的是如果你要去重的数组里都是基本类型这个方法简单可靠。但如果是对象数组new Set()去重会失败因为每个对象字面量都是不同的引用。这时候你可能需要根据对象上某个唯一字段来去重比如idconst userList [ { id: 1, name: A同学 }, { id: 2, name: B同学 }, { id: 1, name: A同学 }, ]; const seen new Set(); const uniqueUsers userList.filter((user) { if (seen.has(user.id)) { return false; } seen.add(user.id); return true; });3.2 集合运算交集、并集、差集的实现集合论里的三个基本运算用 Set 实现非常直观。并集就是把两个 Set 合并后去重利用展开运算符和new Set一次搞定const setA new Set([1, 2, 3, 4]); const setB new Set([3, 4, 5, 6]); const union new Set([...setA, ...setB]); console.log(union); // Set(6) { 1, 2, 3, 4, 5, 6 }交集是过滤出同时在两个集合中的元素const intersection new Set([...setA].filter((item) setB.has(item))); console.log(intersection); // Set(2) { 3, 4 }差集是拿出属于 A 但不属于 B 的元素const difference new Set([...setA].filter((item) !setB.has(item))); console.log(difference); // Set(2) { 1, 2 }这几种运算的核心思想都一样先转数组用filter配合has做条件筛选再包回new Set。我建议你把这三个工具函数封装到项目的工具文件里后面遇到权限比对、标签匹配这种业务需求直接调用。3.3 用 Map 实现一个简易的缓存系统Map 做缓存的关键优势在于键不用转字符串直接使用原始引用或者复合键。这里演示一个根据传入参数缓存计算结果的典型场景。function memoize(fn) { const cache new Map(); return function (...args) { const key args.length 0 ? no-args : args.map((arg) { if (typeof arg object) { // 对象参数用引用本身作为键的一部分 return arg; } return String(arg); }); const cacheKey args.length 1 ? key[0] || no-args : JSON.stringify(key); if (cache.has(cacheKey)) { console.log(命中缓存:, cacheKey); return cache.get(cacheKey); } const result fn.apply(this, args); cache.set(cacheKey, result); return result; }; }更简化的版本如果你的参数都是基本类型直接用一个数组作为键组合const { cache } useCacheStore(); function getConfig(type, version) { const cacheKey ${type}_${version}; if (cache.has(cacheKey)) { return cache.get(cacheKey); } const config fetchConfigFromServer(type, version); cache.set(cacheKey, config); return config; }缓存组件实例、缓存计算结果、缓存对象间的映射关系这些都是 Map 的主场。很多人一开始习惯用对象做缓存等遇到键为对象的情况时再改造成 Map这个经历我也有过。其实一开始就该用 Map省去后面重构的麻烦。3.4 用 Map 解决频次统计和分组问题统计字符串中每个字符出现的次数用 Map 写起来非常清晰function countChars(str) { const counter new Map(); for (const char of str) { counter.set(char, (counter.get(char) || 0) 1); } return counter; } const result countChars(hello world); console.log(result); // Map(8) { h 1, e 1, l 3, o 2, 1, w 1, r 1, d 1 }关键逻辑就一行counter.set(char, (counter.get(char) || 0) 1)。get取不到值的时候返回undefinedundefined || 0得到0再 1就是第一次计数的结果。这种写法比先判断是否存在再分别赋值要干净得多也是我在项目里非常推荐的写法。同理按某个属性对数组分组也是同样思路function groupBy(arr, keyFn) { const groups new Map(); for (const item of arr) { const key keyFn(item); if (!groups.has(key)) { groups.set(key, []); } groups.get(key).push(item); } return groups; } const people [ { name: A同学, age: 20 }, { name: B同学, age: 20 }, { name: C同学, age: 22 }, ]; const byAge groupBy(people, (person) person.age); console.log(byAge); // Map(2) { 20 [ { name: A同学, age: 20 }, { name: B同学, age: 20 } ], 22 [ { name: C同学, age: 22 } ] }4. 常见问题与排查技巧实录4.1 引用类型去重失效这是使用 Set 最常见的坑。new Set([{ id: 1 }, { id: 1 }])得到两个元素因为这是两个不同的对象引用。Set 的比较是基于引用而非内容结构这一点初学者特别容易忽略。解决方案还是要根据业务唯一标识做判断用额外的 Set 存唯一标识再用 filter 过滤原始数组。我在 3.1 节里给的例子就是标准解法。如果你确实想按照对象的内容去重那就必须做深度比较但这是一个成本很高的操作不建议这样做。根据我的实操经验大多数业务场景都有一个明确的唯一键比如后端返回的数据都会有id字段。按id去重是最稳妥的。只有在你拿到的数据确实没有任何唯一字段时才需要考虑JSON.stringify之后再比对但这种方法受键值顺序影响两个字段顺序不同的对象会被误判为不同项。4.2 Map 的键是引用类型时get 不到值这个坑也很典型。你往 Map 里放了一个对象作为键然后用另一个长得一模一样的对象去get结果返回undefined。const map new Map(); map.set({ id: 1 }, 第一个对象); console.log(map.get({ id: 1 })); // undefined原因和上面一样每个对象字面量都是新的引用Map 用引用地址来匹配键。如果你想用对象的某个属性值作为键就应该把属性值本身存为键而不是整个对象const map new Map(); map.set(1, 第一个对象); console.log(map.get(1)); // 第一个对象这个坑在写缓存逻辑的时候特别容易踩。我的经验是Map 适合用对象本身作为键的场景比如组件实例缓存但不适合用对象内容作为键的场景。后者要么找对象里的唯一标识要么自己实现内容到字符串的序列化。4.3 遍历顺序与 for...of 的细节差异Set 和 Map 的遍历顺序是插入顺序但是有几个细节值得注意。第一Map 的keys()、values()、entries()方法会返回迭代器对象这三个迭代器和 Map 本身都以相同的顺序遍历数据。而且它们在遍历过程中如果 Map 被修改了行为是不确定的所以不要在遍历过程中随意增删元素。第二for...of遍历 Map 得到的是[key, value]不是分开的两个值。如果你想只拿值需要手动解构或者用map.values()迭代器。我自己经常看到刚上手的人写for (const [key, value] of map) { // 这是正确的 }但写过一阵子之后有人会忘了解构写成for (const item of map)然后用item[0]拿键、item[1]拿值虽然也能跑但代码可读性变差了。4.4 与 JSON 序列化的兼容问题Map 和 Set 都不能直接用JSON.stringify正确序列化。试一下const map new Map([[name, 某开发者]]); const set new Set([1, 2, 3]); console.log(JSON.stringify(map)); // {} console.log(JSON.stringify(set)); // {}结果都是{}数据直接丢失。这个问题在前后端交互时很致命后端不会认识一个{}。常规方案是序列化时手动转换// Map 转对象 const objFromMap Object.fromEntries(map); // Set 转数组 const arrFromSet Array.from(set); // 反序列化 const mapFromObj new Map(Object.entries(objFromMap)); const setFromArr new Set(arrFromSet);如果你的数据结构层级嵌套较深建议在项目里封装toJSON和fromJSON工具函数统一处理这些转换。我之前做过一个数据上报模块所有上报字段都在 Map 里存着因为上传前忘记整体转成普通对象前端上送的 payload 里全是{}排查了一个多小时才定位到问题。那次经历之后我养成了一个习惯任何跨越边界网络传输、本地存储的数据都要先序列化成纯 JSON 对象。4.5 性能与内存什么时候不用 Set/Map虽然 Set 和 Map 在多数场景下表现很好但也不是没有代价。当数据量极大时Map 比普通对象占用更多内存因为 Map 需要维护更多内部结构来支持任意键类型和稳定的遍历顺序。如果你的映射键全部是字符串且没有频繁增删需求用普通对象就够了。WeakMap 和 WeakSet 则是另一个话题。它们只接受对象作为键/值并且对键/值持有弱引用不阻止垃圾回收。这意味着当你不再引用某个对象时它在 WeakMap 中对应的记录会被自动回收避免内存泄漏。典型使用场景是给 DOM 节点关联数据或者缓存只有生命周期内才需要留存的数据。// 弱引用示例 const weakMap new WeakMap(); let obj { id: 1 }; weakMap.set(obj, some data); obj null; // 此时原对象可以被垃圾回收weakMap 中的记录也会随之消失不过 WeakMap 不可遍历也没有 size 属性这是它的设计局限。不能用它做需要完整遍历的缓存。4.6 常见坑点速查表场景常见错误正确做法数组去重对象数组直接用new Set去重按唯一字段用额外 Set 过滤Map 取键用内容相同但引用不同的新对象get用原引用或用唯一标识当键序列化直接JSON.stringifyMap/Set先转Object.fromEntries/Array.fromNaN 判断以为NaN无法作为 Set/Map 的键Set/Map 用同值零算法NaN等于自身遍历中修改在forEach里直接删改遍历完再操作或拷贝后操作对象做键用字符串化后的对象当键用对象引用本身或提取唯一标识5. 用 Set 和 Map 重构真实代码从混乱到清晰5.1 场景一权限标签判断逻辑假设后端返回了一批权限标签前端需要判断当前用户是否拥有某个标签。旧代码可能是这样// 旧代码数组查找 const userTags [admin, editor, vip]; function hasTag(tag) { return userTags.includes(tag); } // 如果有几十项上百项呢改成 Set 之后const userTagSet new Set([admin, editor, vip]); function hasTag(tag) { return userTagSet.has(tag); }语义更加明确has的意图一眼就能看出来。频繁判断时has的时间复杂度是 O(1)而includes是 O(n)。5.2 场景二表单错误信息收集表单提交时需要收集所有校验失败字段的错误信息但同一个字段可能被多个规则触发需要去重并把错误信息合并。const errorMap new Map(); function addError(field, message) { if (errorMap.has(field)) { errorMap.get(field).push(message); } else { errorMap.set(field, [message]); } } addError(name, 姓名不能为空); addError(name, 姓名不能超过20个字符); addError(age, 年龄必须大于0); console.log(Object.fromEntries(errorMap)); // { name: [姓名不能为空, 姓名不能超过20个字符], age: [年龄必须大于0] }这个模式我在实际表单组件里用了很多次。用 Map 的好处是不用像对象那样担心字段名冲突而且统计、分组逻辑都在一个容器里完成了。5.3 场景三最近浏览记录的维护你要维护一个最近浏览的商品列表要求不能重复最多保留 10 条新浏览的排前面。数组做这个需求需要先判断再unshift再slice而 Set 加一点小心思就能做得比较自然const recent new Set(); function addRecentlyViewed(itemId) { recent.delete(itemId); // 先删除旧记录 recent.add(itemId); // 再添加到末尾最新 if (recent.size 10) { // 删除最旧的一个即第一个元素 const oldest recent.values().next().value; recent.delete(oldest); } return [...recent].reverse(); // 输出时按最新在前 }注意这里我用了一个小技巧删除之后再添加如果项已存在它能被移到 Set 的末尾。因为 Set 保持插入顺序先删后加实现了把已存在的元素挪到最新位置的效果。recent.values().next().value能拿到第一个元素这是插入时间最早的。这个技巧是我在做相似浏览历史功能时总结出来的实测效率很高。6. 结语与个人实操建议说实话Set Map这个题目看起来简单但真要在项目里用得好还是得踩几回坑才能记住哪些场景适合用它们、哪些场景不适合。我的一些体会是第一先把 API 手写一遍。不要停留在看了觉得会了的层面新建一个空白页面用node命令行或者浏览器控制台把add、has、delete、values()、entries()这些方法全部跑一遍特别是注意返回值是什么、参数顺序是什么。很多隐蔽 bug 都是参数顺序搞反引起的比如 Map 的forEach是(value, key)而不是(key, value)。第二在项目里找 3 个现有模块做迁移改造。找那种用数组includes做频繁判断的或者用对象做缓存的改成 Set/Map 实现然后跑一遍原有的测试用例。这种重构练习比读十篇教程都有用。第三主动避开几个高频坑。对象引用当键、JSON 序列化、遍历中修改容器、对象数组直接去重——这四类问题我再三在文章里强调。建议把这篇文章里的速查表截图存到自己的笔记里写代码前翻一眼能省很多排查时间。个人来说我现在写新代码的默认选择已经变了遇到某个值是否存在的需求第一反应是 Set遇到通过键快速找值的需求第一反应是 Map只有需要序列化传输的纯数据对象才用普通对象。这种习惯不是看文档养成的是真的在项目里吃过亏之后换来的。希望这篇能把你在 Set 和 Map 上的疑问解决得差不多了。如果实操中还有其他奇怪的表现欢迎带着具体代码来讨论。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询