JS集合去重和排序:从“能跑”到“跑得稳”的完整实操手册
做前端时间久了你会发现一个特别有意思的现象:很多面试题看似在考算法,实际上考的是你平时写业务代码时有没有真正抠过细节。就拿“JS集合去重和排序”来说,这六个字拆开看每个字都认识,但真到项目里遇到——接口返回的列表要按更新时间倒序、表格数据需要去重后再分页、搜索联想词不能出现重复项——很多人第一反应就是 new Set(arr) 一把梭,再加个 sort() 完事,结果上线一测,要么排序结果和预期不符,要么对象数组去重根本没生效。
我自己在过往的项目里,为了这两个操作踩过的坑不算少,尤其是 sort() 默认按字符串字典序排序这个特性,几乎每个团队都会有人栽一次。今天这篇就想把这套东西从头到尾梳理清楚,从最简单的写法到能应对复杂业务场景的进阶方案,再配合一些典型的踩坑实录,争取让不同基础的朋友都能拿到一份可以直接抄作业的参考。
这篇内容适合几类人看:刚入门 JS 不久、对数组和 Set 操作还停留在 CRUD 阶段的初学者;有一定经验但在做数据清洗、列表渲染时被去重和排序细节坑过的中阶开发者;以及准备面试、想系统的把数组去重和排序各种姿势补齐的同学。我倒不打算把文章写成一板一眼的文档,而是按照我实际处理这些需求时的思考顺序来展开,每个方案我尽量说清它的适用场景、优缺点和背后的原理,毕竟只有真正理解了为什么,下次换个场景你才知道该怎么选。
1. 整体设计思路:先去重还是先排序,这个顺序很关键
1.1 从业务需求反推技术方案
先说一个我自己的体会:任何脱离了业务场景谈技术的方案都是耍流氓。去重和排序看着只是两个独立的操作,但一旦组合起来,先后顺序不同,性能和结果都可能不一样。
举个最简单的例子,你从一个数据源拿到一万条记录,其中有重复项,同时你希望最终呈现给用户的是按时间倒序排列的唯一列表。这时候先排序再去重,还是先去重再排序?很多人凭直觉认为先去重能减少排序的数据量,一定更快。但在某些特定场景下恰恰相反。
假设你的数据源本身已经接近有序,或者你使用的是某些需要额外内存的去重方法,先去重再做完整排序,反而多了一遍全量遍历。而如果先去重再排序,去重的过程往往也需要一次遍历,排序又需要一次遍历,总的时间开销是两者叠加。倒是一些场景下先排序后去重可以将“相邻元素是否重复”的判断复杂度降到 O(1),而且可以利用排序的稳定性省掉一次全量遍历,整体算下来反而更快。
当然,这些都是理论上的权衡,实际项目里数据量没上到一定级别根本感受不到差异。但你要是处理的是一份几万条甚至几十万条的列表(比如后台管理系统的导出数据清洗),这个先后顺序就值得认真考虑了。
1.2 方案选型背后的四个核心考量
我一般接到“去重+排序”这类需求,会先问自己四个问题:
第一,数据量多大?十条数据和十万条数据的方案肯定不一样。十条数据你用 filter 套 indexOf 性能也无所谓,但十万条数据你还这么干,页面可能直接卡死。
第二,数据元素是什么类型?基本类型(字符串、数字)去重用 Set 直接搞定。对象数组去重就麻烦多了,你得明确“重复”的定义——是引用相同,还是某个 key 的值相同,还是多个 key 的组合值相同?定义不清楚,后面的代码全是糊涂账。
第三,排序的规则是什么?按数字大小升序、按某个日期字段倒序、按中文拼音排序?每种规则对应的写法都不同。
第四,是纯前端展示,还是需要把处理后的数据传回后端?如果是要传给后端,那数据清洗的逻辑放前端还是后端就要权衡一下,前端做的好处是交互反馈快,坏处是性能瓶颈明显,而且 JS 的浮点运算和排序稳定性在不同浏览器上有差异,结果不可控风险会高一些。
这四个问题有了答案,方案基本就能定了。这也是我在实际项目中比较习惯的思考路径,先框定边界、再选择工具,而不是一上来就写代码,先让我自己在脑子里把事情理清一遍,然后再动手试试。
1.3 性能和可读性的平衡
说实话,我见过不少同事写的代码,一个去重操作能写七层嵌套,性能是上去了,可后面维护的人看得头皮发麻。所以我的一个原则是:能写清楚再去优化,优化的时候要写注释说明为什么这样优化。尤其去重和排序这种高频操作,一旦出了问题,排查的代价比你省下的那几毫秒要大得多。
我自己的常用套路是:默认用 Set 做基本类型去重,用 Map 做对象数组去重,用 Array.prototype.sort 加自定义比较函数做排序,同时根据数据量决定是否需要引入更复杂的算法或者边界处理。这样代码可读性好,大多数场景下性能也够用,真遇到极端性能需求再换上专门方案,加个分支控制就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数组去重:从 Set 到 Map,再到原地去重
2.1 Set 去重为什么是首选
说到去重,大家第一反应肯定是 Set。ES6 引入的 Set 对象最大的特点就是成员值唯一,配合展开运算符 [...new Set(arr)] 或者 Array.from(new Set(arr)),一行代码就能搞定基本类型的去重。
javascript复制const arr = [1, 2, 2, 3, 4, 4, 5];
const unique = [...new Set(arr)];
console.log(unique); // [1, 2, 3, 4, 5]
这段代码看着简单,但背后有个细节值得知道:Set 内部对值的比较采用的是 SameValueZero 算法,它和 === 基本一致,唯一的区别在于 NaN 被 Set 视为等于自身。这也就是说,[NaN, NaN].length 是 2,但 [...new Set([NaN, NaN])].length 是 1。
这个细节在业务里不多见,但你要是处理的数据是从某个数据源解析出来的,里面可能混着 NaN,去重的预期就和你平时用 === 的判断习惯完全不一样。所以我在实际项目中很少只依赖 Set 去处理包含特殊值的复杂数据,而是会在去重之前先明确“哪些值算作重复”。
另外再提一个大家容易忽略的点:Set 对于对象的处理是基于引用的。这意味着 {a: 1} 和 {a: 1} 虽然在结构上完全相同,但在 Set 看来是两个不同的对象,所以 new Set([{a:1}, {a:1}]) 去重后的长度还是 2。遇到对象数组去重的需求,Set 就不能直接用了。
2.2 对象数组去重的正确姿势
对象数组去重是实际项目中遇到最多的场景。比如后端返回的商品列表,每个商品对象里有一个 id 字段,你希望根据 id 去重。这时候 Set 就无能为力了,需要借助 Map 或者手动维护一个“已见过的 key”集合。
最常规的写法是利用 Map 的 key 唯一性:
javascript复制const list = [
{ id: 1, name: '苹果' },
{ id: 2, name: '香蕉' },
{ id: 1, name: '苹果' },
];
const map = new Map();
list.forEach((item) => {
if (!map.has(item.id)) {
map.set(item.id, item);
}
});
const uniqueList = [...map.values()];
console.log(uniqueList); // [{ id: 1, name: '苹果' }, { id: 2, name: '香蕉' }]
这里用 map.has() 判断是否已经遇到过这个 id,如果没有就存入。这里有个细节:如果你希望遇到重复项时保留的是“后面的那一条”,你就不需要加 if 判断,直接 map.set(item.id, item) 即可,因为 Map 的 key 重复时会覆盖 value,这样后面出现的会覆盖之前出现的。
实际情况中,这种写法也比较灵活,因为“重复”的定义完全由你决定的 key 来决定。比如有些业务要求订单号 + 商品编码组合起来才算是唯一,那就把 key 拼起来:
javascript复制const map = new Map();
list.forEach((item) => {
const key = `${item.orderId}_${item.productCode}`;
map.set(key, item);
});
const uniqueList = [...map.values()];
这种方案的性能复杂度是 O(n),内存复杂度也是 O(n),在绝大多数业务场景下都能接受。但它有一个副作用:它创建的 Map 对象随着数据量的增长会占用较多内存,如果你同时在处理超大数组(比如十万条以上),略微要注意一下内存情况。
另一种是 filter + findIndex 的写法:
javascript复制const uniqueList = list.filter((item, index, self) => {
return self.findIndex((t) => t.id === item.id) === index;
});
这种写法最大的问题是时间复杂度是 O(n²),因为 findIndex 每次都要从头遍历。数据量小的时候无所谓,但一旦数据量上千,性能就会明显下降。我个人建议,不管项目大小,都直接用 Map 方案,养成好习惯后面能省很多事。
2.3 用 reduce 实现更函数式的去重
如果你偏爱函数式编程风格,reduce 也是一个不错的选择。它本质上和 Map 方案一样,只是把存储中间结果的地方从 Map 换成了累加器数组:
javascript复制const uniqueList = list.reduce((acc, cur) => {
if (!acc.some((item) => item.id === cur.id)) {
acc.push(cur);
}
return acc;
}, []);
这里我用了 some() 来判断数组中是否存在相同的 id,和 filter + findIndex 的复杂度一样,也是 O(n²)。不过如果数据量不大,这种写法在可读性上其实挺好,代码意图一目了然。
但如果数据量偏大,我建议做个小改动,配合 Map 来做 O(1) 的查重:
javascript复制const seen = new Map();
const uniqueList = list.reduce((acc, cur) => {
if (!seen.has(cur.id)) {
seen.set(cur.id, true);
acc.push(cur);
}
return acc;
}, []);
这种写法兼顾了可读性和性能,也是我在代码评审时比较推荐的版本。去重的核心逻辑就这么几行,并不复杂,难的是你能不能在看到需求的第一时间想到“这个场景应该用哪种结构”。
2.4 面试经典:有序数组原地去重(快慢指针)
除了上述日常业务写法,还有一种考察算法思维的场景:有序数组的原地去重。这在力扣上有一道原题(26 题),要求不额外开辟新数组,只在原数组上修改,返回去重后数组的新长度。热点词里出现的“js快慢指针有序数组原地去重”,说的就是这道题的标准解法。
思路是使用两个指针:一个慢指针 i 指向当前已经去重后的最后一个位置,一个快指针 j 遍历整个数组。如果 nums[j] 和 nums[i] 不等,说明遇到了新元素,就把 nums[j] 复制到 i+1 的位置,然后 i 前进一步。
javascript复制function removeDuplicates(nums) {
if (nums.length === 0) return 0;
let i = 0;
for (let j = 1; j < nums.length; j++) {
if (nums[j] !== nums[i]) {
i++;
nums[i] = nums[j];
}
}
return i + 1;
}
这个算法的精妙之处在于,它利用了“有序数组”这个前提条件:重复的元素一定相邻。所以当你用一个快指针扫描时,只有遇到和前一个已保留元素不同的值才需要处理,其他的直接跳过。时间复杂度 O(n),空间复杂度 O(1),非常漂亮。
这种写法在业务里虽然不常用,但在面试和算法训练中很经典。理解它的价值在于,你一旦熟悉了“双指针”这类基础套路,后面再遇到类似“删除排序数组中的重复项”“移动零”等衍生题,就可以举一反三。
2.5 高级技巧:大数据量下的分批去重
还有一种极端场景,比如某个表格插件要求一次性接收几万条数据,同时用户上传的文件里可能包含大量重复行。这种时候一次性把整个数组塞进 Map 可能会让内存飙升,甚至导致页面卡顿。
一个我自己的处理思路是:分批处理。把大数组按块切割,比如每次处理 5000 条,用 setTimeout 或 requestIdleCallback 在浏览器空闲时逐个处理,把去重结果逐步累积到一个全局的 Map 中。这样虽然总耗时可能没有减少,但避免了一次性阻塞主线程,用户体验会好很多。
javascript复制function dedupeInBatches(arr, batchSize = 5000, callback) {
const map = new Map();
let index = 0;
function processBatch() {
const end = Math.min(index + batchSize, arr.length);
for (let i = index; i < end; i++) {
const item = arr[i];
map.set(item.id, item);
}
index = end;
if (index < arr.length) {
requestIdleCallback(processBatch);
} else {
callback([...map.values()]);
}
}
processBatch();
}
注意这只是个思路示例,生产环境中还要考虑 requestIdleCallback 的兼容性,以及分批过程中用户操作和状态同步的问题。我这里想强调的是“方案不是万能的,但思路可以复用”。
3. 排序:sort 方法的陷阱与进阶用法
3.1 默认 sort 按字典序排序,这个坑几乎人人踩过
接下来聊排序。JS 数组的 sort() 方法用起来很简单,但默认行为特别容易让人意外:它会把数组元素先转换为字符串,再按照 UTF-16 码元顺序(也就是字典序)进行排序。
举个例子:
javascript复制const nums = [10, 9, 8, 100, 7];
nums.sort();
console.log(nums); // [10, 100, 7, 8, 9]
这个结果对很多初学者来说完全反直觉。因为你心里想的是数字大小排序,但默认 sort 是按字符串排序,字符串 "10" 和 "100" 的开头都是 "1",所以它们会排在一起,而且 "100" 排在 "10" 后面,因为第三个字符 "0" 和字符串结束比较。
要按数字大小排序,必须传入比较函数:
javascript复制nums.sort((a, b) => a - b);
console.log(nums); // [7, 8, 9, 10, 100]
比较函数的规则是:返回负数表示 a 排在 b 前面,返回正数表示 a 排在 b 后面,返回 0 表示位置不变。a - b 是升序,b - a 是降序。理解了这一点,你才能玩转所有自定义排序。
3.2 对象数组排序:根据字段动态排序
实际项目中更多是对对象数组排序,比如按价格排序、按创建时间排序。这种需求本质上是写一个自定义比较函数,然后传给 sort:
javascript复制const products = [
{ name: '苹果', price: 5 },
{ name: '香蕉', price: 3 },
{ name: '橙子', price: 4 },
];
products.sort((a, b) => a.price - b.price);
如果是按字符串字段排序,直接比较字符串:
javascript复制products.sort((a, b) => a.name.localeCompare(b.name));
localeCompare 是一个被低估的方法,它能根据语言环境来比较字符串,中文拼音排序也能用上。
还有一个常见的复杂场景:多个排序条件组合。比如先按 status 排序(固定状态优先级),再按 createTime 倒序。这时候比较函数需要做多级判断:
javascript复制list.sort((a, b) => {
if (a.status !== b.status) {
// status 是数字,比如 0 表示待处理,1 表示处理中,2 表示已完成
return a.status - b.status;
}
// 状态相同,按时间倒序
return b.createTime - a.createTime;
});
这种写法逻辑清晰,后面加条件也容易扩展。我一般会在每个分支写一行注释说明优先级的含义,防止两个月后的自己看着一堆 return x - y 发懵。
3.3 手写排序算法:快排、冒泡、归并到底什么时候用
光会调 sort 当然不够,有些时候你还需要了解排序算法的原理,尤其是面试和性能调优场景。热搜词里出现了“数据结构排序算法”“选择排序”“c语言排序数组”等,说明很多人在从底层理解排序这件事。
但在 JS 实际开发中,Array.prototype.sort 的底层实现已经根据数据量和数组类型做了很多优化,比如 V8 引擎对短数组用插入排序,对长数组用快速排序或 TimSort。你手动去实现一个快速排序,在大多数情况下性能并不会比原生 sort 好,反而容易出错。
那手写排序算法的意义在哪?我的看法是:在于理解复杂度、稳定性和分治思想,而不是真的要在生产环境里替换掉原生 sort。
举个例子,冒泡排序的代码很简单:
javascript复制function bubbleSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
}
它最大的问题就是时间复杂度 O(n²),一万条数据可能就卡了。我知道现在学习排序算法的时候老师会要求你背这些代码,但实际开发中如果你发现一个上万条数据的排序性能有问题,第一反应不应该是优化算法,而应该考虑:数据是不是必须在客户端排?能不能分页?能不能让后端排完再返回?
这里顺便提一下“稳定性”的概念。稳定排序指的是如果两个元素在排序前顺序是 a 在前、b 在后,且它们排序依据的 key 相等,那么排序后 a 依然在 b 前面。对于表格多列排序这种场景,稳定排序很重要。V8 中的 sort 在 ES2019 之后被明确规定为稳定排序,所以现在你可以放心用原生 sort 处理这种情况。
3.4 中文排序的三种思路
处理中文列表排序经常让前端头疼,因为“按中文名排序”到底按什么规则排,不同人理解不一样。
第一种思路是按 Unicode 编码排序,也就是默认 sort 的字典序。这种排序的结果是按拼音首字母大致排序,但不准确,因为中文在 Unicode 中的码位是按部首和笔画来安排的。
第二种思路是用 localeCompare:
javascript复制const names = ['张三', '李四', '王五', '赵六'];
names.sort((a, b) => a.localeCompare(b, 'zh'));
localeCompare 加上 'zh' 语言参数后,在大部分现代浏览器中会按照中文拼音排序,效果基本符合日常预期。但它依赖浏览器内置的国际化支持,不同浏览器可能会有细微差异。
第三种思路是使用 Intl.Collator:
javascript复制const collator = new Intl.Collator('zh', { sensitivity: 'accent' });
names.sort(collator.compare);
Intl.Collator 性能上比 localeCompare 略好,因为比较器可以复用。如果你的列表会频繁重新排序或者数据量不小,建议用这种方式。另外 sensitivity: 'accent' 表示区分重音但不区分大小写,属于比较通用的配置,具体参数可以按需求调。
这三种思路我都在项目里用过,说句实在话,除非你的业务明确要求按拼音排序,否则我一般不推荐在前端做中文排序。因为语义不明确,而且和产品沟通成本高。大多数情况下,中文名的排序需求应该交给后端来做,前端只需要展示后端返回的顺序。
3.5 特殊数值的排序:null、undefined、NaN 怎么处理
这个坑很隐蔽。假设你有一个商品列表,部分商品的 price 是 null 或者是 undefined,你直接写 products.sort((a, b) => a.price - b.price),结果 null 会被转成 0 参与比较,undefined 会被转成 NaN,而 NaN 和任何值比较都返回 false,排序结果就变得不可预测。
我的习惯是在排序之前先做数据清洗,把空的字段统一赋一个默认值,或者把包含空字段的记录单独过滤出来。
javascript复制const validProducts = products.filter((item) => item.price != null);
const nullProducts = products.filter((item) => item.price == null);
validProducts.sort((a, b) => a.price - b.price);
const sorted = [...validProducts, ...nullProducts];
这样至少保证了排序结果的可预测性,也不会因为 NaN 导致排序逻辑出现诡异行为。这个技巧虽然朴素,但在实际项目中真的能救命。
4. 去重 + 排序的组合实战:如何写出既快又稳的代码
4.1 先排序后去重还是先去重后排序的判断依据
回到文章开头的问题。我先给一个通用的判断方法:
如果数据量少(几百条以内),先后顺序随你,性能差异可以忽略。如果数据量大,优先考虑先去重再排序。原因是先去重能减少后续排序的数据量,而排序的复杂度通常是 O(n log n),数据量每减少一倍,排序时间大概能省一大截。
但有一种特殊情况,就是你的去重操作本身很耗时(比如对象数组多层属性比较)。这种情况下,如果你恰好需要一个“去重后保持某个字段有序”的结果,采用“先排序再去重”可以同时完成两件事。具体思路是:先按目标字段排序,然后遍历一次,遇到相邻重复项就跳过。这样只需要一次排序、一次遍历。
举例说明,假设你有一个日志列表,需要按 userId 去重,同时希望保留下来的记录是每个用户最新的一条日志(按 timestamp 倒序):
javascript复制// 先按 userId 分组,组内按时间倒序,每组第一条就是该用户最新的一条
logs.sort((a, b) => {
if (a.userId !== b.userId) {
return a.userId - b.userId;
}
return b.timestamp - a.timestamp;
});
const seen = new Set();
const uniqueLatestLogs = logs.filter((log) => {
if (seen.has(log.userId)) {
return false;
}
seen.add(log.userId);
return true;
});
这种做法非常经典,因为它不需要额外维护一个 Map 来存储整个去重后的数组,只需要一个 Set 来记录已经出现的 userId 即可,而且在排序阶段就已经把“取最新一条”的逻辑融入进去了。我遇到类似“取每个用户最近一条操作记录”的需求时,基本都是用这个套路。
4.2 结合 Set 与排序实现高性能组合操作
如果你的数据本身是基本类型数组,那组合操作非常简单:
javascript复制const arr = [5, 2, 9, 1, 5, 3, 9];
const result = [...new Set(arr)].sort((a, b) => a - b);
// [1, 2, 3, 5, 9]
这里建议把 sort((a, b) => a - b) 作为默认习惯写在代码里,因为上面说过了,不带比较函数的话,[5, 2, 9, 1, 5, 3, 9] 排序后是 [1, 2, 3, 5, 5, 9, 9] 这种正常情况还好,但一旦出现两位数,结果就完全不对了。这也是我在 code review 时最常看到的一个低级错误。
如果你希望数据不修改原数组,可以这样写:
javascript复制const uniqueSorted = (arr) => [...new Set(arr)].sort((a, b) => a - b);
const result = uniqueSorted(arr);
很多时候防患于未然比事后修 bug 更重要,一个纯函数式的写法能避免很多副作用。
4.3 从 Array 到 Set 的底层原理:为什么它能去重
既然讲到这里,顺便说一下 Set 的底层原理。JS 引擎在实现 Set 时,内部使用了一种类似哈希表的结构来存储元素,哈希表的查找时间复杂度是 O(1),所以每次插入新元素时判断是否重复也几乎是 O(1) 的。这正是 Set 去重比 filter + indexOf 高效得多的原因。
哈希表这个类比可以这么理解:每个元素进去之前,先根据它的哈希值找到对应的“抽屉”,如果抽屉里没有就放进去,有就说明重复、直接丢弃。当然 JS 对象类型没法直接计算哈希值,所以引擎对对象的处理走的是引用比较,这也是为什么 Set 无法去重两个结构相同但引用不同的对象。
理解这一点后,你就能推导出很多结论:比如为什么 Set 对 NaN 能去重(NaN 在 SameValueZero 规则下等于自身),为什么 Set 不区分 -0 和 +0。这些细节在面试里常见的很。
4.4 大数据量时的内存优化:避免保留中间结果的副本
有一种情况是:你有一个很大的数组,你需要去重并排序,但你不能额外的再保留一份数组的副本,因为内存太紧张。这时候上面的 [...new Set(arr)] 就不合适了,因为展开运算符会新创建一个完整数组。
可以改用 Array.from(new Set(arr)),这样至少在语义上更明确一些,但实际上还是会创建一个新数组。如果你真的需要极致的内存控制,可以先用 Set 收集、再转化成数组,或者直接使用快慢指针的思路在原地去重。
原地去重的一个典型场景,是 Canvas 绘图或 WebGL 渲染时需要大量顶点数据,这些数据往往不能随意丢,拼成数组后你希望去掉重复坐标、同时按某个规则排序,但你又不能往 GPU 的缓冲区里塞一个巨大的中间副本。这时候用快慢指针先原地去重,再局部排序,就很有实用价值了。
我在实际项目中做过一次顶点去重优化,场景是把一个 3D 模型的三角面片顶点从 50 万压缩到 20 万。那个场景里确实不能生成副本,用的就是先排序再相邻去重的思路。当时把顶点数组按坐标转为字符串 key 排序,然后双指针扫描删除相邻重复项,性能提升非常明显,内存占用也控制住了。这类方案虽然平时前端业务用不到,但掌握它对理解 JS 数组的内存模型和操作本质非常有帮助。
5. 常见问题与排查技巧实录
5.1 sort 默认排序的坑:别再被字典序坑了
这是排名第一的经典错误。只要你传入的数组里包含数字和字符串混排,或者纯数字数组里出现了大于等于 10 的数,裸用 sort() 就会出问题。
一个典型的排查场景是:后端返回的数组里混合了字符串数字和数字,比如 ['10', 9, 8],你直接调 sort(),得到的可能是 [10, 8, 9]。问题的根源在于,sort() 先把每个元素转换成字符串再比较,'10' 和 '9' 比较时是逐字符比较,'1' 小于 '9',所以 '10' 反而排在 '9' 前面。
排查这个问题的思路,其实就是多看一眼比较函数是否存在。如果代码里出现 arr.sort() 且 arr 元素不全是单字符字符串,你就该警惕了。
5.2 对象去重时“看起来相同却不去重”的原因
之前已经解释过了,Set 对对象是引用比较。如果你遇到“明明打印出来两个对象内容一样,Set 去重后还是有两条”的情况,先检查是不是引用了不同的对象实例。
另外还有一种隐蔽情况:后端返回的数据中,有的对象多了一个我们没定义的字段。比如一个对象是 { id: 1, name: '苹果' },另一个是 { id: 1, name: '苹果', extra: undefined },打印出来可能长得差不多,但结构不同。如果你只按某个 key 去重,这两种情况应该正常合并;如果你按整个对象 JSON.stringify 后去重,它们就会被认为是不同的。
实际业务中,我基本不推荐用 JSON.stringify 做对象去重,因为字段顺序一旦变化(比如后端某个接口字段顺序调整了),结果就不稳定。
5.3 排序不稳定导致多列排序失效的排查
多列排序依赖稳定排序这一点,前面提过了。在 ES2019 之前,你还是需要对“排序稳定性”保持敏锐。比如你写了一个按 date 排、再按 type 排的逻辑:
javascript复制list.sort((a, b) => a.date - b.date);
list.sort((a, b) => a.type - b.type);
在非稳定排序环境下,第二次排序会把第一次排序的顺序破坏掉,导致最终结果中 date 字段的序乱了。现在主流浏览器都支持稳定排序,这个写法是安全的,但如果你在维护一些旧项目,或者在某些跨端环境(比如老版本的某些 WebView)上运行,最好还是在一个比较函数里同时写多个条件,避免依赖稳定性。
5.4 快速定位问题的方法论
如果你遇到去重或排序结果不对,我的建议是先建立一个最小可复现的例子,只保留出问题的数据和最简单的代码逻辑。在控制台里跑一遍,看看结果和预期差距是什么。如果是排序问题,先打印没有比较函数时的默认排序结果,再加上比较函数,对比差异,基本就能定位到是不是字典序的坑。
如果是去重问题,先确认“重复”的判定条件,再确认每个元素在判定条件下的 key 值是否真正相等。比如你按 id 去重,但一个 id 是 1(数字),另一个是 '1'(字符串),你用 map.has(item.id) 来判断,Map 的 key 是严格相等比较,所以 1 和 '1' 是两条不同的 key,它们不会被去重。这个坑也踩过的人不少。
5.5 一文速查:常用去重和排序写法对照表
| 需求场景 | 推荐写法 | 时间复杂度 | 注意事项 |
|---|---|---|---|
| 数字/字符串数组去重 | [...new Set(arr)] |
O(n) | 对 NaN 有效,对象无效 |
| 对象数组按某个 key 去重 | Map + map.set(key, item) |
O(n) | 注意 key 的类型:数字和字符串不相等 |
| 有序数组原地去重 | 快慢指针 | O(n),空间 O(1) | 前提:数组有序 |
| 数字数组升序排序 | arr.sort((a, b) => a - b) |
O(n log n) | 不要用默认 sort() |
| 对象数组按字段排序 | arr.sort((a, b) => a.field - b.field) |
O(n log n) | 多条件按优先级分步写 |
| 中文按拼音排序 | new Intl.Collator('zh').compare |
O(n log n) | 依赖浏览器国际化支持 |
6. 写在最后的实战心得
这篇文章写到这里,去重和排序的各种方法基本上都覆盖了。我再分享一个个人体会:很多时候,一个看似简单的“去重排序”需求,背后真正的难点并不在于代码本身,而在于你能否在动手之前,先把“重复”的定义、“排序”的规则和数据的边界都问清楚。
我自己踩过最深的一个坑,是在一个数据可视化大屏项目里,有一个接口返回了全站的埋点日志,前端需要按 eventId 去重后按时间排序展示。当时我直接用 Set 去重,结果上报的数据里 eventId 有时是字符串 "123",有时是数字 123,Set 认为它们是不同的,导致去重失效,大屏上出现了几十条一模一样的事件。后来排查了很久,才发现是类型不一致导致的。从那之后,我每个去重需求都会先确认 key 的数据类型,必要时先做类型统一再处理。
最后再分享一个小技巧:如果你在 Vue 或 React 项目里需要对列表做频繁的去重和排序,建议用 computed 或 useMemo 把这个操作包起来,同时把去重和排序的结果缓存住,避免每次渲染都重复计算。尤其在列表项很多的情况下,这个优化效果非常明显。
希望这篇内容能帮你把“JS集合去重和排序”这个看似基础、实则充满细节的话题彻底搞清楚。如果有哪里写得不对或者可以优化的地方,欢迎在评论区交流指正,我也还在不断学习的过程中。下次如果你又遇到“明明排序了但结果不对”的诡异 bug,不妨回来翻翻这篇文章,看看是不是又踩进了字典序的坑。
