数组去重这个题目,前端面试几乎必考,日常开发也躲不掉。刚工作那会儿,我以为Array.from(new Set(arr))一行搞定就够了,直到被面试官追问“Set 的去重规则是什么?NaN 怎么处理?对象数组怎么按字段去重?”。我才意识到,数组去重背后牵扯的是对 JS 类型系统、相等比较算法、数据结构和性能取舍的理解。这个主题值得掰开揉碎讲清楚。
这篇文章我整理了自己实际用过的 7 种数组去重写法,从最基础的双层循环到现代的 Set 一行流,再到对象数组按字段去重的实战方案。每种方法我都会讲原理、给代码、说坑点,最后再给一个综合的选型参考。不管你是刚入门的前端新人,还是想巩固基础的中级开发者,这篇文章应该都能给你一些不一样的收获。
1. 为什么“数组去重”这么个小问题值得单独写一篇
很多开发者觉得数组去重太简单了,不就是“把重复的去掉嘛”。但当你真正落到代码层面,会发现里面有三个维度的坑:类型判断、顺序保持、性能取舍。这三个维度相互纠缠,不同场景下最优解完全不同。
先说类型判断。JS 数组里的元素可以是数字、字符串、布尔值、NaN、null、undefined、对象、数组甚至函数。不同去重方法对不同类型的处理规则不一样。比如arr.filter((item, index) => arr.indexOf(item) === index)这种经典写法,遇到 NaN 就会失效,因为indexOf内部用的是严格相等(===),而NaN !== NaN。但Set内部用的是SameValueZero算法,能正确处理 NaN。就这一个差别,就足以让你在真实项目里排查半天。
再说顺序保持。有些去重方法会打乱原数组顺序。排序后相邻去重就是一个典型——先去重再排序,顺序全变了。如果你的业务要求保留元素第一次出现的顺序(比如渲染一个列表,希望保留后端返回的顺序),那排序后就全乱了。这一点在选型时必须先想清楚。
最后是性能。双层循环是 O(n²),数据量小的时候无所谓,数据量到了万级,浏览器直接卡死。Set 和 Map 是 O(n),但哈希结构对引用类型和基本类型的处理又不一样。排序去重是 O(nlogn),介于两者之间,但因为原生sort的默认行为是字典序排列,数字数组稍不注意就出 bug。
还有一个很多人忽略的点:面试官问“数组去重”时,本质上不是在考你会不会写代码,而是在考你对 JS 相等比较规则、数据类型特征和算法复杂度的理解深度。一个能写出七种写法并讲清各自优劣的人,和只会背一行 Set 的人,面试官一眼就能分辨。
所以我建议你把这篇文章当成一个系统回顾的机会,而不是又一个收藏夹里的代码片段。我也会在每一节里,把那些“不试不知道、一试吓一跳”的坑点全部标出来。
2. 基础打法:双层循环与 indexOf 写法的边界问题
这一节是很多人的启蒙写法,虽然现在项目里不太直接用了,但理解它们能帮你打牢基本功,也是面试时展示思路演进的重要素材。
2.1 双层循环 + splice:最原始的去重方式
原理很直白:外层循环固定一个元素,内层循环遍历它后面的所有元素,如果发现相同的就把后面的那个删掉。注意 splice 会改变数组长度和索引,所以删完后要让j--,否则会跳过一个元素。
function uniqueByLoop(arr) { const result = [...arr]; // 不修改原数组 for (let i = 0; i < result.length; i++) { for (let j = i + 1; j < result.length; j++) { if (result[i] === result[j]) { result.splice(j, 1); j--; // 删除后索引前移,必须回退一位 } } } return result; }这里有个小技巧:我习惯先用展开运算符[...arr]拷贝一份,这样函数不会污染原数组。很多初学者直接在原数组上splice,调用完原数组就被改了,这在 React 的不可变数据场景里很容易埋雷。
这个方法的判断逻辑是===,也就是严格相等。所以1和'1'不会互相去重,NaN永远不等于自己,两个NaN进去会原样保留。这种方法的复杂度是 O(n²),一万条数据就要执行约五千万次比较,浏览器直接就卡了。
2.2 indexOf + filter:更优雅但同样有坑
filter方法会让每个元素执行一次回调,回调内部用arr.indexOf(item)判断当前元素在数组中第一次出现的位置。如果第一次出现的位置正好等于当前索引,说明前面没有出现过,就保留它。
function uniqueByIndexOf(arr) { return arr.filter((item, index) => arr.indexOf(item) === index); }代码非常简洁,但坑藏在indexOf的相等算法里。它内部使用严格相等,无法识别 NaN。比如:
[NaN, NaN].filter((item, index) => [NaN, NaN].indexOf(item) === index) // 结果:[NaN, NaN],一个都没去重因为indexOf(NaN)永远是 -1,永远不会等于任何索引。解决办法是把indexOf换成includes,因为includes内部用的是SameValueZero算法,能识别 NaN:
function uniqueByIncludes(arr) { const result = []; arr.forEach(item => { if (!result.includes(item)) { result.push(item); } }); return result; }但注意includes底层还是要遍历数组,所以复杂度仍然是 O(n²)。另外,indexOf和includes对-0和0的处理是相同的(视为同一个值),这一点不需要太纠结,但面试偶尔会被问到。
3. 哈希思想引入:对象键值对去重与 Map 的演化
从这一节开始,我们进入 O(n) 的世界。核心思路是用一个额外的哈希结构记录“已经见过的元素”,遍历一遍数组,没见过的就留下,见过的就跳过。JS 中最自然的哈希结构就是普通对象,但它的坑也比较隐蔽。
3.1 对象键值对标记:一切皆字符串的陷阱
最常见的写法是把元素当作对象的 key,遍历时检查 key 是否存在:
function uniqueByObject(arr) { const obj = {}; const result = []; for (let i = 0; i < arr.length; i++) { const item = arr[i]; const key = typeof item + item; // 用类型 + 值 组合成唯一键 if (!obj[key]) { obj[key] = true; result.push(item); } } return result; }这里最关键的一行是typeof item + item。为什么要加类型?因为对象 key 会隐式转换成字符串,1和'1'如果不加类型前缀,就会被当作同一个 key,导致数字 1 和字符串 '1' 互相误伤。加了typeof之后,数字 1 的 key 是"number1",字符串 '1' 的 key 是"string1",二者就区分开了。
但这个方法对对象无能为力。任何对象转成 key 都是"[object Object]",所以两个不同对象哪怕是{a:1}和{b:2},也会被当成相同元素只留一个。所以对象键值对法只适用于基本类型组成的数组。
别忘了null的typeof是"object",null + "object"的 key 是"objectnull",倒是能和对象区分开,但如果你数组里同时有{a:1}和null,key 一个是"[object Object]"一个是"objectnull",实际运行没毛病。不过这类写法本质依赖字符串拼接,可读性一般,维护起来容易出幺蛾子。
3.2 用 Map 替代对象:绕过类型转换的正确姿势
ES6 引入的Map从根本上解决了对象 key 只能存字符串的问题。Map的 key 可以是任何值,数字、字符串、NaN、对象引用,都不会做类型转换。这意味着用Map去重时,1和'1'天然是两个不同的 key,不需要额外拼类型。
function uniqueByMap(arr) { const map = new Map(); const result = []; arr.forEach(item => { if (!map.has(item)) { map.set(item, true); result.push(item); } }); return result; }注意Map的相等判断同样采用SameValueZero算法,所以 NaN 也能正确处理。相比Set,用Map多写几行,但它的价值在于:可以按字段去重。对象数组去重时,我们往往不是要求“整个对象完全相同”,而是要求“某个字段不重复”,比如订单列表按照订单号去重,这种场景 Map 是最顺手的工具。
function uniqueByKey(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 orders = [ { id: 1, name: 'A' }, { id: 2, name: 'B' }, { id: 1, name: 'C' }, ]; const uniqueOrders = uniqueByKey(orders, 'id'); // 结果:[{ id: 1, name: 'A' }, { id: 2, name: 'B' }]这里我用了map.values()保留的是每条记录第一次出现时的完整对象,后面的重复项直接丢弃。如果你想要“后出现的覆盖先出现的”,改成每次map.set(item[key], item)就行,后写的会覆盖先写的。
4. 现代一行流:Set 与 reduce 的函数式写法
4.1 Set + 扩展运算符:日常开发中的首选
Set是 ES6 提供的新数据结构,存储的值永远不会重复。利用这个特性去重,代码可以简化到一行:
function uniqueBySet(arr) { return [...new Set(arr)]; }或者用Array.from写法:
function uniqueBySet(arr) { return Array.from(new Set(arr)); }两种写法等价,[...new Set(arr)]更简洁,Array.from语义上更明确。Set内部的去重规则是SameValueZero,跟includes和Map一致,所以 NaN 能被正确处理,+0和-0被视为同一个值。
Set还天然保持插入顺序,迭代时遇到哪个就是哪个。这意味着去重后的数组顺序和原数组一致,不会像排序去重那样乱序。这在很多 UI 渲染场景里非常重要。
它的局限在于:对引用类型的去重是基于引用地址,而不是内容。两个结构完全相同的对象,只要不是同一个引用,Set就会视为不同元素。所以:
const obj = { a: 1 }; new Set([obj, { a: 1 }, obj]); // 结果包含两个元素:obj 和 { a: 1 }(后者去重不生效)如果你需要对对象数组按内容去重,Set直接失效,得回到Map按字段去重的方案。
4.2 reduce 实现去重:函数式风格的取舍
reduce是Array.prototype上被低估的一个方法,它能把数组归约为任意值,正好适合做去重这种“累积结果”的操作:
function uniqueByReduce(arr) { return arr.reduce((acc, cur) => { if (!acc.includes(cur)) { acc.push(cur); } return acc; }, []); }这段代码的意思是:初始化空数组作为累计结果,对原数组每一项执行回调,如果累计结果中还没有当前元素就 push 进去。写法很函数式,可读性也不错,但复杂度依然是 O(n²),因为includes每次都要线性扫描。
如果你想要 O(n) 的函数式写法,就把includes换成Map:
function uniqueByReduceMap(arr) { const map = new Map(); arr.forEach(item => map.set(item, item)); return [...map.keys()]; }严格说这不是标准意义上的 reduce,但思路是类似的:先建哈希,再取键。这种方式能正确处理基于引用的去重和基本类型的去重,但对象数组的内容去重依然不在它的能力范围内。
我在实际项目里,reduce写法用得不算多。大多数场景一行Set就够了,函数式写法更多是为了在团队代码规范要求“不允许 for 循环”的情况下苟一苟。但面试时能脱口而出这种写法,确实会给面试官留下“你基础很扎实”的印象。
5. 排序去重:另一种思路,但 sort 的默认排序是个大坑
第七种方法换一个思路:不去“查重”,而是先排序,再利用“排序后相同元素必定相邻”的特性,一遍遍历就能去重。理论上复杂度是 O(nlogn),比双层循环快,但比哈希慢。
5.1 相邻比较去重的实现
function uniqueBySort(arr) { const sorted = [...arr].sort(); const result = []; for (let i = 0; i < sorted.length; i++) { if (i === 0 || sorted[i] !== sorted[i - 1]) { result.push(sorted[i]); } } return result; }我先用[...arr].sort()复制了原数组再排序,避免修改原数组。然后遍历排序后的数组,只要当前元素和前一个不同就保留,相同则跳过。
原理简单,但问题集中在sort()的默认行为上。JS 的sort()默认会将元素转换为字符串,然后按 UTF-16 字典序排序。这导致一个经典问题:
[1, 2, 10].sort(); // 结果是 [1, 10, 2],因为 "10" 的字典序在 "2" 之前如果你用排序去重处理数字数组,不做任何处理,顺序直接崩盘。解决办法是传入比较器:
const sorted = [...arr].sort((a, b) => a - b);但这又引入了另一个问题:排序会打乱原数组顺序。如果业务要求保留第一次出现的顺序,排序去重就不适用。比如后端返回的菜单列表有一定排序含义,你只是想去掉重复项但不希望重新排序,那排序去重会给你带来意外“惊喜”。
5.2 什么场景下排序去重是优解
虽然排序去重既有字符串序坑,又有顺序打乱问题,但有一种场景它确实是最优解:你本来就需要对数组排序,顺手再去掉重复项。比如展示一个按价格从低到高排列的商品列表,同时商品 ID 不能重复。这时候先排序再去重,一步到位,总复杂度还是 O(nlogn),不额外增加开销。
再比如面试官问“如果不允许使用额外的空间(不使用 Set、Map、对象),你怎么去重?”答案就是排序后原地去重。这是个很经典的空间复杂度优化题,值得记住。
6. 高频衍生场景:对象数组按字段去重与忽略大小写去重
实际项目中,纯基本类型数组去重的机会远没有你想象的多。真正磨人的是两类衍生需求:对象数组按某个字段去重,以及字符串去重忽略大小写。这两个我在日常需求和面试题里都反复遇到过,单独拿出来讲。
6.1 对象数组按字段去重的通用封装
对象数组去重的核心不是“相同的对象只留一个”,而是“相同字段值的对象只留一个”。最靠谱的方案是用 Map:
function uniqueByField(arr, field) { const map = new Map(); for (const item of arr) { const key = typeof item[field] + item[field]; // 字段可能是数字或字符串 if (!map.has(key)) { map.set(key, item); } } return [...map.values()]; } const users = [ { id: 1, name: '张三', city: '北京' }, { id: 2, name: '李四', city: '上海' }, { id: 1, name: '张三', city: '广州' }, // 与第一条 id 相同 ]; const result = uniqueByField(users, 'id'); // 结果:[{ id: 1, name: '张三', city: '北京' }, { id: 2, name: '李四', city: '上海' }]这里我特意把typeof item[field] + item[field]作为 key,是因为字段可能是数字也可能被后端转成字符串。如果不加类型前缀,id: 1和id: '1'会被当成同一个 key,可能误伤。虽然大多数接口字段类型是稳定的,但防一手省得线上出幺蛾子。
如果你不想让调用方传入字段名,而是希望更灵活地传入一个“取键函数”,可以把封装再提一层:
function uniqueBy(arr, getKey) { const map = new Map(); for (const item of arr) { const key = getKey(item); if (!map.has(key)) { map.set(key, item); } } return [...map.values()]; } uniqueBy(users, user => user.id); // 也可以基于多个字段组合: uniqueBy(users, user => `${user.id}_${user.city}`);基于函数传入,自由度更大,可以组合多个字段形成复合键,比如“同一用户在同一城市只保留一条”。这个封装我在项目里用了很多年,基本没踩过坑。
6.2 忽略大小写的字符串去重
如果数组元素是字符串,希望'abc'和'ABC'视为相同,那直接new Set是不行的。处理思路还是回到“哈希键”上——把每个元素统一转成小写(或大写)作为去重依据:
function uniqueIgnoreCase(arr) { const map = new Map(); for (const item of arr) { const key = String(item).toLowerCase(); if (!map.has(key)) { map.set(key, item); } } return [...map.values()]; } uniqueIgnoreCase(['apple', 'Apple', 'BANANA', 'banana', 'cherry']); // 结果:['apple', 'BANANA', 'cherry'],保留的是第一次出现的版本这里有一个容易忽略的细节:你最终保留的是哪个版本?上面的代码会保留第一次出现的版本(比如'apple'),因为后续相同 key 的元素直接跳过。如果你希望保留最后一次出现的版本,把map.set(key, item)放到has判断之外,每次覆盖即可:
function uniqueIgnoreCaseKeepLast(arr) { const map = new Map(); for (const item of arr) { map.set(String(item).toLowerCase(), item); } return [...map.values()]; }两种行为差异在真实业务中是有感知的。比如你先展示了一版数据,又拉回来一版新数据,你希望以新数据为准,那就用覆盖式。
7. 横向对比与选型参考:七种方法到底怎么选
说了这么多,我把七种方法放在同一张表里做对比,方便你按场景快速决策。
7.1 七种方法综合对比表
| 方法 | 时间复杂度 | 空间复杂度 | 改变原数组 | 保持原顺序 | NaN 处理 | 适用场景 |
|---|---|---|---|---|---|---|
| 双层循环 + splice | O(n²) | O(1) | 是 | 否 | 不识别 | 学习、教学演示 |
| indexOf + filter | O(n²) | O(1) | 否 | 是 | 不识别 | 短数组、兼容老环境 |
| includes 循环 | O(n²) | O(n) | 否 | 是 | 识别 | 短数组、需要识别 NaN |
| 对象键值对标记 | O(n) | O(n) | 否 | 是 | 识别 | 基本类型数组、老浏览器 |
| Map 去重 | O(n) | O(n) | 否 | 是 | 识别 | 基本类型和引用类型通用 |
| Set + 展开运算符 | O(n) | O(n) | 否 | 是 | 识别 | 基本类型数组的首选 |
| 排序后相邻去重 | O(nlogn) | O(n) | 否(复制) | 否 | 识别 | 恰好需要排序时 |
补充两个细节:
“改变原数组”那一列,双层循环写的是“是”,因为我给的示例直接操作了
result(它是原数组的浅拷贝),真正传进去的原数组其实没被改。但如果你直接把arr当成操作的变量,那就会改原数组。我强调这一点,是因为很多初学者会在原数组上直接 splice,函数调用完原数组就废了。对象键值对标记法我把“保持原顺序”标成“是”,是因为它按原顺序遍历,遇到新元素才 push,确实能保持。但要注意它无法正确处理对象元素,只适合基本类型。
7.2 我个人在项目里的选型习惯
最后分享一点我在真实项目里的选型习惯,希望能帮你少走弯路:
绝大多数基本类型数组,我用[...new Set(arr)]一行搞定,简洁、速度快、不改变原数组,而且 NaN 也能正确处理。唯一的例外是用户还在用 IE11 的场景,那我会退回indexOf + filter或对象键值对方案。
遇到对象数组去重,我几乎只用 Map,而且是封装成uniqueBy这种接受取键函数的通用函数。参数用函数而不是字符串键名,是因为现实需求经常会演变成“按 id 去重”变成“按 id + type 组合去重”,函数传参意味着你不需要改函数内部,只要改调用方式。
数字数组如果排序本身没有业务含义,我从不为了去重而排序。先排序再去重,本质上是以丢失原顺序为代价换取 O(nlogn) 的复杂度,在很多场景里性价比不高。但如果你本来就打算让数组按某种规则排序,那“排序 + 相邻去重”的组合拳是很划算的。
面试时我推荐按复杂度递进讲述:从双层循环讲到哈希标记,再讲到 Set 底层规则,最后补一个排序去重的思路。与其背代码,不如把每种写法背后的复杂度、相等算法和边界条件讲清楚。面试官听到你能从NaN聊到SameValueZero,从对象键聊到Map的引用键机制,一般都会觉得这个人是真的懂,而不是背了八股。
数组去重这件事,说到底是对“什么是相等”这个问题的一次系统训练。搞懂了它,你对 JS 类型系统的理解会上一个台阶,后面再碰深拷贝、比较函数、响应式依赖收集这些更复杂的话题,都会顺很多。希望这篇文章能让你少踩几个我当年踩过的坑。