ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JS数组去重百万级数据性能实测:Set为何碾压filter+indexOf

JS数组去重百万级数据性能实测:Set为何碾压filter+indexOf 处理 JS 数组去重几乎是每个前端和 Node 开发都绕不开的事。平时数据量小怎么写都不卡可一旦数据量拉到百万级不同去重方式之间的差距会从“都能用”变成“一个几十毫秒、一个卡死页面”。我之前在优化一个数据清洗脚本时就因为顺手用了filter indexOf去重 100 万行数据跑了将近十分钟一度以为程序死循环了。后来换成 Set几十毫秒出结果。这个反差让我彻底明白js 去重方式不是随手挑一个就行在百万级数据量级下选错方式是真的会出事。这篇内容主要讲两件事一是各种主流 js 去重方式的底层原理和时间复杂度二是我在百万级数据下实际跑出来的结果以及不同场景下到底该怎么选。无论你是刚接触前端不久还是在搞埋点日志、数据清洗这份对比和踩坑记录都值得收藏。1. 去重方式的底层差异为什么 Set 能把百万级数据按在地上摩擦1.1 先说结论查重思路决定时间复杂度常见的去重方式有 Set、Map、filter indexOf、对象键值法、排序去重、双重循环。表面上看都是“把重复元素干掉”但它们判断重复的方式完全不同性能差距也由此而来。Set 和 Map 底层都是哈希表结构。插入一个元素时能平均在 O(1) 时间内完成“这个值我之前有没有见过”的判断。循环 n 个元素总时间复杂度就是 O(n)。filter indexOf 则完全不同每次 indexOf 都要从数组头开始扫描判断一个元素需要遍历一遍已有数组内层循环套外层循环整体就是 O(n²)。百万级数据下n 1,000,000O(n²) 意味着大约 10^12 次操作而 O(n) 只有一百万次理论差距就是百万倍。即便哈希表有常数开销这个数量级差距也已经决定胜负了。1.2 百万级数据量到底放大了什么100 万条数据对浏览器来说已经不是小数目。一个纯数字的数组按 Number 类型每个 8 字节算大约占用 8MB 左右如果存的是字符串或者对象内存占用会成倍上涨。这个时候如果去重方法还要创建大量中间数组、反复深拷贝或者出现嵌套循环内存和 GC 压力会跟着爆炸不是光慢那么简单有可能直接把标签页搞崩。我用一个生活化类比indexOf 去重就像是新来一个人要挨个问前面所有人“你们见过这个人吗”每来一个都问一遍人越多询问次数膨胀得越离谱。而 Set 是给每个值一个独立柜子看到新名字先打开对应柜子看看有没有人有就跳过没有就登记。柜子查找是常数时间即便 100 万人也只需要 100 万次开柜子。差距就是这么来的。1.3 核心衡量指标时间复杂度、内存、可读性选去重方式不能只看速度还要兼顾内存和可读性。时间复杂度在百万级数据下基本决定一切Set、Map 这类哈希方案统治级领先。内存方面Set/Map 本身要有额外哈希表开销但相比 O(n²) 方案不断创建临时数组和频繁调用栈通常还是更划算。可读性上Set 一行代码最直观Map 做对象数组按字段去重时逻辑也很清晰。我一般按这个顺序权衡先看会不会修改原数组再看时间复杂度和内存最后考虑代码给别人看的时候要不要解释半天。百万级数据场景下时间和内存优先级最高代码稍微绕一点加注释就行但性能不行就真的不行。2. 百万级数据实测搭一个能复现的基准测试2.1 测试环境与数据准备先说测试环境Node.js 18.16.0M1 MacBook Pro16G 内存。浏览器端结论类似但不同 JS 引擎对 Set 的优化有差异数字不会完全一致。想复现的话把代码粘到 Node 环境直接跑就行。数据准备很关键。不能用固定顺序的数组如果数据恰好有序排序去重会占大便宜。为了模拟真实混排数据我用随机数生成 100 万条数组取值范围 0 到 499999这样重复率不会太低也不会全部重复。生成代码很简单const arr Array.from({ length: 1000000 }, () Math.floor(Math.random() * 500000));这行代码会在内存里生成约 100 万个随机数。取值范围 50 万理论上随机生成 100 万个位置去重后大约 43 万左右重复率约 57%。真实业务里的脏数据通常就是这个量级有重复但不至于全是重复。2.2 测试代码与运行方式因为 Set 这类方案耗时可能只有几十毫秒而 filter indexOf 直接跑 100 万可能等到天荒地老所以我把所有方案放在同一份脚本里用 performance.now 计时多跑几次取中位数避免单次抖动。每个方案都传入同一个 arr但方法内部自己拷贝需要处理的数据避免前面方法改了原数组影响后面的结果。const { performance } require(perf_hooks); function test(name, fn, data) { const start performance.now(); const result fn(data); const end performance.now(); console.log(name, (end - start).toFixed(2) ms, 去重后长度:, result.length); } // filterindexOf 在 100 万数据下太慢单独准备一个 5 万子集 const smallArr arr.slice(0, 50000); test(Set去重, (data) [...new Set(data)], arr); test(Map去重, (data) [...new Map(data.map((item) [item, item])).keys()], arr); test(对象键值去重, (data) { const obj {}; return data.filter((item) { if (obj[item]) return false; obj[item] true; return true; }); }, arr); test(排序相邻去重, (data) { const sorted [...data].sort((a, b) a - b); return sorted.filter((item, i) i 0 || item ! sorted[i - 1]); }, arr); test(filterindexOf, (data) data.filter((item, index) data.indexOf(item) index), smallArr);我在测试时特意把 filter indexOf 单独放到 5 万条数据上不直接跑 100 万因为 100 万条下它可能要等好几分钟脚本看起来就像卡死了。这也是踩坑之后学到的教训基准测试也要先评估被测试方法能不能扛住测试规模不能拍脑袋直接全部跑 100 万。2.3 从数据上理解为什么差了几个数量级我这边跑出来的结果大致如下具体数字和运行环境有关但数量级差距不会变去重方式数据量耗时去重结果长度Set100 万约 25~35ms约 43 万Map100 万约 35~45ms约 43 万对象键值100 万约 50~70ms约 43 万排序相邻100 万约 120~180ms约 43 万filterindexOf5 万约 1500~2000ms约 4.8 万注意表格里的 filter indexOf 只跑了 5 万条数据。它的复杂度是 O(n²)数据量从 5 万涨到 100 万变成了 20 倍耗时理论上要变成 400 倍。按 1.5 秒估算到 100 万就是 600 秒约 10 分钟。这个数量级基本是灾难不是优化能救回来的。3. 五种主流去重方式逐个拆解与结果对比3.1 Set 去重一行代码为什么最快Set 去重是现在最常用的写法核心就是利用 Set 存唯一值的特性const unique [...new Set(arr)]; // 或者 Array.from(new Set(arr))Set 底层使用哈希表插入时计算哈希值定位到桶冲突概率小的话平均 O(1) 完成插入。整个过程只需要一次循环不需要额外的 indexOf 扫描内存多出一份 Set 结构的大小。字符串、数字、布尔值这些原始类型都可以直接存NaN 也能正确去重这是它相比 indexOf 方案的一个隐藏优势。我实际测下来100 万数据 Set 去重基本都在几十毫秒内代码只有一行几乎没有比它更好的性价比。唯一的硬伤是对引用类型Set 比较的是引用地址而不是结构。两个内容完全一样的对象只要不是同一个引用Set 就不会去重。这个后面讲对象数组去重时会单独说。3.2 filter indexOf典型 O(n²) 反面教材代码非常好读const unique arr.filter((item, index) arr.indexOf(item) index);意思就是只有当某个元素在数组里第一次出现的位置就是当前位置时才保留它。问题在于indexOf 每次都要从数组头部开始遍历查找filter 本身也要整体遍历一遍很多元素会被反复扫描。数据量小的时候没感觉到百万级就是灾难。我这边测 5 万条数据已经要 1.5 秒以上如果强行跑 100 万10 分钟是保守估计。这还没算 filter 会创建新数组indexOf 在查找时不断访问原数组带来大量内存和 CPU 缓存开销。真实浏览器环境里这段代码会让页面长时间无响应体验极差。如果业务上真的绕不开这个写法最多也就处理几百到几千条数据。超过这个量级老老实实用 Set 或 Map。不要觉得 filter indexOf 简洁就无脑用简洁在这一场景里是陷阱。3.3 对象/Map 键值法和 Set 很像但陷阱不少对象键值法的常见写法有两种一种是对象当哈希表一种是 Map。先说对象const obj {}; const unique arr.filter((item) { if (obj[item]) return false; obj[item] true; return true; });这个方案看起来也是 O(n)但有几个坑。第一对象的键名只能是字符串或 Symbol数字会被转成字符串于是 1 和 1 会被当成同一个键。第二如果数据里有 proto、constructor 这类特殊字符串直接 obj[item] 可能造成原型链污染甚至误判。第三对象普通属性的读写比 Map 要慢一些。所以我用对象做百万级测试时耗时通常是 Set 的两倍左右。用 Map 会更稳const map new Map(); arr.forEach((item) { if (!map.has(item)) map.set(item, true); }); const unique [...map.keys()];Map 的键可以是任意类型读写性能和 Set 接近而且不会把数字强转字符串。如果只做去重Set 其实更合适如果后续还要保留每个键对应的某个值那 Map 是首选。百万级数据下 Map 去重通常比 Set 慢 10~20ms差距不大可以接受。3.4 排序 相邻比较时间不差但要注意副作用思路是先排序再遍历一次只保留和上一个元素不同的值const sorted [...arr].sort((a, b) a - b); const unique sorted.filter((item, i) i 0 || item ! sorted[i - 1]);排序时间复杂度 O(n log n)比 O(n) 差一点但比 O(n²) 好很多。实测 100 万数据大概 120~180ms某些场景下依然可用。但有两个坑一是 sort 会改变元素顺序如果你希望保留第一次出现的顺序排序去重直接不满足二是比较函数如果写不好对有 NaN 的数组会得到很奇怪的结果。代码里我用的是数字比较函数如果数组里有字符串需要换成 localeCompare 之类的比较器。还有sort 方法会修改原数组所以我先做了浅拷贝 [...arr]这也会增加一份内存。如果原数组顺序无所谓排序去重是个折中方案但能 Set 还是首选 Set因为 Set 代码更短、更快。3.5 双重循环 / 标记法只适合小数组或特殊去重有些人不用 Set是因为业务场景要求按对象的某个字段去重或者要保持原顺序。这时候可能会写双重循环const unique []; for (let i 0; i arr.length; i) { let isDuplicate false; for (let j 0; j unique.length; j) { if (unique[j] arr[i]) { isDuplicate true; break; } } if (!isDuplicate) unique.push(arr[i]); }这本质上也是 O(n²)而且比 filter indexOf 还多一个数组 push百万级数据下绝对不能碰。它唯一的优势是可以在比较时写复杂逻辑比如比较对象多个字段。但更好的做法是用 Map 把查找重复的复杂度降到 O(1)循环本身保持 O(n)。对于 Canvas 里面实时处理上万个点双重循环勉强能用但超过 10 万就要考虑换方案了。4. 不同业务场景下的最佳去重方案4.1 纯原始值数组无脑用 Set如果数组元素是数字、字符串、布尔值这类原始值不需要额外保留每个值对应的其他信息Set 就是最优解。代码最简单速度最快也不容易出错。唯一要确认的是兼容性IE 不支持 Set但现在还在做 IE 适配的场景已经不多了就算要兼容也应该用 polyfill而不是换成一个 O(n²) 的方案。我之前在优化一个百万级 ID 数组时试过用 reduce 手动去重代码写了一大段性能还是不如 Set。后面想通了简单场景不要炫技直接const uniqueIds [...new Set(ids)];干净、快、不容易出 bug。4.2 对象数组按字段去重用 Map 做 key 映射对象数组去重是另一个高频场景比如日志列表按 userId 去重商品列表按 sku 去重。如果直接用 Set两个字段相同但引用不同的对象会被当成不同元素去不掉。正确做法是用 Map把要去重的字段拼成 keyconst list [ { id: 1, name: a }, { id: 2, name: b }, { id: 1, name: c }, ]; const map new Map(); for (const item of list) { const key item.id; if (!map.has(key)) map.set(key, item); } const unique [...map.values()];如果去重字段不止一个可以把多个字段拼成一个字符串作 key但要注意分隔符的选择。比如key ${item.type}-${item.id} 时如果 type 或 id 本身可能包含 -就会撞 key。更稳妥的办法是用嵌套 MapouterMap.get(type).set(id, item)或者手动构造一个稳定的字符串 key。JSON.stringify 一个只含目标字段的小对象也是一种办法但要注意字段声明顺序不同会产生不同 key。这个坑我在实际项目中踩过两个重复项没被去掉统计数据差了一大截。4.3 超大数组内存敏感分片处理或原地标记如果数据量不只百万而是上千万或者运行环境是内存紧张的设备Set/Map 的内存开销也要考虑。Set 对每个元素都要维护哈希表项内存可能比原数组大好几倍。这时候可以分片处理比如每次处理 20 万条去重后合并用全局 Set 存放已见值function dedupeInChunks(arr, chunkSize 200000) { const seen new Set(); const result []; for (let i 0; i arr.length; i chunkSize) { const chunk arr.slice(i, i chunkSize); for (const item of chunk) { if (!seen.has(item)) { seen.add(item); result.push(item); } } } return result; }这种写法的时间复杂度仍是 O(n)内存不会一次性塞入全部 Set适合数据量特别大的场景。代价是代码变长。如果内存非常紧张可以不 slice直接通过索引在原数组上遍历。4.4 需要保持顺序不同方案顺序表现保留顺序的需求经常被忽略。Set、Map、filter indexOf 天然保留第一次出现的顺序。对象键值法如果只用 filter 也是保留顺序的但特殊键名有风险。排序去重会改变顺序不适合需要按原顺序输出的场景。如果在去重的同时还需要把每类数据的最后一条记录取出来Map 也可以实现更复杂的操作通过每次都更新 value 就行。这是 Set 做不到的需要按场景选型。4.5 性能速查表方案时间复杂度内存占用是否保序百万级实测参考适用场景SetO(n)中是25~35ms原始值数组首选MapO(n)中是35~45ms对象按字段去重/需要键值映射对象键值O(n)低是50~70ms简单场景注意键名陷阱排序相邻O(n log n)高排序拷贝否120~180ms不要求顺序数据非海量filterindexOfO(n²)中是5万约1.5s只适合几百到几千的小数组表格里的耗时是我个人环境的结果不代表所有浏览器但时间复杂度是确定的。你在自己机器上跑一遍就能验证这个数量级。5. 实战案例百万级日志按用户去重的完整方案5.1 需求描述与踩坑案例一个真实场景某天我需要处理 100 万条登录日志每条 log 是一个对象包含 uid、time、device 等字段。业务要求按 uid 去重并且保留每个 uid 最新的一条日志。数据量百万级如果写双重循环或者 filter indexOf页面或者 Node 脚本基本就废了。一开始同事用 sort 按时间倒序排然后 filter 相邻 uid 去重logs.sort((a, b) Date.parse(b.time) - Date.parse(a.time)); const result logs.filter((item, i) i 0 || item.uid ! logs[i - 1].uid);这个方案在 100 万条数据下大约跑了 1 秒多看起来还行但它改变了原数组顺序而且如果 uid 是数字和字符串混合判断item.uid ! logs[i - 1].uid就会把123和123当成不同用户。我们线上就因为这个踩了坑。5.2 高效实现与关键代码最稳的方案是直接一遍 Map边遍历边比较时间保留最新记录。时间复杂度 O(n)也不会被 uid 类型混合影响const map new Map(); for (const log of logs) { const uid String(log.uid); // 统一字符串 key避免 123 和 123 分离 const old map.get(uid); if (!old || Date.parse(log.time) Date.parse(old.time)) { map.set(uid, log); } } const result [...map.values()];如果不想每次比较都重新 Date.parse可以先预处理一个小结构把日志时间转成时间戳再进 Mapconst map new Map(); for (let i 0; i logs.length; i) { const log logs[i]; const uid String(log.uid); const timestamp Date.parse(log.time); const old map.get(uid); if (!old || timestamp old._ts) { map.set(uid, { ...log, _ts: timestamp }); } } const result [...map.values()].map(({ _ts, ...rest }) rest);第二种做法把 Date.parse 的调用次数减少到每个用户至多两次时间比较也变成纯数字比较百万级数据实测大约 200~300ms 出结果。相比排序去重的 1 秒多又快了不少而且完整保留了原始对象只是中间多了一个临时字段。5.3 结果和扩展建议按这个方案跑下来100 万条日志去重后长度大概在 30 万左右耗时稳定在 300ms 以内。如果数据量继续涨到 500 万、1000 万我建议直接分片每 20 万条一批处理配合全局 Map避免单次内存占用过高。再大一点可以把任务丢到 Web Worker 里跑浏览器主线程完全不卡。这个案例还说明了一个道理去重的核心不一定是“快速去掉重复项”而是“在去重的同时拿到你真正需要的那条数据”。用 Map 可以边去重边保留最新的、最早的、或者聚合后的值比单纯 Set 更灵活。6. 避坑指南与我的实操建议6.1 你以为去重很纯粹NaN、-0、引用类型这些边角料Set 对 NaN 的处理比 indexOf 强。indexOf 内部走的是严格相等NaN NaN 为 false所以用 indexOf 去重时多个 NaN 不会被去掉。Set 内部用的是 SameValueZero 算法NaN 算同一个值能正常去重。另外-0 和 0 在 Set 里也被认为是同一个值不会产生两个元素。对于引用类型Set 比较的是引用地址。如果两个对象结构完全一样但属于不同引用Set 不会去重。所以不要试图用 Set 直接去重对象数组除非你明确知道对象都是同一个引用。还有一个容易忽略的用 JSON.stringify 配合 Set 做对象去重时如果对象里有函数、undefined、Symbol这些会被 JSON.stringify 忽略或转成 null可能导致错误去重。比如两个对象一个多了 undefined 字段序列化后居然一样。这个坑我踩过最后改成手动拼接关键字段才解决。6.2 测试时最容易犯的错编译器优化、随机数干扰做性能对比时很多人直接 console.time 包一下就下结论结果被误导。第一必须保证各方法操作的是同一份数据源或等价数据不能一个方法改了原数组后面方法跟着受影响。第二如果方法内部创建了新数组测试时要把新数组消费掉否则引擎觉得结果没被使用可能会做死代码消除导致结果虚低。第三随机数组每次内容不同重复率不同会影响排序去重这类方案的耗时所以要固定数据源或多次取平均。我调试时的做法是先固定生成一份数组存在变量里每个方法传入同一个 arr方法内部自己拷贝需要的部分保证互不干扰。运行多次取中位数比单次结果更可信。另一个容易被忽略的是 JIT 编译预热。第一次调用某个函数时JS 引擎可能要优化编译所以测试时先跑一遍热身再用第二遍的结果作为参考。如果只跑一遍可能会把预热时间也算进去得到不真实的数字。6.3 我用了这么久的一点心得文章写到这里我把个人沉淀的几个习惯分享出来。第一在大数据量场景里不要聪明过头先无脑用 Set 把功能跑通再用性能分析工具看瓶颈。很多同事一上来就写个看起来很高效的排序去重结果数据不是数值类型排序还得写复杂比较器反而比 Set 更慢。第二去重需求往往伴随着统计需求比如去重后还要计算每个分类的数量、保留最近一条记录等这时候直接用 Map 而不是 Set省得后面再造一遍轮子。第三如果数据量已经大到 Set 也撑不住除了分片还可以考虑在数据源头做约束比如后端 SQL 里加 DISTINCT或者日志采集时先用更节省空间的概率结构把明显重复的丢掉。前端 JS 能做的优化有上限不能把压力全扛在浏览器里。我用分片处理后再配合 Web Worker 把任务丢到后台线程跑页面完全不会卡体验好了很多。最后分享一个小技巧当数组是百万级且全是不重复的数字时可以把数字转成 Uint32Array 之类的 TypedArray 来省内存。但要注意TypedArray 在去重这件事上并不会比普通数组快很多它主要省内存不是省时间。真正要省时间还是要回到哈希表加 O(n) 循环这条路。希望这份实测记录能帮你少走点弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进