ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

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算法能识别 NaNfunction 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是objectnull object的 key 是objectnull倒是能和对象区分开但如果你数组里同时有{a:1}和nullkey 一个是[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换成Mapfunction 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 对象数组按字段去重的通用封装对象数组去重的核心不是“相同的对象只留一个”而是“相同字段值的对象只留一个”。最靠谱的方案是用 Mapfunction 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 类型系统的理解会上一个台阶后面再碰深拷贝、比较函数、响应式依赖收集这些更复杂的话题都会顺很多。希望这篇文章能让你少踩几个我当年踩过的坑。
返回列表