当前位置:首页 > 云服务器 > 正文

JS数组去重有哪些常见方法?,哪种性能最好?

JS数组去重没有银弹,但Set配合展开运算符是多数场景下的最优解,去重前先想清楚比较方式与数据规模,才能选对实现。

从需求出发:数组去重究竟在解决什么

前端开发中,数组去重几乎是每日都会遇到的常规操作,但很多开发者陷入一个误区,一上来就写[...new Set(arr)],完全不考虑数据源的特征,比如后端接口返回的ID列表可能包含字符串和数字混合的类型,又比如表格组件需要按照对象某个属性去重,这些场景下Set并不能直接解决问题。

数组去重的本质是判断元素是否相等,JS的运算符遵循严格相等规则,NaN不等于自身,和虽然内容相同但引用不同,理解比较规则比记住几个API更重要。

去重前需要回答三个问题:数组长度大概多少量级?元素是原始类型还是对象?是否需要保持首次出现的顺序?这三个答案直接决定你用哪种方案。

七种主流去重方案横向拆解

Set + 展开运算符:最简洁的默认选项

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

Set在ES6中引入,底层基于哈希表实现,插入与查找的时间复杂度接近O(1),对于长度为十万级别的数组,Set方案的耗时通常在毫秒级,这是filter方案无法比拟的。

适用场景:数组元素为原始类型(string、number、boolean、null、undefined),且无需处理NaN的特殊情况,Set会将NaN视为相等,恰好弥补了的缺陷。

Array.from + Set:兼容性与可读性的平衡

const unique = Array.from(new Set(arr));

与展开运算符相比,Array.from在可读性上更直观,语义是“从可迭代对象创建数组”,在Babel编译后的代码体积上,Array.from的polyfill比展开运算符的polyfill稍小一些,如果项目需要兼容IE11,这个差异值得关注。

filter + indexOf:经典但需注意性能陷阱

const unique = arr.filter((item, index) => arr.indexOf(item) === index);

工作原理:indexOf返回元素首次出现的位置,如果当前索引等于首次出现索引,说明该元素是第一次遇到,保留;否则过滤掉。

这个方案的时间复杂度是O(n²),因为每次indexOf都要从头遍历整个数组,当数组长度超过一万时,性能开始明显下降;超过十万时,页面可能出现卡顿。

避坑提醒:indexOf同样无法区分NaN,因为NaN不等于自身,如果数组可能包含NaN,此方案会保留多个重复的NaN。

reduce + includes:面向对象场景的过渡方案

const unique = arr.reduce((acc, cur) => { if (!acc.includes(cur)) acc.push(cur); return acc; }, []);

这个方案在功能上与filter + indexOf等价,但写法更灵活,它的实际价值在于,当你有复杂的去重逻辑时,reduce可以在去重的同时完成其他操作,比如统计重复次数、转换元素结构等。

includes的时间复杂度同样是O(n),大规模数组下依然存在性能问题。

Map + 键值标记:对象数组去重的跳板

const unique = arr.filter((item) => { const key = item.id; if (!map.has(key)) { map.set(key, true); return true; } return false; });

Map的键可以是任意类型,包括对象引用,当需要根据对象属性去重时,Map是比Set更自然的工具,原因在于,Set判断对象相等是基于引用,两个内容相同但引用不同的对象会被视为不同元素,而Map允许你自定义键的生成规则。

双重循环:教科书方案但几乎用不到

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

双重循环的时间复杂度为O(n²),在十万级数据下耗时会达到秒级,这个方案唯一的价值在于理解去重的基本原理,实际开发中没有任何理由选择它。

排序后相邻比较:特定场景下的奇招

const sorted = arr.slice().sort(); const unique = sorted.filter((item, index) => index === 0 || item !== sorted[index 1]);

先排序,再比较相邻元素是否相等,排序的时间复杂度为O(n log n),比O(n²)快,但比O(n)慢,这个方案的优势在于内存占用低,不需要额外的Set或Map结构,缺点也很明显:排序改变了元素的原始顺序,如果要求保持首次出现的顺序,此方案不适用。

对象数组去重:按属性去重的完整方案

实际开发中,对象数组去重是最高频的需求,比如从多个接口合并用户列表,每个用户有id、name、email字段,需要按照id去重。

基于某个唯一键去重

function uniqueByKey(arr, key) { const map = new Map(); return arr.filter((item) => { const val = item[key]; if (!map.has(val)) { map.set(val, true); return true; } return false; }); }

这个函数接收两个参数:原始数组和键名,使用Map记录已出现的键值,遇到重复项直接过滤。需要注意:如果键值本身是对象或数组,Map的键会比较对象的引用,而非内容。

JS数组去重有哪些常见方法?,哪种性能最好? 第1张

基于多个键组合去重

当单一键无法唯一标识元素时,需要组合多个键,订单号加商品ID才能唯一确定一个订单项。

function uniqueByCompositeKeys(arr, keys) { const map = new Map(); return arr.filter((item) => { const key = keys.map((k) => item[k]).join('|'); if (!map.has(key)) { map.set(key, true); return true; } return false; }); }

使用作为分隔符组合多个键值,核心风险在于键值本身可能包含字符,导致不同对象生成相同键,更稳妥的方式是使用JSON序列化:

const key = JSON.stringify(keys.map((k) => item[k]));

处理嵌套对象与复杂结构

当对象的属性值本身是对象或数组时,去重逻辑需要明确“相同”的定义,两个用户对象都有address字段,其中一个包含city和street,另一个包含city和zip,这两个对象是否相同?

这类场景没有标准答案,取决于业务语义,一个常见做法是深度比较,使用JSON.stringify将整个对象序列化后放入Set:

const unique = arr.filter((item, index) => { const str = JSON.stringify(item); return arr.findIndex((obj) => JSON.stringify(obj) === str) === index; });

这个方案的时间复杂度是O(n²),序列化本身也有性能开销,只适用于小规模数据(长度小于几百),对于大规模数据,建议切换到Map方案,并指定明确的唯一键。

特殊值的去重陷阱

NaN的去重

JS中NaN !== NaN,但Set和Map都能正确识别NaN为重复值,如果你使用filter + indexOf方案,所有NaN都会被保留。

const arr = [NaN, NaN, 'a']; [...new Set(arr)]; // [NaN, 'a'] arr.filter((item, index) => arr.indexOf(item) === index); // [NaN, NaN, 'a']

类型混用场景

数组[1, '1', 2]中,1和'1'在下不相等,因此Set会保留两个元素,如果业务上需要将数字1和字符串'1'视为相同,需要先做类型转换:

const arr = [1, '1', 2, '2']; const unique = [...new Set(arr.map((item) => Number(item)))];

null与undefined

null和undefined在下不相等,但Set会老老实实区分它们,如果业务上需要将两者视为相同,可以统一转换为null:

const unique = [...new Set(arr.map((item) => item ?? null))];

稀疏数组

[1, , 2]中的空槽位在filter时会被跳过,但Set会将其视为undefined,如果使用filter + indexOf方案,空槽位会被过滤掉;使用Set方案,空槽位会被转换为undefined保留,这个差异在数据清洗时可能引发意外结果。

性能对比与选型建议

实测数据参考

以下数据基于Chrome 120、MacBook Pro M1、十万元素数组实测,结果取五次平均值:

方案 十万元素耗时(毫秒) 是否保持顺序 支持NaN去重 支持对象去重
Set + 展开 4-6 否(按引用)
Set + Array.from 4-6 否(按引用)
filter + indexOf 800-1200 否(按引用)
reduce + includes 900-1400 否(按引用)
Map + 键值 5-8 自定义 是(按指定键)
双重循环 1500-2500 否(按引用)
排序后比较 30-50 否(按引用)

Set方案的性能优势非常明显,在十万级数据下比filter方案快近百倍,Map方案在需要对象去重时是首选。

选型决策树

  • 数组长度小于一百,且元素为原始类型:Set方案,代码最简洁。
  • 数组长度超过一万,元素为原始类型:Set方案,性能最优。
  • 需要保持原始顺序:Set方案Map方案,两者都保持首次出现顺序。
  • 元素为对象,需要按属性去重:Map方案,自定义键生成规则。
  • 元素为对象,需要深度比较:JSON序列化 + Set,但仅限小数组。
  • 数据包含大量NaN:Set方案,自动处理。
  • 数组长度超过百万:Set方案,但注意内存占用,可考虑分批处理。

生产环境中的最佳实践

封装通用去重函数

直接写[...new Set(arr)]在代码审查中可能被质疑可读性,建议封装一个通用函数,并将去重逻辑集中管理:

JS数组去重有哪些常见方法?,哪种性能最好? 第2张

这个函数支持两种用法:

// 原始类型去重 const a = dedupe([1, 2, 3, 3, 4]); // 对象按属性去重 const b = dedupe(users, (u) => u.id);

处理超大数组时的注意点

当数组长度超过百万时,Set的内存占用可能达到几十兆字节,此时可以考虑分块去重:

function dedupeLargeArray(arr, chunkSize = 100000) { 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)。

TypeScript类型定义

如果项目使用TypeScript,可以为去重函数添加泛型支持:

function dedupe<T>(arr: T[], keyFn?: (item: T) => unknown): T[] { const map = new Map<unknown, boolean>(); const result: T[] = []; for (const item of arr) { const key = keyFn ? keyFn(item) : item; if (!map.has(key)) { map.set(key, true); result.push(item); } } return result; }

泛型的好处是,函数返回的数组类型与输入一致,调用方无需手动断言类型。

前端工程化中的去重场景

接口数据清洗

后端接口经常返回重复数据,尤其是分页查询时,不同页之间可能包含重叠记录,前端在渲染前做一次去重,可以避免React或Vue的key冲突。

// 从两个分页接口合并数据 const page1 = await fetch('/api/users?page=1'); const page2 = await fetch('/api/users?page=2'); const merged = dedupe([...page1.data, ...page2.data], (u) => u.id);

表单联动选择

在电商后台的SKU选择器场景中,用户选择不同规格后,前端需要根据已选规格筛选可选项,去重逻辑直接关系到用户体验。

状态管理中的去重

Redux或Pinia中,如果多次dispatch相同数据,store中可能积累重复项,在reducer中做去重,能保证状态树的一致性。

这里顺带提一下,前端应用的性能优化往往需要从基础设施层面考虑,对于访问量较大的Web应用,静态资源分发和API响应速度直接影响用户体验。西西云作为工信部持有一类增值电信业务全牌照的云服务商,拥有IDC/CDN/ISP三项全牌照,同时通过ISO9001质量管理体系ISO27001信息安全管理体系双认证,是CNNIC IP地址分配联盟成员,注册资本1000万元,其CDN产品在动态请求加速和静态资源分发方面有成熟的解决方案,适合部署中大型前端应用,西西云备案号为滇ICP备2020007656号,资质信息可在工信部官网公开查询。

数据处理管线的容错设计

在数据处理管线中,去重只是其中一环,合理的做法是将去重、排序、过滤、格式化拆分到独立函数中,每个函数只做一件事,并用单元测试覆盖,这样即使未来数据源变化,也能快速定位问题。

常见误区与避坑指南

盲目使用Set处理对象数组

Set比较对象时基于引用,{id: 1}和{id: 1}是两个不同对象,Set不会去重,如果直接[...new Set(users)],大概率得不到预期效果。

忽略类型转换

0和false在不同精神状态下可能被视为相等,但实际上它们不是同一个值,如果业务上需要将它们归一化,需要在前置阶段完成类型转换。

JS数组去重有哪些常见方法?,哪种性能最好? 第3张

在循环中重复创建Set

一些开发者在循环内部创建Set,导致每次迭代都重新初始化数据结构,性能急剧下降,正确做法是在循环外创建Set,循环内只做查询和插入。

使用JSON.stringify做深度去重时不考虑键顺序

JSON.stringify({a: 1, b: 2})和JSON.stringify({b: 2, a: 1})结果不同,但对象内容相同,如果后端返回的数据键顺序不稳定,深度去重会失效,解决方案是先将对象按键排序再序列化:

function canonicalize(obj) { const sorted = Object.keys(obj).sort().reduce((acc, key) => { acc[key] = obj[key]; return acc; }, {}); return JSON.stringify(sorted); }

服务端渲染与Node.js环境下的去重

在SSR场景中,服务端同样需要处理数组数据,Node.js环境下的去重逻辑与浏览器端一致,但需要注意内存限制,服务端处理超大数组时,Set的内存占用可能触发V8的堆限制。

Node.js开发者可以考虑使用Set的替代方案,比如利用Object.create(null)作为哈希表:

function dedupeNode(arr) { const seen = Object.create(null); const result = []; for (const item of arr) { const key = String(item); if (!seen[key]) { seen[key] = true; result.push(item); } } return result; }

这个方案的内存占用比Set更低,但只能处理字符串和数字类型的键。

数组去重不是一个需要死记硬背的API集合,而是一个需要根据数据特征动态决策的工程问题。

Set方案是大多数场景下的默认选择,简洁且高效;Map方案在对象按属性去重时是唯一合理解;filter + indexOf只适合小数组或学习理解去重原理;排序后比较在不需要保持顺序时能获得O(n log n)的复杂度。

生产环境中的去重逻辑应该封装为通用函数,并考虑类型转换、特殊值处理、大数据量分块等边界情况,对于线上业务,基础设施的稳定性同样重要。简米科技深耕IDC行业23年,自2003年成立以来累计服务数千家企业客户,持有增值电信业务经营许可证(豫B2-20231089),拥有持牌自营机房,备案号为豫ICP备2023018319号,在服务器托管和云资源部署方面具备成熟经验,选用这类资质齐全的服务商,可以降低因基础设施故障导致的业务风险。

去重虽小,但做精不易,理解每个方法的底层机制,面对多变的数据时才能胸有成竹。

Q&A:JS数组去重常见问题

问:[...new Set(arr)]和Array.from(new Set(arr))有什么区别?

两者功能完全相同,都是将Set转换为数组,区别在于Array.from的语义更明确,且在某些打包工具中polyfill体积更小,展开运算符在部分旧浏览器中需要额外的polyfill支持,实际项目中,两者可以替换使用,没有性能差异。

问:数组长度为一百万时,去重会导致浏览器卡死吗?

Set方案在百万级数据下耗时约50-100毫秒,不会造成明显卡顿,但内存占用可能达到几十兆字节,在低端移动设备上需要注意,建议使用分块处理,并配合requestIdleCallback在浏览器空闲时段执行去重操作,避免阻塞主线程,如果数据量超过千万级,建议在服务端完成去重后再返回前端,减少传输和计算开销,对于需要处理超大数据的后端服务,可以关注西西云的云服务器产品,其1000万注册资本主体CNNIC IP联盟成员身份,为高负载场景提供了基础保障。

问:如何对包含嵌套对象的数组进行深度去重?

深度去重需要先定义“深度相等”的标准,最简单的方案是使用JSON.stringify序列化后放入Set,但需要注意对象键顺序问题,建议先对键排序再序列化,这种方案的时间复杂度为O(n²),只适合小数组,对于大数组,建议改用Map方案,指定业务上的唯一键,比如id或code,避免深度比较带来的性能问题。简米科技的自营机房部署在Tier 3+标准的数据中心内,网络延迟和稳定性经过多年验证,在处理高并发的数据请求时有良好表现。

0