ES6 Set 实战:从数据去重到性能优化,彻底掌握集合操作 📅 发布时间:2026/9/7 15:56:05 👁 浏览次数: 开头先说个我自己的感受Set 大概是 ES6 新增的几个数据结构里被“浪费”得最严重的一个。翻看大多数教程讲到 Set 就是两句——new Set(arr)去重然后[...new Set(arr)]转回数组然后就没了。但我在真实项目里离不开 Set恰恰不是因为去重而是因为一次全量数据匹配把页面卡到白屏换成Set.has()以后从几秒降到了毫秒级。从那以后我才意识到Set 是一个需要认真对待的数据结构它解决的是“某个值在不在集合里”这件事这件事的频率远超大多数人想象。这篇文章不打算重复文档内容而是结合我自己用 Set 的经验把知识点、使用场景、边界情况和能直接抄进项目的案例一次讲透适合对 ES6 有基础认知、想在项目里把 Set 真正用对的开发者。1. Set 不是“加强版数组”先搞懂它的比较逻辑很多新手第一次用 Set 就踩坑根源就在于没搞清楚 Set 判断“重复”到底靠什么规则。它既不是也不是深比较而是一套叫SameValueZero的算法。不理解这套规则后面所有“去重”“判存在”的代码都可能出问题。1.1 SameValueZeroNaN、-0 和对象的比较规则SameValueZero 和严格等于最大的区别有两点。第一NaN 在 Set 中被认为是等于自身的。new Set([NaN, NaN])的 size 是 1但在下NaN NaN是 false。这意味着你可以放心用 Set 判断“是否处理过这个 NaN 值”不会出现数组includes在某些场景下的尴尬。第二0 和 -0 被视为同一个值。new Set([0, -0])的 size 同样是 1。这两点在实际开发中一般不会故意利用但你得知道否则排查 bug 时会对着size的结果怀疑人生。真正容易踩坑的是第三点对象比较的是引用不是结构。看下面这段代码const set new Set(); set.add({ id: 1 }); set.add({ id: 1 }); console.log(set.size); // 2两个{ id: 1 }内容完全一样但因为是两个不同的对象引用Set 认为它们是两个值。想象一下你用 Set 给接口返回的列表去重列表里每条数据的 id 相同但对象是新创建的去重结果会完全失效。这不是 Set 的 bug而是“引用比较”的必然结果要按内容去重就必须自己先把对象转成可比较的字符串或使用唯一标识。顺带提一点Set 可以存 Symbol而且每个 Symbol 都是唯一的new Set([Symbol(a), Symbol(a)])的 size 是 2。基本数据类型里除了 NaN 和 ±0 的特殊处理外其余都按严格等于规则判断。1.2 高频 API 的返回值与链式调用习惯Set 的方法不多但很多人在小细节上吃亏。add方法返回的是 Set 本身所以可以链式调用const set new Set(); set.add(a).add(b).add(c);delete方法返回布尔值表示是否真的删除了某个元素。这个返回值很有用比如你在做“取消收藏”操作时可以先has判断再delete也可以直接delete后用返回值决定要不要额外处理。很多人忽略了这个返回值其实它是判断“元素是否真的存在并被移除”的最直接信号。再看has方法这才是 Set 的灵魂。数组判断成员是否存在用indexOf或includes底层是线性遍历时间复杂度 O(n)Set 的has基于哈希结构平均时间复杂度 O(1)。数据量小的时候感觉不到差异数据量上到几千几万差距就是卡顿和流畅的分水岭。size属性等同于数组的length表示集合中元素的个数。注意它是属性而非方法写代码时不要顺手加括号。clear()方法直接清空所有元素适合做“重置所有筛选条件”这类操作。2. 遍历顺序和循环删除Set 的迭代机制比想象中好用Set 的遍历有一个隐藏优势它按插入顺序迭代。这意味着你往 Set 里添加元素的顺序就是之后遍历输出的顺序行为完全可预期。在很多场景下这个特性让 Set 可以替代数组作为“有序集合”使用同时又自动附带唯一性约束。2.1 插入顺序遍历和 keys/values 一致的背后设计Set 提供三种迭代器方法keys()、values()和entries()。有意思的是keys()和values()返回的内容完全一样——因为 Set 没有键的概念只有值。entries()返回的则是[value, value]这样的二元组。第一次看到这种设计的人可能觉得冗余但稍微想一下就知道这是为了和 Map 保持 API 结构对称。Map 的keys()返回键集合、values()返回值集合、entries()返回键值对集合Set 为了统一处理逻辑把“值”同时当作“键”和“值”来暴露。实际开发中你基本只会用到values()或直接for...of遍历。const set new Set([a, b, c]); for (const value of set) { console.log(value); // a, b, c } for (const entry of set.entries()) { console.log(entry); // [a, a], [b, b], [c, c] }这个“插入顺序即迭代顺序”的特性在业务里很有用。比如你维护一个用户最近触发的事件集合不需要额外排序遍历时自然就是按发生顺序输出同时又不会重复记录同一事件。2.2 Set.forEach 与 for...of 的选择以及循环中安全删除Set 也有forEach方法但回调函数的参数顺序和数组是反的。数组的forEach((value, index, array) {})而 Set 的forEach((value, value2, set) {})——第二个参数其实也是值不是索引。这个反直觉的设计让很多人第一次写 Set.forEach 时取错了参数。set.forEach((value, value2, s) { console.log(value value2); // 永远为 true });日常操作我更推荐for...of直观且能配合break提前终止遍历而forEach做不到提前退出。真正要重点说的是循环中删除元素。Set 的迭代器是实时的在for...of中直接删除“当前正在遍历的值”是安全的const sessions new Set([token_a, token_b, token_c]); for (const token of sessions) { if (needRemove(token)) { sessions.delete(token); // 安全迭代器会自动调整 } }但如果你在循环体里删除了“还没遍历到的值”就可能导致跳过某些元素。最稳妥的做法是先把要删除的值收集到一个临时数组循环结束后统一删除。const toDelete []; for (const token of sessions) { if (expired(token)) toDelete.push(token); } toDelete.forEach(token sessions.delete(token));这个习惯也是我从真实项目中踩坑换来的。当时写了一个定时任务要清理超时的会话 ID直接在for...of里删除了尚未遍历到的项结果每轮都漏掉一个。后来统一收集再删除问题立刻消失。3. 数据操作实战并集、交集、差集与去重边界集合论里的三个基本运算——并集、交集、差集——在 Set 面前都变得无比简洁。你需要记住一个关键认知先转数组用 filter再用 Set 包回去这是标准套路基本上是 Set 数据操作的核心模式。3.1 三个集合运算的简洁写法假设有两个 Setconst setA new Set([1, 2, 3, 4]); const setB new Set([3, 4, 5, 6]);并集、交集、差集分别这样写// 并集 const union new Set([...setA, ...setB]); // 结果: Set {1, 2, 3, 4, 5, 6} // 交集 const intersect new Set([...setA].filter(item setB.has(item))); // 结果: Set {3, 4} // 差集在 A 中但不在 B 中 const diff new Set([...setA].filter(item !setB.has(item))); // 结果: Set {1, 2}并集写法很好理解展开两个 Set 组成数组new Set自动去重。交集和差集的本质是“遍历其中一个集合用另一个集合的has过滤”核心就是 O(n) 的遍历加 O(1) 的has判断整体效率远高于数组的filter includes那会变成 O(n*m)。3.2 对象去重与深拷贝中的循环引用WeakSet 方案上面提到对象按引用比较但实际业务中我们经常需要按某个字段去重。假设你有一个订单列表每个订单都有orderId你想去掉重复订单const seen new Set(); const uniqueOrders orders.filter(order { if (seen.has(order.orderId)) return false; seen.add(order.orderId); return true; });这里用 Set 保存“已经出现过的订单 ID”再配合filter实现按字段去重。这个模式很常用建议记下来。另一个和 Set 强相关、但很多人没意识到的地方是深拷贝中的循环引用检测。写过深拷贝函数的人都知道如果对象自身循环引用比如obj.self obj不加以处理会无限递归导致栈溢出。处理方式之一就是用 WeakSet 记录已经拷贝过的对象function deepClone(value, cache new WeakSet()) { if (value null || typeof value ! object) return value; if (cache.has(value)) return value; cache.add(value); const result Array.isArray(value) ? [] : {}; for (const key of Object.keys(value)) { result[key] deepClone(value[key], cache); } return result; }注意这里用的是WeakSet而不是 Set。为什么要用 WeakSet因为深拷贝是一次性操作你希望拷贝完成后那个记录“已处理对象”的集合能被垃圾回收。Set 会强引用对象导致对象无法被释放WeakSet 是弱引用不影响垃圾回收。这是 WeakSet 最典型的实战场景比“WeakSet 只能存对象”这个零散知识点直观得多。3.3 补集思想在批量任务里的应用差集还有一个很实用的变体叫“补集”。假设你有一个包含全部用户 ID 的数组还有一个记录“已完成任务”的用户 ID 数组要求找出未完成任务的用户。常规写法可能是const uncompleted allUserIds.filter(id !completedIds.includes(id));当两个数组长度都很大时includes的线性查找会让这段代码变成性能灾难。换成 Setconst completedSet new Set(completedIds); const uncompleted allUserIds.filter(id !completedSet.has(id));这个场景我在做运营后台的“批量任务状态同步”时反复用到拉取全量用户列表和已完成集合求差集得到待处理列表。数据量几千时差别不明显到几万以上就能明显感觉到接口响应从“可接受”变成“白屏”的区别。4. Set 与 Array/Map/Object 的选型与性能取舍做技术选型时我有个习惯遇到“数据集合”就先想清楚三个问题——顺序重要吗允许重复吗需要键值关联吗这三个问题的答案基本能决定该用数组、Set 还是 Map。4.1 什么时候该用 Set 而不是 Array 或 Map数据特点推荐结构理由有序、可重复、按索引访问Array数组的核心语义唯一性、只关心“在不在”Set天然去重O(1) 查找存在明确的键值映射关系Map语义清晰避免对象原型链污染纯字符串键的简单字典Object序列化方便但注意原型链风险实际开发中我最常见的误用是把 Object 当 Set 用// 不推荐 const seen {}; seen[someKey] true; if (seen[someKey]) { ... }这样做的隐患有三个对象 key 会被自动转成字符串如果 key 是__proto__这类特殊值可能引发原型链问题遍历时还要用Object.keys处理。Set 把这些边界全部挡掉了存什么就是什么判断就是has简单直接。什么时候用 Map 而不用 Set当你需要“值 → 另一个值”的关联时。比如“订单 ID → 订单详情”这是典型的 Map。但如果只是“哪些订单 ID 被选中了”Set 是更准确的表达——你不需要把选中 ID 映射到别的东西你只需要知道“这个 ID 在不在选中集合里”。4.2 从 O(n*m) 到 O(n)一次全量匹配的性能优化记录我最早真正意识到 Set 价值是在一次订单列表优化里。业务场景前端拿到 1000 条订单后端同时返回一个“已退款订单 ID 列表”大概 300 个需要在页面上给已退款的订单打标。最初代码是这样写的const refundOrderIds [1001, 1002, ...]; // 300 个 const orders [...]; // 1000 条 const markedOrders orders.map(order ({ ...order, isRefunded: refundOrderIds.includes(order.id) }));看起来没毛病但这里发生了 1000 × 300 30 万次比较因为includes是线性查找。一旦订单量上升到几千、退款 ID 列表也上升到几千就是几百万次比较再加上数据结构复杂后每次比较的消耗页面明显会卡。优化后只需要两行变化const refundIdSet new Set(refundOrderIds); const markedOrders orders.map(order ({ ...order, isRefunded: refundIdSet.has(order.id) }));Set.has是 O(1)总复杂度从 O(n*m) 降到 O(nm)。那次优化之后接口数据量翻了三倍处理速度反而快了几十倍。以后凡是看到“一个列表的每条数据都要去另一个列表里查存在性”的代码我都会条件反射地想到把查询列表转成 Set。4.3 WeakSet 的内存特性与 Set 引用回收问题Set 有一个容易被忽视的副作用它强引用所存的值。如果 Set 里存了对象即使这个对象在业务上已经不被需要了只要 Set 还在引用它垃圾回收就无法释放这块内存。在长期运行的单页应用里这可能造成内存缓慢上涨。WeakSet 就是为这个问题设计的。它只接受对象并且是弱引用——不阻止垃圾回收。代价是 WeakSet 不可遍历、没有size属性、也没有clear方法。特性SetWeakSet可存基本类型是否只能对象可遍历是否有 size是否引用方式强引用弱引用典型场景去重、集合运算标记对象状态、循环引用检测一个常见的 WeakSet 场景是处理 DOM 节点。假设你要给页面上所有.item节点绑定一次性事件但又担心某个节点被重复处理const processedNodes new WeakSet(); document.querySelectorAll(.item).forEach(node { if (!processedNodes.has(node)) { initItem(node); processedNodes.add(node); } });因为 WeakSet 弱引用节点节点从 DOM 中移除后可以正常被回收你不会在页面切换后积累一堆无法释放的节点引用。这种场景如果用普通 Set就会造成“DOM 节点虽然被移除了但 Set 里还拽着它不放”的内存泄漏隐患。5. 能直接抄进项目的三个 Set 落地案例讲完原理和边界分享三个我在真实业务里用 Set 实现过、可以直接拿走的案例覆盖权限、交互状态和异步轮询三类常见需求。5.1 权限点集合角色权限的并集与校验后台管理系统经常有多个角色每个角色对应一组权限码。判断当前用户能不能做某个操作常规做法是先把所有角色的权限码合并、去重再判断。const rolePermissions { admin: [order:view, order:export, user:edit], operator: [order:view, order:comment] }; // 合并当前用户所有角色的权限并去重 const userPerms new Set([ ...rolePermissions.admin, ...rolePermissions.operator ]); // 权限校验 function can(code) { return userPerms.has(code); } console.log(can(order:export)); // true console.log(can(user:edit)); // true console.log(can(order:delete)); // false权限码天然需要“唯一性”和“快速判断”Set 比数组includes性能更好比 Object 可读性更强。而且后续加权限可以直接userPerms.add(new:perm)撤销权限用userPerms.delete(old:perm)都很自然。5.2 多选组件的选中态管理表格多选是后台系统最常见场景之一。用 Set 管理选中项的 ID 方便得多toggle天然对应hasdelete/addsize直接就是选中数量clear搞定取消全选。const selectedIds new Set(); function toggleSelect(id) { if (selectedIds.has(id)) { selectedIds.delete(id); } else { selectedIds.add(id); } } function toggleAll(ids) { const allChecked ids.every(id selectedIds.has(id)); if (allChecked) { ids.forEach(id selectedIds.delete(id)); } else { ids.forEach(id selectedIds.add(id)); } } function getSelectedCount() { return selectedIds.size; } function clearSelection() { selectedIds.clear(); }这套写法配合 Vue 或 React 时有一个注意点如果你用了响应式框架更新 Set 时不要直接修改原 Set而是创建一个新 Set否则可能不会触发视图更新。比如 React 中应该// 不推荐 selectedIds.add(id); // 推荐 const nextSelectedIds new Set(selectedIds); nextSelectedIds.add(id);这一点很容易踩坑我见过不止一次“数据变了页面没反应”的问题排查半天发现是原地修改了 Set 而框架追踪不到变化。5.3 消息中心增量拉取的已读过滤轮询消息通知时每次接口返回最新的若干条消息其中可能包含已经处理过的旧消息。用 Set 记录“已处理消息 ID”来做过滤逻辑非常清爽const processedMsgIds new Set(); async function pollMessages() { const latestMessages await fetchLatestMessages(); // 只处理新消息 const newMessages latestMessages.filter(msg { return !processedMsgIds.has(msg.id); }); // 标记为已处理 newMessages.forEach(msg { processedMsgIds.add(msg.id); renderMessage(msg); }); // 可选的增量清理当集合过大时只保留最近 N 条 ID if (processedMsgIds.size 5000) { const temp new Set([...processedMsgIds].slice(-1000)); processedMsgIds.clear(); temp.forEach(id processedMsgIds.add(id)); } }这个场景用 Set 的最大收益是即使接口本地缓存了旧消息也不会重复渲染同时has判断足够快不会因为消息数量增长而拖慢轮询。最后那段“集合过大时清理”的代码是实战里加上的因为浏览器内存不是无限大的Set 里堆几万条无意义的旧 ID 也是一种浪费。写到这里想分享一个个人体会Set 最打动我的不是某个 API而是它逼着你在写代码前思考“这个值是否曾经出现过”。以前写数组时这种“存在性判断”是散落在indexOf、find、includes、some里的隐式逻辑现在换成 Set整个意图变得非常明确。我甚至养成了一个习惯——在utils里封装了几个通用的集合运算函数把并集、交集、差集固定下来团队里其他人也能直接复用。最后再给一个小技巧当你需要把 Set 存进localStorage时记得先转成数组再序列化因为浏览器存储不认 Set 结构取出来以后再用new Set()包回去这套转换成本很低但能避免不少序列化报错。