ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript数组去重与排序实战指南:Set/Map/sort全解析

JavaScript数组去重与排序实战指南:Set/Map/sort全解析 作为一个写了快十年JavaScript的前端我几乎每天都会在代码里跟“集合去重和排序”打交道。无论是处理接口返回的重复列表、给用户展示按规则排列的数据还是在状态管理里维护不重复的ID集合这俩操作都是绕不开的基础功。但正是因为太基础很多人反而写得随意性能不好或者逻辑有瑕疵的情况很常见。这篇博文我会从实际开发的角度把JS里的数组去重、对象集合去重、各种排序方案以及去重和排序组合使用的完整思路拆开揉碎讲清楚里面大多数方案都是我在真实项目里验证过的。1. 数组去重先别急着循环Set 是首选1.1 Set 为什么能做这件事ES6 的 Set 是一个“值不重复”的集合结构往里面 add 重复值时会自动忽略。这个特性天然就是为去重设计的所以在处理“数组里没有对象、只是基本类型”的去重场景时最简洁的方案就是const arr [1, 2, 3, 2, 4, 1, 5]; const unique [...new Set(arr)]; // [1, 2, 3, 4, 5]需要注意 Set 内部用的是 SameValueZero 的等价逻辑也就是说它区分 1 和 1因为类型不同但它在处理 NaN 时会把所有 NaN 视为同一个值这恰好补上了以前 indexOf 方案处理不了 NaN 的坑。我用这个方案处理过几千条商品ID列表执行耗时基本可以忽略不计。如果你担心 Set 迭代顺序不稳定那可以放心Set 的遍历顺序是按照插入顺序来的所以[...new Set(arr)]会保留原始数组里第一次出现某个元素的顺序。基于这个特性它在“去重但不改变原有先后关系”的需求下尤其适用。1.2 其他去重方案对比不是所有场合都能直接用 Set比如在老项目里不想引入 ES6 语法、或者需要兼容极老的环境那还是得用传统方案。下面这几个我都实际用过各有优劣方案写法时间复杂度适用场景Set 展开[...new Set(arr)]O(n)绝大多数基本类型去重filter indexOfarr.filter((v, i) arr.indexOf(v) i)O(n²)老环境或需要兼容性reduce includesarr.reduce((acc, v) acc.includes(v) ? acc : [...acc, v], [])O(n²)数据量小的教学场景对象键值法用obj[item]标记O(n)需要统计次数的场景filter indexOf 的写法有个隐蔽问题indexOf 内部会遍历数组所以每处理一个元素都要把整个数组从头扫一遍数据量上了万之后明显变慢。有一次我处理 5 万条日志数据做去重用 filter 方案跑了大概 3 秒多换成 Set 之后降到 10 毫秒以内这个差距是真实存在的。对象键值法虽然也快但有一个容易踩的坑如果元素是字符串 hasOwnProperty直接拿它当对象属性会覆盖原型方法。我建议要么用Object.create(null)创建真空对象要么用 Map 来替代。1.3 去重时的隐藏坑NaN、引用类型和大小写按我的经验新手写的去重代码最常见的问题不是性能而是语义不完整。比如 NaNconst arr [NaN, NaN, 1, 2]; [...new Set(arr)] // [NaN, 1, 2]正确 arr.filter((v, i) arr.indexOf(v) i) // [NaN, 1, 2] // 实际是 [NaN, 1, 2]不indexOf 在比较 NaN 时用 而 NaN NaN 是 false如果真要处理 NaN传统 filter 方案会失败因为 indexOf 内部走的是严格相等比较而 NaN 不等于自身。这也解释了为什么我一直推荐优先用 Set。另外如果是字符串去重但希望忽略大小写比如[Apple, apple]算重复那 Set 直接做不到需要先统一大小写再回原数组找位置或者用 Map 保存小写版本对应的原始值。对象数组用 Set 直接去重是无效的因为 Set 比较的是引用地址两个内容相同的对象在内存中是不同的引用后面我会重点讲对象集合怎么处理。2. 对象集合去重这才是业务项目里真正高频的场景2.1 根据某个字段去重实际开发中数组里放的不是基本类型而是对象的情况占大多数。订单列表按订单号去重、用户列表按 userId 去重、日志列表按 traceId 去重这些都是最常见的需求。我的首选方案是 Map把唯一标识当 key整个对象当 valueconst list [ { id: 1, name: Alice }, { id: 2, name: Bob }, { id: 1, name: Alice2 }, { id: 3, name: Cara }, ]; const map new Map(); list.forEach(item { if (!map.has(item.id)) { map.set(item.id, item); } }); const result [...map.values()];这个方案的好处是O(n) 时间完成保留第一次出现的那个对象不会把后面的重复项覆盖进来代码也容易读懂。如果你希望保留最后一条而非第一条把判断条件反过来或者在 has 之后仍然 set 新的值都能灵活实现。我在处理接口联调时经常需要把两个数据源并起来比如一个是列表页接口一个是详情接口补全的数据这时候用 Map 按主键合并去重几乎是唯一合理选择。2.2 多字段联合去重只有一个 id 还简单难的是“两个字段合起来相同才算重复”。比如点击事件日志里有 page 和 button 两个字段同一页面上同一个按钮只统计一次。这时候 Map 的 key 要改成组合值const list [ { page: /home, button: buy, count: 1 }, { page: /home, button: cart, count: 1 }, { page: /home, button: buy, count: 2 }, ]; const map new Map(); list.forEach(item { const key ${item.page}_${item.button}; if (!map.has(key)) { map.set(key, item); } });组合 key 的时候要注意分隔符选择${page}_${button}如果 page 本身可能带下划线就容易产生碰撞。稳妥做法是使用不太可能出现在字段值里的分隔符或者用 JSON.stringify([item.page, item.button]) 这种方式来生成 key。我踩过一次很深的坑是两个对象字段完全相同但一个来自缓存、一个来自接口某些字段类型不一致一个是数字一个是字符串结果拼接出来的 key 对不上。所以在做去重之前先明确字段类型是否统一这个非常关键。2.3 处理“对象完全相同”的去重如果需求是“对象本身完全一样才算重复”那等同于把对象结构序列化后再去重。常见的做法是对每个对象做 JSON.stringifyconst uniqueByJson (arr) { const seen new Set(); return arr.filter(item { const key JSON.stringify(item); if (seen.has(key)) return false; seen.add(key); return true; }); };这个方案对纯数据对象有效但有几个要注意的点对象属性顺序不同会被视为不同比如{a:1, b:2}和{b:2, a:1}序列化结果不同。如果接口返回的数据属性顺序不固定建议先用一个排序函数稳定属性顺序再 stringify。另外如果对象里有 Date、RegExp、undefined 这些类型JSON.stringify 的表现可能和预期不一致需要提前处理好。不过在绝大多数监控数据、埋点数据的去重场景里这个方法足够好用。3. 排序sort 机制、自定义规则与手写排序的取舍3.1 sort 的默认行为常常不是你想的那样JavaScript 数组的 sort 方法不传参数时会把元素转成字符串再按 UTF-16 码元顺序排序这是初学者踩得最多的坑const nums [1, 2, 10, 21]; nums.sort(); // [1, 10, 2, 21]很多人一开始都会被这个结果吓一跳。其实这不算奇怪因为默认比较的是字符串10 比 2 小因为 1 排在 2 前面。所以处理数字数组时必须传比较函数nums.sort((a, b) a - b); // [1, 2, 10, 21]升序 nums.sort((a, b) b - a); // [21, 10, 2, 1]降序比较函数返回负数表示 a 排前面返回正数表示 b 排前面返回 0 表示两者相等。掌握这个规则之后所有排序都能通过构造合适的返回值来完成。3.2 对象数组的多条件排序对象数组排序和数字数组套路一致只是比较函数里要指定字段。比如按年龄升序、年龄相同按姓名拼写降序const users [ { name: Tom, age: 28 }, { name: Amy, age: 24 }, { name: Bob, age: 24 }, ]; users.sort((a, b) { if (a.age ! b.age) return a.age - b.age; return b.name.localeCompare(a.name); });这里关键技巧是“先判断主排序字段再处理次排序字段”。第一个 return 已经能区分的就不进入下一步只有主字段相等时才看次字段。这个模式可以无限扩展比如先部门、再职位、再入职时间只需要维持这个链式判断。实际项目里排序规则经常是动态的比如表格点击列头切换排序。这时候我把列字段和方向抽成变量再动态生成比较函数function createSorter(field, direction asc) { return (a, b) { const valA a[field]; const valB b[field]; if (valA valB) return direction asc ? -1 : 1; if (valA valB) return direction asc ? 1 : -1; return 0; }; }这样表格组件只需要传createSorter(price, desc)就能排价格降序不需要为每个字段都写一遍比较函数。我后来在后台管理系统里把所有表格排序都统一成了这个方案维护起来很省心。3.3 字符串、中文和版本号的排序字符串排序在 JS 里看着简单但一遇到中文就容易出问题。直接用或比较中文时是按 Unicode 编码排的和拼音顺序完全不是一回事。要按中文拼音排序我一般用 localeCompareconst names [张三, 李四, 王五, 赵六]; names.sort((a, b) a.localeCompare(b, zh-Hans-CN));这里第二个参数指定了中文区域localeCompare 会根据该区域的排序规则来判断多数浏览器默认支持实测效果是拼音顺序。但要注意不同浏览器、不同系统对 localeCompare 的底层实现有差异如果对排序结果要求极高且需要跨端一致建议后端排序。版本号排序也是个高频操作比如[1.9.0, 1.10.0, 1.2.0]字符串排序会出错因为 1.10.0 1.9.0。我通常会先把版本号拆成数组再逐个比较const versions [1.9.0, 1.10.0, 1.2.0]; versions.sort((a, b) { const partsA a.split(.).map(Number); const partsB b.split(.).map(Number); for (let i 0; i Math.max(partsA.length, partsB.length); i) { const numA partsA[i] || 0; const numB partsB[i] || 0; if (numA ! numB) return numA - numB; } return 0; }); // [1.2.0, 1.9.0, 1.10.0]3.4 手写排序算法还有必要吗工作中我基本不会去手写冒泡排序或快速排序因为 V8 内部实现的排序算法在绝大多数场景下都足够快而且数组长度小于等于 10 时用插入排序、长度更长时用快速排序或者较新版本的稳定排序 TimSort性能和稳定性都有保障。但有几种情况我会考虑手写一是面试考察算法基础二是处理超大数组且有特殊内存限制时需要自己控制排序过程中的临时开销三是对排序稳定性有强制要求但运行环境不支持稳定 sort。除此之外老老实实用 sort 是效率最高的选择。我记得有一次需要给几万条数据按多个权重字段做加权排序当时直接传一个复杂的 comparator 给 sort跑了大概几百毫秒完全够用。如果哪天这个数量级涨到百万我可能会考虑用 Web Worker 放到后台线程执行而不是自己造排序算法的轮子。4. 去重与排序的组合实战4.1 先排序还是先去重业务里经常要“去重 排序”一起做比如给下拉框提供不重复且有序的选项。这时候有个顺序问题值得想清楚。如果先去重再排序Set 去重的 O(n) 之后再做一次排序 O(n log n)整体开销是排序主导。如果先排序再去重排序 O(n log n)去重可以用相邻比较法 O(n) 完成整体同样由排序主导。从时间复杂度看两者没有本质区别但从代码简洁度看先 Set 去重再 sort 会简单很多const raw [3, 1, 2, 3, 4, 2, 1]; const result [...new Set(raw)].sort((a, b) a - b); // [1, 2, 3, 4]但如果你处理的是对象数组先排序再去重反而更有利。因为按某个字段排序之后同样的值一定相邻再用快慢指针做原地或原地拷贝的去重就可以在一次遍历里完成不需要额外维护 Map 的 Key 集合。我之前做过一个比赛排行榜需求数据是直播间的热度快照同一个直播间会出现多次要取最新时间戳的那条还要按热度倒序。这时候直接sort((a,b) b.hot - a.hot)之后再按 roomId 去重去重时保留第一条因为是按热度排过序的第一条就是热度最高的一次排序加一次遍历干净利落。4.2 字符串的去重和排序字符串本质上也可以拆成字符数组处理。业务里比如给一串标签去重并排序我用的方案是先 split 再 Set 再 sort 最后 joinconst tags react-vue-angular-react-vue-node; const uniqueSorted [...new Set(tags.split(-))].sort(); // [angular, node, react, vue]这个组合在“从 URL 参数中提取去重的筛选条件”时非常好用。我做过一个后台的表单回显需求URL 里携带多个相同筛选条件比如?tagatagbtaga后端要求拿到的是去重后的条件列表而且要求按字典序传回去方便缓存。当时就是用这个一次性流程搞定的。4.3 数组合并后再去重排序多个数组合并的场景也很常见比如把本地缓存和服务器返回的数据合并成一个全量列表。合并去重排序我通常会一次性完成const arr1 [5, 3, 9]; const arr2 [3, 1, 9, 7]; const merged [...new Set([...arr1, ...arr2])].sort((a, b) a - b); // [1, 3, 5, 7, 9]这里要注意的是如果数组元素是对象Set 去重无效还是要走 Map 按 id 合并的思路。把多数据源的同一份业务数据合并时通常不是简单去重还要决定谁覆盖谁这时 Map 的 set 顺序就很重要。在实际业务里我处理过一个“门店列表合并”的需求一个接口返回默认门店一个接口返回用户最近访问过的门店两边会有重叠。要求是用户访问过的排前面剩下的按门店名排序。我的思路是先把访问过的列表按名排好再合并时用 Set 记录已出现的 id剩下的默认门店按名排序后过滤掉重复的再拼接。这里 Set 主要当作“查询表”使用不依赖它的顺序刻意改一下用法效果更好。4.4 去重排序还会和分组、过滤一起出现很多需求表面上是“去重和排序”实际上还伴随着分组和过滤。比如要对用户列表按城市分组每个组内按注册时间排序且只保留每个邮箱出现一次的记录。这种组合需求我的做法是先用 Map 按邮箱去重得到全量唯一用户再 reduce 按城市分组组内 sort。const byEmail new Map(); users.forEach(u { if (!byEmail.has(u.email)) byEmail.set(u.email, u); }); const unique [...byEmail.values()]; const grouped unique.reduce((acc, u) { (acc[u.city] || []).push(u); return acc; }, {}); Object.values(grouped).forEach(list list.sort((a, b) a.regTime - b.regTime));我发现很多前端新手一遇到“又要去重又要排序又要分组”就慌了其实拆成三步走每一步都是基础操作组合起来就是很稳的逻辑。这也是我一直强调的思路复杂业务问题先拆解成基础原语再用原语组合。5. 性能陷阱与边界场景排查5.1 大数据量下的性能表现我用不同量级的数组实测过各种去重方案的性能结论很稳定Set 和 Map 方案在数据量大时一骑绝尘而 filter indexOf 在 5 万条数据之后已经能感受到明显卡顿。具体数据大概是这样的时间只做量级参考实际依机器而定数据量Set 去重filter indexOfMap 对象去重1,0001ms5ms 左右1ms10,0001-2ms200ms2ms 左右100,00010ms 左右20s15ms 左右所以在需要处理大量数据的场景比如日志分析、数据清洗、前端表格虚拟滚动前的数据预处理优先选 Set 或 Map。而且要注意Set 和 Map 在插入过程中会自动去重不需要先把数组遍历一遍再判断直接 add 或者 set 就行。5.2 排序稳定性到底影响什么排序稳定性的意思是两个排序字段值相等的元素排序后是否保持原来的相对位置。V8 的老版本对长度超过 10 的数组用的是不稳定的快速排序所以同样一批数据在不同浏览器里排序结果可能不同。新版本 V8 已经切换到稳定的 TimSortChrome 和 Node 环境下基本没问题但如果你的代码要跑在低版本 WebView 里就可能碰到排序后原位置变化导致的一些诡异 bug。一个典型的场景是表格多列排序用户先按姓名排再按年龄排稳定排序会保留第一列排序的结果看起来才合理不稳定排序可能让姓名相同的行顺序错乱。所以如果你的系统要兼容老浏览器担心稳定性可以给每个元素额外加一个原始索引并在比较函数里把索引当作最后一个排序条件arr.map((item, index) ({ item, index })) .sort((a, b) a.item.age - b.item.age || a.index - b.index) .map(x x.item);这个技巧我给同事讲过很多次代码只多几行却能从根本上解决稳定性带来的不确定性。5.3 别忘了 null、undefined 和稀疏数组去重和排序遇到 null、undefined 的时候行为容易出乎意料。比如[null, undefined, null, 1].sort() // [1, null, undefined]默认排序时 null 会被转成 nullundefined 在末尾如果业务里允许这些值存在排序前最好先过滤掉或者明确它们在排序里的位置。我通常会在 comparator 里手动判断list.sort((a, b) { if (a null) return 1; if (b null) return -1; return a - b; });稀疏数组也很容易踩坑。数组中间的空槽在迭代时会跳过Set 展开时会变成 undefinedindexOf 也有可能过滤掉空槽导致最终去重结果和预期不一致。所以拿到一个可能是稀疏数组的数据时我建议先arr arr.filter(() true)或者用 Array.from 填充把稀疏部分统一成 undefined再做后续处理。5.4 去重逻辑“看起来对了但结果不对”的排查思路我调试过不少同事的去重代码总结出来的常见 bug 有几个用而不是比较导致数字和字符串被误判为重复对象的某个字段本身有默认值被当成重复标记数组去重时用includes判断但原数组里有对象永远不重复Map 去重时 key 用了对象本身导致每个对象都不同。排查时我一般先打印去重后的 length对比原始 length 和期望 length 是否一致不一致再打印中间结果看哪些 key 被错误合并了。这个排错路径虽然朴素但在大多数场景下都能快速定位问题。6. 日常开发里的心得和工具化封装6.1 把去重排序封装成通用函数因为这些逻辑太常用了我在公司内部沉淀了一套工具函数基本能覆盖各种集合处理需求。这里分享两个我每天都会用的核心封装// 按字段去重返回一个新的数组 function uniqueBy(arr, key) { const seen new Set(); return arr.filter(item { const val typeof key function ? key(item) : item[key]; if (seen.has(val)) return false; seen.add(val); return true; }); } // 按字段排序支持多字段和方向 function sortBy(arr, field, direction asc) { const dir direction desc ? -1 : 1; return [...arr].sort((a, b) { const valA a[field]; const valB b[field]; if (valA valB) return -dir; if (valA valB) return dir; return 0; }); }这两个函数的好处是uniqueBy 传入item item.id或者就直接传 idsortBy 可以指定字段和方向底层都用了 Set 和 sort性能和可读性都在线。我一般建议团队里大家直接用这两个函数而不是每个人自己写一套避免出现“同一种需求三种写法、结果还各不一样”的情况。6.2 去重排序在框架里的实际结合如果是 React 或者 Vue 项目我平时会把这些逻辑放在一个单独的 utils/array.js 文件里组件里引入后配合 useMemo 或 computed 使用。为什么要配合 useMemo因为去重排序会产生新数组如果直接在渲染函数里做每次组件更新都会重新计算数据量大时会影响渲染性能。比如 React 里这样写const processedList useMemo(() { return uniqueBy(rawList, id).sort((a, b) a.time - b.time); }, [rawList]);Vue 里则是const processedList computed(() { return uniqueBy(rawList.value, id).sort((a, b) a.time - b.time); });这样做还有个额外好处当接口返回新数据时只有内容真正发生变化引用才变化避免子组件重复渲染。实际项目里这种优化提升体验非常明显尤其是列表数据在切换筛选条件时。6.3 一个小技巧去重统计频率去重有时候不只是“去掉重复的”还要知道每个值出现了几次。这种场景我用 Map 而不是 Set遍历一次就统计完频率const list [apple, banana, apple, orange, banana, apple]; const counter new Map(); list.forEach(item { counter.set(item, (counter.get(item) || 0) 1); }); // Map(3) { apple 3, banana 2, orange 1 }需要“出现次数最多的前三名”时直接把 counter 转成数组按次数排序再 slice 就行const top [...counter.entries()].sort((a, b) b[1] - a[1]).slice(0, 3); // [[apple, 3], [banana, 2], [orange, 1]]这个“去重 计数 排序”的三连操作在许多数据看板的需求里反复出现比如统计最热门的搜索词、最多人点击的按钮、销量最高的商品思路完全一致。掌握了 Map 的用法之后很多数据处理需求都会变得简单清楚。我在实际开发里的体会是去重和排序看似各是一个小知识点但把它们结合到具体业务场景里时要注意的边界条件远比想象中多。凡是涉及对象字段、空值、类型不一致、浏览器兼容性的地方都有可能隐藏着问题。但基础方案只要选对用 Set、Map 处理去重用自定义 comparator 处理排序80% 的需求都能在几行代码内稳定通过。最后再分享一个小经验任何时候拿到一段要处理的数据先不要急着写循环先想清楚三件事——数据规模大概有多大、要不要保留顺序、去重和排序的依据是什么字段。把这三个问题在心里回答一遍方案基本就自然出来了。这也是我这些年处理集合问题效率比较高、少返工的核心原因。
返回列表