JavaScript集合去重与排序:从Set到对象字段去重的实战指南
2026/9/24 19:00:12 网站建设 项目流程

很多前端人第一次接触“集合去重和排序”这两个操作,都是从面试题开始,或者从实际需求里被迫学会的。我以前也是,遇到重复数据就[...new Set(arr)],遇到排序就arr.sort((a, b) => a - b),以为这两行代码就是全部答案。直到后来在处理对象数组、字段合并、大数据量排序时接连踩坑,才发现这里面的门道比想象中多得多。

这篇文章把我在真实项目里积累的经验一次说清楚。全文围绕“去重”和“排序”这两件事,从最基本的值类型数组,到对象数组按字段去重,再到多条件排序、稳定性处理、性能优化,最后封装成可以直接抄走用的工具函数。不管你是刚入行的前端新人,还是已经在写复杂中后台系统、天天跟表格数据打交道的开发者,应该都能从这里找到有用的东西。

1. 去重与排序的整体设计思路

1.1 先想清楚:你处理的是“值”还是“引用”

很多人去重翻车,根源在于没分清“基本类型值”和“引用类型对象”的区别。

对于[1, 2, 2, 3, '3']这种数组,里面的元素是基本类型(number、string、boolean、null、undefined、symbol、bigint),去重只需要比较值是否相等。但对于对象数组,比如[{id: 1, name: 'a'}, {id: 1, name: 'a'}],这两个对象虽然内容一样,但在内存里是两个不同的引用,用new Set()直接去重是无效的,因为 Set 判断重复用的是SameValueZero算法,它只认引用是否相同,不认内容是否相同。

实际项目里,我们更常遇到的是“根据对象某个唯一字段去重”,比如用户列表按userId去重,订单列表按orderNo去重。这种场景下,不能用Set直接兜底,需要自己维护一个“已经见过的 key”的映射表。

1.2 明确排序的稳定性和效率需求

排序算法本身就有十几种:冒泡、选择、插入、希尔、归并、快排、堆排、计数、基数、桶排……但 JavaScript 的Array.prototype.sort()已经帮我们封装好了底层排序,V8 引擎在数组长度小于等于 10 时使用插入排序,大于 10 时使用快速排序(现在新版 V8 用的是 TimSort 的变体),它在大多数情况下是稳定排序。

这里有一个关键点:排序稳定性。如果你的数据先按时间排了一次,又按城市排了一次,而第二次排序是不稳定的,那么同城市内部的相对顺序就会被打乱。JS 的sort()在现代引擎里是稳定的,但如果你自己写快排,稍不注意就会变成不稳定排序。所以在绝大多数业务场景下,我建议直接使用内置sort(),不要自己造排序轮子。

1.3 组合思路:先去重再排序,还是先排序再去重

有人会问,这两个操作谁先谁后有区别吗?有,而且区别很大。

如果先去重,再去排序,那么去重阶段只需要一个SetMap,时间复杂度 O(n);排序阶段用内置sort(),时间复杂度 O(n log n)。整体复杂度是 O(n log n)。

如果先排序,再去重,那么排序后相同的元素会相邻,去重时可以只比较当前元素和前一个元素。这样去重逻辑会更简单,但排序同样要花 O(n log n)。另外有个陷阱:如果对象数组根据多个字段判断是否重复,先排序再去重可能会因为排序字段和去重字段不一致而漏掉或错杀数据。

所以我的建议是:默认先去重,再排序。这样思路清晰,而且在大部分数据量场景下性能已经足够。只有当你的数据量达到几十万上百万级别,且需要极致的性能优化时,才需要去琢磨“先排序相邻去重”这种 O(n log n) 内完成的方案。

2. 数值数组去重:从暴力到优雅

2.1 双重循环去重

最朴素的方法,就是两层循环,外层遍历原数组,内层遍历结果数组,看当前元素是否已经在结果里出现过。

function uniqueDup(arr) { const result = []; for (let i = 0; i < arr.length; i++) { let isDuplicate = false; for (let j = 0; j < result.length; j++) { if (arr[i] === result[j]) { isDuplicate = true; break; } } if (!isDuplicate) { result.push(arr[i]); } } return result; }

这个方法的优点是兼容性极好,代码逻辑直观,适合教学和小数据量场景。缺点是时间复杂度是 O(n²),数据量一大就慢得离谱。比如 1 万个元素的数组,最坏情况下要比较约 5000 万次,浏览器分分钟卡顿。

2.2 indexOf/includes 去重

indexOfincludes可以替代内层循环,代码更简洁,但本质上时间复杂度还是 O(n²),因为indexOf内部同样是线性查找。

const arr = [1, 2, 2, 3, 4, 4, 5]; const result = arr.filter((item, index) => arr.indexOf(item) === index);

这里filter遍历整个数组,indexOf又从头查找,所以每个元素都要往复扫描一遍。好处是写法很“函数式”,可读性不错;坏处是性能依然不理想,而且对于NaN这类特殊值,indexOf找不到(indexOf用的是严格相等,NaN !== NaN),用includes则可以正确处理NaN

2.3 Set 去重:现代 JavaScript 的默认答案

ES6 引入Set之后,去重变成了一行代码:

const uniqueArr = [...new Set([1, 2, 2, 3, 3, 4])]; // [1, 2, 3, 4]

Set内部使用哈希表实现,插入和查找的平均时间复杂度都是 O(1),所以整体去重是 O(n)。这是目前处理基本类型数组去重最推荐的方式。

这里要补充一个细节:Set的判重规则是SameValueZero,它把NaN视为相等。所以[NaN, NaN]用 Set 去重后是[NaN],而用indexOf去重做不到这一点。另外,new Set([-0, 0])只保留一个 0,因为SameValueZero认为-00是相等的。这些边界行为在实际开发中需要留意。

2.4 保持首次出现顺序的技巧

Set 去重天然保持元素的首次出现顺序,因为 Set 底层链表和哈希表结合,迭代顺序就是插入顺序。所以:

const arr = [3, 1, 2, 1, 3, 4, 2]; const unique = [...new Set(arr)]; // [3, 1, 2, 4]

你会发现,去重结果里 3 在 1 前面,1 在 2 前面,和原数组首次出现顺序一致。这在某些场景很重要,比如保持用户选择的操作顺序,或者保持后端返回的数据顺序。如果需求是“去重后还要按数值升序”,那就直接[...new Set(arr)].sort((a, b) => a - b)

2.5 大数据量下的性能实测

我之前用 10 万个随机整数做过一次简单测试(在本机 Chrome 里跑),结果大概是这样:

方案耗时结论
双重循环卡死,几秒无响应不可用
filter + indexOf约 2000ms勉强可用,但慢
Set约 5ms强烈推荐

这个量级差距非常直观。所以凡是数组元素是基本类型,且数据量可能超过几百上千,无脑用Set就好。不要觉得“反正数据量不大”,接口哪天加个分页全量返回,性能说崩就崩。

3. 对象数组去重:按字段去重的正确姿势

3.1 为什么 Set 对对象数组无效

看这个例子:

const list = [ { id: 1, name: 'a' }, { id: 1, name: 'a' }, ]; console.log(new Set(list).size); // 2

两个对象内容相同,但 Set 认为它们不重复,因为它们是两个不同的对象引用。这和基本类型的行为完全不一样。

3.2 使用 Map 按唯一键去重

最常见的做法是使用Map,以对象的某个唯一标识字段作为 key:

const list = [ { id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 1, name: 'a' }, { id: 3, name: 'c' }, ]; function uniqueByField(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 = uniqueByField(list, 'id'); // [{id:1,name:'a'}, {id:2,name:'b'}, {id:3,name:'c'}]

这里有几个细节值得注意:

第一,Map的 key 可以是任意类型,但如果我们用对象作为 key,会因为对象引用不同而失效。所以一定要用基本类型字段作为 key,比如字符串 id、数字 code 等。

第二,保留的是“第一次出现”的那一项,因为我们在!map.has(item[key])时才set。如果你希望保留最后一次出现,可以改成每次都map.set(item[key], item),这样最后 map 里保存的就是遍历过程中的最后一项。

第三,Map 的遍历顺序是插入顺序,所以[...map.values()]返回的数组保持了每组重复项中第一次出现的顺序。

3.3 更通用的多字段去重

如果去重依据不是单个字段,而是多个字段组合,比如根据type + id去重,可以把多个字段拼成一个字符串作为 key:

function uniqueByFields(arr, fields) { const map = new Map(); for (const item of arr) { const key = fields.map((f) => item[f]).join('|'); if (!map.has(key)) { map.set(key, item); } } return [...map.values()]; } const result = uniqueByFields(list, ['type', 'id']);

拼接的时候要注意,字段值本身如果包含分隔符|,可能导致不同的组合拼出相同的 key。稳妥的做法是使用JSON.stringify(fields.map(...))或自定义一个稳定的序列化函数。但在大多数业务场景中,用一个不太可能出现在业务数据里的分隔符就够了,不必过度设计。

3.4 全对象深比较去重:什么时候需要

有时候你需要比较整个对象的内容,而不是某个唯一键。这通常出现在你需要合并两份可能存在嵌套结构的配置数据时。最简单粗暴的方案是把每个对象序列化为字符串,再用 Set 去重:

function uniqueByDeep(objArr) { const seen = new Set(); return objArr.filter((obj) => { const key = JSON.stringify(obj); if (seen.has(key)) { return false; } seen.add(key); return true; }); }

但这里有一个大坑:JSON.stringify对同一个对象,如果键值顺序不同,序列化结果会不同;如果对象里有undefined值、函数、Symbol属性,序列化时会丢失;NaN会被序列化成null。所以这种方案适合“结构非常规整”的数据,比如后端返回的标准化 JSON 配置,而对包含多种类型、键顺序不稳定的对象,容易误判。

如果想要严谨的深度比较,可以引入lodashisEqual,或者自己写递归比较。但说实话,生产环境里真正需要“全对象深比较去重”的需求并不多见,大部分场景都是“指定唯一键去重”,所以不要一上来就上重型工具。

4. 排序:不只是sort((a,b)=>a-b)那么简单

4.1 认识默认的字符串排序

很多人第一次用sort()时会被坑到:

const arr = [10, 9, 100, 2]; arr.sort(); // [10, 100, 2, 9]

为什么?因为默认的sort()会把元素转换成字符串,按 Unicode 码点排序。所以“10”和“100”都排在“2”前面。想要按数值大小排序,必须传入比较函数(a, b) => a - b

const arr = [10, 9, 100, 2]; arr.sort((a, b) => a - b); // [2, 9, 10, 100]

这是新手最容易踩的第一个排序坑。要理解的是,比较函数返回负数、零、正数,分别表示 a 应该排在 b 前面、两者相等、a 应该排在 b 后面。

4.2 升序、降序、中文排序、混合排序

升序:arr.sort((a, b) => a - b)

降序:arr.sort((a, b) => b - a)

字符串按字母顺序排:

const names = ['Bob', 'Alice', 'Carol']; names.sort((a, b) => a.localeCompare(b)); // ['Alice', 'Bob', 'Carol']

中文按拼音排:

const chineseNames = ['张三', '李四', '王五']; chineseNames.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));

localeCompare可以用本地化规则处理中文拼音、声调等,在中文业务场景里特别常用。比如城市列表按拼音排序,用localeCompare就好。

混合排序:有时候你要先按一个字段排序,再按另一个字段排序。可以这么写:

const users = [ { name: '张三', age: 30 }, { name: '李四', age: 25 }, { name: '王五', age: 25 }, ]; users.sort((a, b) => { if (a.age !== b.age) { return a.age - b.age; // 先按 age 升序 } return a.name.localeCompare(b.name); // 再按 name 升序 });

这种方式非常直观,也不容易出错。

4.3 对象数组排序的常见写法

实际业务中,对对象数组排序的需求一般是:

  • 按数值字段排序(价格、数量、时间戳)
  • 按日期字符串排序
  • 按多个字段组合排序(例如楼层 + 房间号)
  • 按自定义业务顺序排序(例如状态:待处理 < 处理中 < 已完成)

按时间字符串排序,如果日期格式是YYYY-MM-DD HH:mm:ss,可以直接用字符串比较:

const list = [ { time: '2024-03-01 10:00:00' }, { time: '2024-02-28 12:30:00' }, ]; list.sort((a, b) => a.time.localeCompare(b.time));

如果是时间戳数字,直接相减就行。

按自定义业务顺序排序,可以用一个映射表:

const statusOrder = { pending: 0, processing: 1, completed: 2 }; const tasks = [{ status: 'completed' }, { status: 'pending' }, { status: 'processing' }]; tasks.sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);

这种方法很实用,比在比较函数里写一堆 if-else 要清爽得多。

4.4 排序稳定性:这是个一直被忽视的问题

排序稳定性的意思是:当两个元素的排序依据相等时,它们原本的相对顺序在排序后不会改变。

早期的 V8 快排不是稳定排序,后来 V8 升级到 TimSort(混合了归并和插入排序)后,sort()才稳定。现代浏览器基本都支持稳定排序,但如果你自己实现排序算法,或者在某些老环境里跑,还是要注意。

比如用户表格,先按“注册时间”升序排好了,然后用户点击“年龄”列,希望年龄相同的人仍然保持注册时间的先后顺序。得益于稳定排序,你只需按年龄排序即可,不需要再把注册时间纳入排序条件。

如果担心环境不稳定,可以显式地把次要排序条件写进去:

users.sort((a, b) => { if (a.age !== b.age) return a.age - b.age; return a.createdAt - b.createdAt; });

这样最保险。

4.5 大数据量排序的注意事项

内置sort()用的是优化过的混合排序算法,性能通常足够好。但有一个容易被忽略的问题:排序是原地修改原数组的

const arr = [3, 1, 2]; const sorted = arr.sort((a, b) => a - b); console.log(arr); // [1, 2, 3] console.log(sorted); // [1, 2, 3]

如果你不想改动原数组,需要先拷贝:

const newArr = [...arr].sort((a, b) => a - b);

另外,当数组规模非常大时(几十万条以上),排序本身会占用额外内存(归并排序的特性),但一般不用担心。真正的问题往往出现在排序回调函数写得过于复杂。比较函数会被调用很多次,如果每次比较都做昂贵的计算(比如正则匹配、深拷贝),性能就会急剧下降。这时候可以考虑预处理,把需要比较的字段提取出来,建立索引数组再排序。

5. 实战:把去重和排序封装成可复用的工具函数

5.1 一个完整的基础工具函数

我在实际项目中,会把这些逻辑封装成一个小模块,放工具库里。下面是一个相对完整的基础版本:

/** * 数组按指定字段去重,返回新数组,不修改原数组 * @param {Array} arr - 原数组 * @param {string|string[]} key - 单个字段或字段数组 * @param {boolean} keepLast - 是否保留最后一项,默认保留第一项 * @returns {Array} 去重后的新数组 */ function uniqueBy(arr, key, keepLast = false) { const fields = Array.isArray(key) ? key : [key]; const map = new Map(); for (const item of arr) { const k = fields.map((f) => item[f]).join('__'); if (keepLast) { map.set(k, item); } else if (!map.has(k)) { map.set(k, item); } } return [...map.values()]; } /** * 数组排序,支持多个排序条件 * @param {Array} arr - 原数组 * @param {Array} sortKeys - 排序配置数组,例如 [{ field: 'age', order: 'asc' }, { field: 'name', order: 'desc' }] * @returns {Array} 排序后的新数组(不修改原数组) */ function sortBy(arr, sortKeys) { const clone = [...arr]; return clone.sort((a, b) => { for (const conf of sortKeys) { const { field, order = 'asc' } = conf; const aVal = a[field]; const bVal = b[field]; if (aVal < bVal) return order === 'asc' ? -1 : 1; if (aVal > bVal) return order === 'asc' ? 1 : -1; } return 0; }); } export { uniqueBy, sortBy };

用法示例:

const data = [ { id: 1, name: '张三', age: 30, city: '北京' }, { id: 2, name: '李四', age: 25, city: '上海' }, { id: 1, name: '张三', age: 30, city: '北京' }, { id: 3, name: '王五', age: 25, city: '北京' }, ]; // 按 id 去重 const uniqueData = uniqueBy(data, 'id'); // 先按 city 升序,再按 age 升序 const sortedData = sortBy(uniqueData, [ { field: 'city', order: 'asc' }, { field: 'age', order: 'asc' }, ]);

这里有几个设计上的考量:

Map而不是{}做去重映射表,是因为Map的 key 支持任意类型,而且迭代顺序稳定。如果用普通对象,key 会被强制转成字符串,而且如果你要按数字 id 去重,nullundefined'__proto__'这些值还容易出现意外。

排序函数接收一个条件数组,而不是写死单个字段,好处是调用方可以灵活组合排序规则,且逻辑非常自然。

5.2 基础类型去重+数值排序的合并写法

有时我们要处理的是这样的流程:接口返回一个数组,里面可能有重复,也可能顺序乱七八糟,我们需要展示一个有序、无重复的列表。比如某个下拉框选项,去重后还要按 label 排序:

const options = ['react', 'vue', 'angular', 'vue', 'svelte', 'react']; const uniqueSorted = [...new Set(options)].sort((a, b) => a.localeCompare(b)); // ['angular', 'react', 'svelte', 'vue']

对这种简单场景,一行搞定,完全不需要引入工具函数。

5.3 去重后排序的边界情况:空值处理

实际业务中,数据往往带有空值。排序时空值怎么处理,是前端经常要跟产品确认的。比如商品列表按价格排序,没有价格(null)的商品应该排最后还是排最前?这没有统一答案,不同的产品有不同的预期。

常见的策略是把空值排到最后:

list.sort((a, b) => { const aPrice = a.price ?? Infinity; const bPrice = b.price ?? Infinity; return aPrice - bPrice; });

这里用??null/undefined统一替换成一个足够大的数值,这样没有价格的商品会沉到末尾。如果希望空值排最前,就换成-Infinity

去重时也要注意空值字段。按字段去重时,如果多条记录的 id 都是null,它们会被视为重复,最终只保留一条。这通常符合预期,但也可能不符合——比如两条临时数据都没有 id,但内容不同,你不能把它们合并。所以去重字段最好一定得有值,否则需要先做数据清洗,或者把空值处理规则明确下来。

6. 细节与性能:从底层原理理解为什么这些写法有效

6.1 Set 和 Map 底层的哈希机制

Set之所以能做到近似 O(1) 的查找,是因为它内部使用哈希表(在 V8 中实际是哈希表 + 线性探测的组合实现)。插入一个元素时,会计算它的哈希值,定位到哈希桶,如果桶里已经存在相同值(按SameValueZero规则判断),就认为重复。

这也是为什么Set对基本类型很快,但对对象类型“很笨”——两个内容相同的对象,它们的哈希值不同(因为哈希基于对象引用),所以永远不重复。

Map的 key 机制类似。如果你用普通对象作为 key,那去重实际上还是按引用去重,没有意义。要用基本类型字段作为 key,才能实现“字段值去重”。

6.2 JS 内置排序到底是什么算法

V8 引擎的Array.prototype.sort经过多次演变。早期版本,数组长度 ≤ 10 时用插入排序,大于 10 时用快速排序。快速排序不稳定,而且最坏情况复杂度可能退化到 O(n²)。后来 V8 采用了 TimSort,这是 Python 的list.sort也在用的算法,本质上是“归并排序 + 插入排序”的混合体。

TimSort 在“近乎有序”的数据上表现特别好,复杂度接近 O(n),而随机数据上是 O(n log n)。所以咱们用内置sort(),在绝大多数情况下不仅省事,性能也靠谱。自己手写快排反而容易因为基准值选得不好而翻车。

6.3 空间复杂度和内存占用

Set去重需要额外维护一个哈希表,空间复杂度 O(n)。当 n 是百万级别时,可能占用不小的内存。但说实话,前端场景里处理百万级纯数组的机会不多,真要处理,一般也是后端先做。如果前端真的遇到超大数组(比如从 WebSocket 拿全量数据),可能更好的方案是让后端做去重,或者前端做分页 + 流式处理。

sort()内部分配的额外内存取决于具体算法。V8 的 TimSort 在需要合并归并段时可能会申请额外数组。这个内存申请在排序结束之后会释放,一般不会常驻。所以对常规业务数据,无需过度焦虑。

6.4 避免在排序回调里做太多事

这是一个特别容易踩的性能坑。看这个写法:

list.sort((a, b) => { const aKey = a.profile.settings.theme ?? 'default'; const bKey = b.profile.settings.theme ?? 'default'; return aKey.localeCompare(bKey); });

如果list有 10 万条,sort回调会被调用约 150 万次(实际次数取决于算法和数据)。每次调用都去深层取一次profile.settings.theme,性能开销非常大。优化方法是先把排序字段提取到一个临时数组,排序后再组装回对象,或者用装饰模式:

const decorated = list.map((item, index) => ({ item, key: item.profile.settings.theme ?? 'default', index, })); decorated.sort((a, b) => a.key.localeCompare(b.key) || a.index - b.index); const result = decorated.map((d) => d.item);

这种“装饰-排序-还原”模式,既避免了重复取值的开销,又能保证稳定排序(通过 index 做次要排序)。这是一种很经典的优化思路。

7. 常见问题与排查技巧实录

7.1 NaN 去重失效

问题:[NaN, NaN].filter((item, index) => arr.indexOf(item) === index)得到的是[NaN, NaN],去重没生效。

原因:indexOf内部使用严格相等比较,NaN === NaNfalse,所以找不到。

解决:改用Setincludesincludes使用SameValueZero,会把 NaN 视为相等)。

const arr = [NaN, NaN]; console.log([...new Set(arr)]); // [NaN]

7.2 数字和字符串自动转换导致排序错乱

问题:[10, '9', 2].sort()排成了[10, '9', 2],不符合预期。

原因:默认排序转字符串比较,“10”排在“2”前面。

解决:确保数据类型一致,并传比较函数。

const arr = [10, '9', 2].map(Number).sort((a, b) => a - b);

7.3 对象数组按数字字段排序,但字段是字符串

问题:[{price: '10'}, {price: '9'}]a.price - b.price排序,结果不对?

实际上'10' - '9'会被强制转成数字,JavaScript 的减号操作符会把两个操作数转为 number,所以结果还是 1,没问题。但如果用localeCompare或者直接<比较字符串,就会出现“10”排在“9”前的问题。所以最好在数据源上就保证字段类型统一。

7.4 排序是原地修改的问题

问题:调用arr.sort()之后,arr本身变了,有时候逻辑上希望原数组不变。

解决:使用[...arr].sort(...)arr.slice().sort(...)拷贝后再排序。

7.5 localeCompare 的性能

localeCompare虽然方便,但性能比普通比较慢。如果只是比较 ASCII 字符串(比如英文 id),直接用<>更快。如果是中文拼音排序,才需要考虑localeCompare

7.6 多条件排序的优先级理解

写多个排序条件时,最容易出现的错误是“后面的条件把前面的条件覆盖了”。比如:

list.sort((a, b) => a.age - b.age); list.sort((a, b) => a.name.localeCompare(b.name));

这样最后一个sort会完全忽略前面的排序,因为第二次排序是在第一次排序的结果上重新排列的,如果第二次排序算法稳定,第一次排序只在“name 相等”的情况下才会保留相对顺序。最终效果其实等价于“先按 age 排,再按 name 排”中的次要条件,而不是“同时按两个字段排”。要同时按多个字段排序,必须在同一个比较函数里写 if-else,或者像我封装的sortBy一样循环处理。

7.7 去重后数量变化不符合预期

有时候你按 id 去重,结果发现比预期的少。排查思路:

  1. 打印原始数组的 id 列表,看看是不是真的存在重复。
  2. 看看是不是有些 id 是字符串'1',有些是数字1,Map 的 key 区分类型,所以字符串'1'和数字1不会视为重复。如果你希望它们视为重复,需要统一类型。
  3. 看看 id 字段名是否正确。一个隐蔽的坑是接口返回的字段名带空格或下划线,比如"id ",导致取值取不到,所有项的 key 都是undefined,最终只剩一条。

这类问题通常在联调时出现,多打 console 看看就好。

8. 生产环境中的综合案例

8.1 场景:用户列表展示

假设有一个后台管理系统,需要展示用户列表。接口返回了两份数据:一份来自用户服务,一份来自权限服务,需要按用户 id 合并去重,然后按创建时间降序展示。

const userServiceData = [ { id: 1, name: '张三', createdAt: '2024-01-01' }, { id: 2, name: '李四', createdAt: '2024-02-01' }, ]; const authServiceData = [ { id: 2, name: '李四', role: 'admin', createdAt: '2024-02-01' }, { id: 3, name: '王五', role: 'user', createdAt: '2024-03-01' }, ]; const merged = [...userServiceData, ...authServiceData]; const unique = uniqueBy(merged, 'id'); const sorted = sortBy(unique, [{ field: 'createdAt', order: 'desc' }]);

这里uniqueBy保留的是先出现的那条,也就是 userServiceData 的数据。如果权限服务里更新了用户的名称或角色,可能需要合并字段而不是简单丢弃。这时候可以考虑把两份数据各自处理成 Map,再做字段合并。

8.2 场景:前端下拉框选项排序

后端返回的商品分类列表可能有重复,需要按分类名称拼音排序,同时去重。

const rawCategories = ['水果', '蔬菜', '水果', '肉类', '水产', '蔬菜']; const categories = [...new Set(rawCategories)].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));

8.3 场景:表格批量操作任务队列

表格里选择多行数据做批量操作,为了避免重复选择同一行,需要按行 id 去重,并且保持用户勾选的先后顺序。

const selectedRows = [ { id: 10, task: '编译' }, { id: 7, task: '测试' }, { id: 10, task: '编译' }, { id: 3, task: '发布' }, ]; const uniqueRows = uniqueBy(selectedRows, 'id'); // [{id:10, task:'编译'}, {id:7, task:'测试'}, {id:3, task:'发布'}]

去重顺序由 Map 的迭代顺序保证,所以用户先勾选的行会排在前面。

9. 一些值得沉淀的经验与最终建议

说到底,“JS集合去重和排序”不是一个孤立的小知识点,它背后涉及数据结构的选型、算法复杂度、JavaScript 语言特性、实际业务场景的边界判断,这些东西组合在一起,才构成了一个合格的前端开发者处理数据的基本功。

我个人的经验是,写任何去重排序逻辑之前,先问自己三个问题:

第一,数据是什么类型?基本类型数组还是对象数组?对象数组按哪个字段去重?

第二,性能要求是什么?数据量大概多大?如果只有几十条,就算写得烂一点也无所谓;如果有几万条,就得考虑用 Set/Map,不能写 O(n²) 的双重循环。

第三,排序的语义是什么?是数值大小排,还是字符串字典序排,还是中文拼音排?空值怎么处理?多字段排序的优先级是什么?这些不确定的时候,宁可去问产品经理,也不要自己拍脑袋,因为排序和去重直接影响用户看到的列表顺序,错了很难察觉,又很影响体验。

再分享一个小技巧:日常开发中,把uniqueBysortBy这类函数沉淀到团队自己的 utils 库里,写好 JSDoc 注释,加上单测,能省掉大量重复劳动。不要每次用到都在业务代码里重新写一遍,写着写着就会写出不同行为,最后维护起来很痛苦。

写到这里,关于“JS集合去重和排序”我能想到的坑和方案基本都过了一遍。如果你在实际开发中也有遇到过其他奇奇怪怪的去重排序问题,那大概率也是某个边界条件没处理到位。回到原理层面,再检查一遍数据类型、判重规则、稳定性和空值逻辑,基本都能找到答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询