news 2026/9/30 15:55:41

JavaScript数组去重:7种方法与原理深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaScript数组去重:7种方法与原理深度解析

数组去重这个题目,前端面试几乎必考,日常开发也躲不掉。刚工作那会儿,我以为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 处理适用场景
双层循环 + spliceO(n²)O(1)是否不识别学习、教学演示
indexOf + filterO(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 类型系统的理解会上一个台阶,后面再碰深拷贝、比较函数、响应式依赖收集这些更复杂的话题,都会顺很多。希望这篇文章能让你少踩几个我当年踩过的坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 15:55:37

Vue项目从零运行全图解:环境配置、依赖安装到常见报错排查

有些人可能觉得“运行一个 Vue 项目”不就是敲两行命令的事么&#xff0c;至于写一篇超详细图解吗&#xff1f;我还真见过不少人在这个最简单也最关键的环节上翻车——装完 Node 后直接卡在 npm install &#xff0c;或者明明照着文档敲了 npm run serve &#xff0c;浏览器…

作者头像 李华
网站建设 2026/9/30 15:50:15

增强土壤瓜氨酸降解功能:缓解土传镰刀菌枯萎病的新思路

最近在《Nature Communications》上读到南农沈其荣院士团队袁军老师课题组的工作&#xff0c;标题很直白&#xff0c;讲的就是增强土壤中的瓜氨酸降解功能来缓解土传镰刀菌枯萎病。做土传病害和根际微生物的人一眼就能看出来&#xff0c;这个切入点不落俗套&#xff0c;它没有继…

作者头像 李华
网站建设 2026/9/30 15:48:48

Model-Optimizer:大模型推理效能工程方法论

1. 项目概述&#xff1a;Model-Optimizer不是工具&#xff0c;而是一套可落地的模型推理效能工程方法论 “Model-Optimizer”这个名称听起来像某个开源工具或GUI软件&#xff0c;但实际在工业级AI部署现场&#xff0c;它根本不是一个现成的下载包&#xff0c;而是一整套贯穿模…

作者头像 李华
网站建设 2026/9/30 15:48:48

粉料自动包装机全解析:选型、调试与故障排查实战

干粉料包装车间里的活&#xff0c;说苦是真苦。以前靠人工套袋、接料、称重、封口&#xff0c;一个班下来人跟从面缸里捞出来似的&#xff0c;口罩里全是灰&#xff0c;胳膊酸到抬不起来&#xff0c;而且50公斤一袋的误差经常超过半斤。后来换了粉料自动包装机&#xff0c;情况…

作者头像 李华
网站建设 2026/9/30 15:45:57

Excel跨表引用实现数据自动更新:从基础操作到动态联动

做Excel表格最怕什么&#xff1f;不是公式不会写&#xff0c;而是辛辛苦苦维护了一张原工作表&#xff0c;另一张新工作表里的数据却还是上周的旧值。我经常被同事拉着问&#xff1a;“我在原工作表里把单价改了&#xff0c;新工作表里的金额怎么不变&#xff1f;到底怎么操作&…

作者头像 李华
网站建设 2026/9/30 15:45:29

Qwen Image 2.1全栈工作流:8G显存跑通10图批量编辑与2K直出

1. 项目概述&#xff1a;这不是一个“跑通就行”的Demo&#xff0c;而是一套能落地进日常创作管线的Qwen Image 2.1全栈工作流从云栖大会回来那天下着雨&#xff0c;我坐在杭州城西一家咖啡馆里&#xff0c;把刚领到的Qwen Image 2.1技术白皮书摊在桌上&#xff0c;旁边是台顶配…

作者头像 李华