尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JS集合去重与排序全解析:从Set到sort的实战避坑指南

JS集合去重与排序全解析:从Set到sort的实战避坑指南 前几天代码评审同事交上来一行堪称标准答案的代码[...new Set(data)].sort()。去重调用Set排序交给sort看起来一气呵成。结果测试一跑就翻车数字列表直接乱套10排在9前面99排在100后面。原因其实不复杂sort()不传比较函数时默认按字符串的Unicode码点排序数字会被转成字符串后再排。这种问题在JS集合去重和排序的处理中太典型了。我做过不少数据清洗、列表渲染、接口聚合的活数组去重、对象数组去重、按字段排序、多字段混合排序都系统踩过一遍今天把这块内容捋清楚从最基础的写法到性能权衡再到各种隐形坑一次讲透。不管你是刚接触JS的初学者还是写了两三年代码依旧被sort坑过的老手这篇都值得花十分钟过一遍。1. 先认清集合都有哪些容器选错后面全白搭说JS集合去重排序如果不先把集合这个词搞清楚后面写出来的代码很容易四不像。JS里能装多个元素的结构远不止数组一种Array、Set、Map、WeakSet、WeakMap还有TypedArray、arguments、NodeList这种类数组。去重和排序这对操作在不同容器里待遇完全不一样。1.1 Set是去重的正主但排序得回到数组Set在ES6之后几乎就是为了保证元素唯一而生的。它的has、add操作在平均情况下都是常数级时间底层跟Map一样是哈希结构你可以把它理解成一个默认就帮你做去重的容器。所以你要对数组做纯值去重[...new Set(arr)]是首选代码短语义清楚性能还好。但Set有个天然的短板它没有sort方法也没有下标访问。Set的遍历是插入顺序你没法像数组那样随意交换元素位置也没法直接通过索引取第几个元素。所以一旦涉及排序你得先把Set转回数组。常见的完整链路就是数组转Set做去重再把Set转回数组最后调用sort排序。这个链条很多人每天都在写但真正理解每一步为什么要这样走的人不多。Map的地位也不可忽视。面对对象数组按某个字段去重比如一堆订单按orderId去重Map的键唯一特性正好派上用场。WeakSet和WeakMap因为键只能是对象、且不可遍历去重还能做排序就别想了。TypedArray虽然有自己独立的sort方法但去重还是得借助Set再转回TypedArray。所以回答集合有哪些这个问题本质上是在决定你打算用哪种数据结构来承担去重和排序这两个语义。1.2 引用类型和值类型的重复是两回事去重第一个要回答的问题是怎样才算重复对数字、字符串、布尔这类基本类型答案简单值相等就是重复。对对象、数组、函数这类引用类型判断的是是否指向同一个引用而不是内容是否相同。这就导致一个新手经常困惑的现象const a { id: 1 }; const b { id: 1 }; new Set([a, b]).size; // 2内容一样但不是同一个引用 new Set([a, a]).size; // 1同一个引用只算一次换句话说内容一模一样但不同引用的两个对象Set认为它们不重复。这个特性决定了如果你想去掉的是内容完全相同的对象那光靠Set是不行的需要对内容做深比较。而如果业务上只要求某个字段相同就算重复比如id一样就按一条算那就用Map按字段去重。先把这两个方向分开后面所有写法才不会混在一起。这里还有一个很多人忽略的点数组本身是一个对象它作为Set的元素时同样按引用比较。new Set([[1, 2], [1, 2]]).size的结果是2因为两个不同的数组引用哪怕内容完全一样也不是同一个值。处理嵌套结构的时候这个特性经常让人怀疑人生。2. 去重的几种主流写法各有什么隐藏的坑去重的写法网上能找到一堆但能说清楚每种写法为什么对、为什么错的文章不多。这里按使用频率从高到低过一遍。2.1 Set一行流和它背后的SameValueZero最基本的写法const arr [1, 2, 2, 3, 2]; const unique [...new Set(arr)]; // [1, 2, 3, 2]注意这里数字2和字符串2不会混Set会区分。Set内部使用SameValueZero比较规则和严格相等大部分一致但有两点不同一是NaN被视作等于自身二是0和-0被视作同一个值。所以new Set([NaN, NaN]).size是1这在filter加indexOf方案中是做不到的。你平时可能还会看到Array.from(new Set(arr))的写法和[...new Set(arr)]完全等价纯个人喜好问题。需要说明的是Set去重保留的是元素第一次出现的顺序这个特性在4.5节的集合运算里会用到。2.2 filter indexOf看着直观坑也不少早年没有Set的时候最常见的写法是const unique arr.filter((value, index, self) self.indexOf(value) index);它的思路很简单只保留第一次出现的元素。但问题在indexOf使用的是严格相等规则NaN在这里不仅没法去重还会被整个丢掉。因为[NaN].indexOf(NaN)返回-1走这个filter任何位置的元素都不可能满足-1 indexNaN直接全部消失。同理findIndex对对象数组也是严格相等判断如果一个字段的值是NaN按该字段去重也会出问题。另一个问题是性能。indexOf在自身上面做线性扫描整体时间复杂度是O(n²)。数组长度几百的时候无感几万条数据就会明显卡顿。我在本地用十万条随机数跑过Set去重耗时在个位数毫秒级别filter加indexOf直接飙到几百毫秒而且数组越长两个方案的差距是几何级拉大。现在ES6普及率这么高这个写法大多数场景已经没有存在必要了。2.3 对象数组去重按字段、按序列化、按深比较对象数组在业务里最常见去重要分三种场景。按某个字段去重用Map最顺手function uniqueBy(arr, key) { const map new Map(); for (const item of arr) { if (!map.has(item[key])) { map.set(item[key], item); } } return [...map.values()]; }这里有个细节值得说Map的has也使用SameValueZero所以如果你用id去重而id恰好有NaN也不会出错。值得强调的是这个函数保留的是每个字段值第一次出现时的那个对象如果你想把后面出现的覆盖前面出现的把if (!map.has(...))改成无条件map.set就行语义完全不同。按整个对象内容去重网上流行用JSON.stringify做keyconst unique [...new Map(arr.map(item [JSON.stringify(item), item])).values()];这个方案只适合字段顺序固定、没有函数、undefined或循环引用的简单对象。一旦遇到{a:1,b:2}和{b:2,a:1}这种键顺序不同的对象序列化结果不一样明明内容相同的两个对象会被当成不同。真有按对象内容深去重的需求别自己硬写直接用lodash的isEqual做辅助或者退一步按业务主键去重写到生产环境更稳。2.4 特殊值NaN、-0、Symbol、BigInt把特殊值单独拎出来是因为它们每个都有自己的脾气。NaN在Set和Map里可以正确去重在indexOf和findIndex里完全无效0和-0在Set里算同一个值排序时它们也彼此相等Symbol每次调用都是全新引用new Set([Symbol(a), Symbol(a)])的长度是2如果你非要用Symbol描述符去重只能手动转字符串。BigInt在Set里按照值比较new Set([1n, 1n]).size是1这点和普通数字一致。日常业务里特殊值出现的频率不高但一旦出现最容易在为什么去不掉和为什么被删多了这两类报障里见到它们。3. sort()排序的底层规则默认行为、比较函数、稳定性、本地化排序这块要聊的细节比去重还多因为sort的默认行为看起来特别合理用起来特别坑。3.1 字典序陷阱不传比较函数等于白排[10, 9, 80].sort()的结果是[10, 80, 9]。原因在于sort默认把每个元素转成字符串再用UTF-16码元序列比较。就算数组里装的全是数字比较的也是它们的字符串形式10按字符顺序排在9前面。所以数字排序必须传比较函数const sorted [10, 9, 80].sort((a, b) a - b);比较函数返回负数表示a应该在b前面正数表示b应该在a前面0表示两者相等顺序无所谓。a - b升序b - a降序。但要注意比较函数返回NaN时排序引擎会把它当0处理结果等于这两个元素谁先谁后无所谓在大数组里可能会产生让你摸不着头脑的排列。另外null参与比较时会被转成0undefined则会一律被排到数组末尾这是规范层面的默认行为不是你代码的偶发bug。3.2 字符串和中文排序localeCompare才是正解数组里是字符串时默认排序同样按码点排英文大小写结果和字典习惯不一样。比如按默认排序apple会排在Banana前面因为小写字母的码点比大写字母靠前。这时可以用localeCompareconst names [apple, Banana, cherry, Apple]; names.sort((a, b) a.localeCompare(b, en));处理中文时localeCompare配合zh-Hans-CN大致能按拼音排序。需要提醒的是不同浏览器的ICU版本差异会导致中文排序结果不完全一致特别是多音字和生僻字。所以如果产品对排序结果有严格预期最好在数据源头统一处理或者自己维护一个拼音映射不要把前端的localeCompare当成全平台一致的排序标准。3.3 稳定排序改写了多年老经验早年间JS的sort稳定性是没有保证的V8 7.0之前元素一多就翻车。ES2019正式把sort规定为稳定排序现在的浏览器环境基本都稳定了。稳定排序带来的直接好处是多字段排序不一定要写复杂比较函数。你可以先按次要字段排一次再按主要字段排一次相同主字段值的元素会保持上一次排序的相对顺序。当然大多数场景我还是推荐在一次比较函数里把所有字段的优先级写完更可控list.sort((a, b) { if (a.priority ! b.priority) return b.priority - a.priority; return a.name.localeCompare(b.name, zh-Hans-CN); });这段的意思是priority不同的按priority倒序priority相同的按name正序。一次比较函数搞定不依赖排序稳定性也能保证结果正确这比连续sort两次更可靠因为两次sort要求第二轮的排序算法必须稳定而对更老的环境没有保障。3.4 日期、浮点数、null等特殊排序日期排序常见两种写法new Date(a.time) - new Date(b.time)或者先把时间戳算好再减。Date对象可以直接相减因为会隐式调用valueOf拿到时间戳但为了可读性建议显式调用getTime。浮点数排序用a - b没问题但别指望排序过程做精确计算它只看结果的正负号。日期字段可能在接口里以字符串形式返回比如2024-05-20T10:30:00Z这种字符串直接用a - b会被转成NaN所以要么先new Date(a)做转换要么保证数据结构层面已经统一成时间戳。4. 去重排序组合实战业务场景逐个拆开讲完单点来看组合场景。我平时在项目里遇到最多的是下面几类基本可以直接照抄。4.1 数字数组先去重再排序const nums [5, 2, 8, 2, 5, 1, 9]; const result [...new Set(nums)].sort((a, b) a - b);这个顺序建议先想好先去重能减少排序时的元素数量。Set去重是O(n)排序是O(n log n)重复项多的时候节省的不只是一点点。如果数组本身已经有序你甚至可以不去重直接输出因为相邻元素相同的可能性很大但这种场景比较特殊常规情况下还是Set加sort这条链路最稳。4.2 字符串数组大小写不敏感加中文const words [Banana, apple, 苹果, Cherry, banana]; const result [...new Set(words)].sort((a, b) a.toLowerCase().localeCompare(b.toLowerCase(), zh-Hans-CN) );注意这里只改变了比较方式不会改变原数组里元素的大小写。比如Banana依然保留大写B只是排序时按小写形式比较。如果你希望输出也统一小写那就得先做一次格式化再去重排序否则去重阶段Banana和banana会被当作两个不同的值因为它们作为字符串确实不相等。4.3 对象数组按id去重再按时间倒序const list [ { id: 1, name: A, time: 100 }, { id: 2, name: B, time: 200 }, { id: 1, name: A-again, time: 150 }, ]; function uniqueBy(arr, key) { const map new Map(); for (const item of arr) { if (!map.has(item[key])) map.set(item[key], item); } return [...map.values()]; } const result uniqueBy(list, id).sort((a, b) b.time - a.time);这段的逻辑是先保留每个id第一次出现的数据再按时间倒序。如果你希望保留的是同一个id里时间最新的那条那就先把数组按时间升序排好再用map.set做无条件覆盖这样留在map里的一定是时间最大的那条。这两种业务语义要分清楚否则测试用例很难写。4.4 数组合并去重concat、展开、Set三选一合并两个数组并去重排序最直观的写法是const result [...new Set([...arr1, ...arr2])].sort((a, b) a - b);数据量小随便用数据量大建议arr1.concat(arr2)先合并再转Set展开运算符处理超长数组时会有调用栈压力虽然日常开发不太可能触发但既然concat语义更直接就没有必要冒险。合并去重在接口数据聚合时特别常见分页接口返回了重叠数据、多个渠道的数据源有交叉、前端缓存和历史数据有重复这种场景下这个四行代码基本是标配。4.5 集合运算视角交集、差集配合排序把去重和排序放一起实质上是在做集合运算。用Set实现交集、差集很顺手且天然去重function intersect(a, b) { const setB new Set(b); return [...new Set(a)].filter(item setB.has(item)); }这种写法在你需要在去重结果的基础上继续过滤时特别有用。比如某个接口返回的标签列表里有重复项另一个接口返回了你关注的标签白名单取交集、去重、排序一气呵成。我实际做过一个数据面板页面统计用户权限分组用了三四个类似函数做交集和差集整个模块的代码量比用循环嵌套少了近一半。5. 性能数据与实战避坑这些东西没人写在文档里5.1 几种方案的复杂度对比把前面提到的主要方案列成表一目了然方案时间复杂度空间复杂度适用场景Set去重O(n)O(n)基本类型数组首选Map按字段去重O(n)O(n)对象数组按主键去重filter indexOfO(n²)O(1)小数组或ES5环境filter findIndexO(n²)O(1)小对象数组sort含比较器O(n log n)O(log n)排序统一入口实际开发里大数组几乎不用O(n²)方案。有一次线上表格接口返回了两万多条日志前端先filter加indexOf去重再sort页面卡了快三秒换成Set加单次sort后压到几十毫秒差距就是这么明显。这里还要补充一点现代V8引擎对sort的实现小数组用插入排序大数组用TimSort平均复杂度都是O(n log n)但常数项比你自己写的简单排序要优得多能用内置sort就尽量别手写。5.2 排序原地修改的坑sort是原地排序直接改了原数组。如果你后面还要用原始顺序必须先拷贝const copy [...arr].sort((a, b) a - b);这个坑我见不少人踩过排序后原数组被改又找不到bug源。特别在React、Vue这类框架里直接sort传入的数组还可能导致响应式依赖的列表顺序莫名其妙变了排查半天发现是原数据被污染。所以我自己的习惯是凡是需要排序的公共函数一律先浅拷贝再排序返回值是新数组不接受任何就地修改的副作用。5.3 比较函数必须保持一致性V8的排序算法依赖比较器的一致性。如果你的比较函数里用了Math.random()、依赖外部可变状态、或者在不同条件下返回互相矛盾的结果轻则排序结果无法预测重则直接抛RangeError: Invalid compare function。所以比较函数应该是一个纯函数只依赖两个参数本身。这也是为什么我不建议用0.5 - Math.random()这种写法来打乱数组它看起来能洗牌其实会让sort内部的一致性检测失效行为完全不可控。洗牌请用Fisher-Yates排序就老老实实写稳定比较器。5.4 一个能直接复制进项目的工具函数集合把常用逻辑收敛成工具函数避免每次重写// 基本类型数组去重排序 function uniqueSort(arr, compareFn) { return [...new Set(arr)].sort(compareFn); } // 对象数组按字段去重保留首次出现的元素 function uniqueBy(arr, key) { const map new Map(); for (const item of arr) { if (!map.has(item[key])) map.set(item[key], item); } return [...map.values()]; } // 对象数组多字段排序 // fields [{ key: time, desc: true }, { key: name, desc: false }] function sortByFields(arr, fields) { return [...arr].sort((a, b) { for (const field of fields) { const va a[field.key]; const vb b[field.key]; let cmp; if (typeof va number typeof vb number) { cmp va - vb; } else { cmp String(va).localeCompare(String(vb), zh-Hans-CN); } if (field.desc) cmp -cmp; if (cmp ! 0) return cmp; } return 0; }); }这几个函数都遵循返回新数组、不动原数据的原则在业务代码里用起来很安全。我自己的习惯是凡是进了公共工具库的排序和去重函数一律返回新数组并在命名上突出这个行为配合约定减少误用。如果你在团队里开放代码评审把这两个约束写进注释里基本能挡住一大半低级bug。最后分享个实操中的小习惯每次写完去重排序我都会手动检查几个边界数据——全重复数组、空数组、含null和undefined的数组、含NaN的数组这四类数据能同时通过基本就稳了。遇到的线上排序bug十有八九是数据里混进了一个预期之外的类型而不是算法本身的问题。处理JS集合去重和排序最值钱的不是记住某个写法而是遇到问题时有能力推理出这一步该用Set、那一步该写比较器、数据里藏着哪种脏值。希望这篇能帮你把这条推理链彻底打通。
返回列表