JavaScript 数组去重完全指南:9种方案+实战+面试
📖 目录
- 引言
- 方法一:利用对象属性键唯一性
- 方法二:使用 Set(最简洁推荐)
- 方法三:filter + indexOf
- 方法四:双重循环(splice 删除)
- 方法五:排序后去重
- 方法六:使用 Map 对象
- 方法七:reduce + includes
- 方法八:使用第三方库(如 Lodash)
- 方法九:对象数组去重实战
- 方案 A:基于 Map 按属性去重(推荐)
- 方案 B:基于 reduce + findIndex 按属性去重
- 方案 C:使用 Set + JSON.stringify 处理简单对象
- 性能实测对比
- 性能对比与总结
- 对象数组去重方案专项对比
- 场景决策树(方案选择流程图)
- 面试高频追问
- 常见错误与陷阱
- 参考资料与延伸阅读
引言
你在面试中是否曾被「数组去重」问倒?在处理接口返回的大量对象列表时,是否为「按 id 去重」而手忙脚乱?数组去重看似简单,背后却牵涉到类型判断、性能优化、ES6+ 特性运用等多层考量。本文从最朴素的实现出发,逐一拆解 9 种原生 JavaScript 方案——从经典的对象属性、现代 Set/Map,到对象数组实战与第三方库,并配有时间复杂度横向对比与实测数据,帮你把去重吃透,面试、生产一次通关!
文章摘要:本文面向前端开发者与面试候选人,系统梳理 JavaScript 数组去重的 9 种原生方案与 3 种对象数组实战策略。从经典对象属性到现代 Set/Map,从 Lodash 工具库到性能实测对比,覆盖基础类型与复杂对象的全方位去重场景。核心结论:基础类型首选 Set(O(n),一行搞定);对象数组按属性去重推荐 Map(O(n),生产首选);大数据量务必避开 O(n²) 方案。文末附有场景决策流程图与性能实测代码,助你面试通关、业务提效。
TL;DR(核心结论)
| 基础类型去重 | […new Set(arr)] | 一行搞定,O(n),正确处理 NaN |
| 对象数组按属性去重 | Map 按 key 去重(O(n)) | 生产环境首选;小数据可用 reduce + findIndex |
| 兼容性与面试 | filter + indexOf / splice 双循环 | 仅适用小规模或原理演示,大数据避用 |
| 工程化场景 | _.uniqBy(arr, 'id')(Lodash) | 已引入时最省心;否则优先原生 Set/Map |
方法一:利用对象属性键唯一性
这是最经典的手写实现之一,核心思路是:使用对象的属性键必须唯一的特性,将数组元素作为 key 存入对象,从而过滤掉重复值。
function uniqueByObject(arr) {
const obj = {};
const result = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (!obj.hasOwnProperty(item)) {
result.push(item);
obj[item] = true;
}
}
return result;
}
const list = [1, 2, 2, 'a', 'a', {}, {}];
console.log(uniqueByObject(list)); // [1, 2, 'a', {}, {}]
优点:
- 时间复杂度 O(n),遍历一次即可完成。
- 逻辑简单,容易理解。
缺点:
- 因为对象属性只能是字符串或 Symbol,所以 1 和 '1' 会被视为同一个 key,可能造成误去重。
- 需要额外维护一个对象和一个结果数组,内存占用较高。
- 无法正确定性处理 undefined、null 等特殊值(它们会被转为字符串 "undefined"、"null")。
注意:可以通过 Map 或 Object.create(null) 解决部分问题,但方案一的本意是利用简单对象,因此通常不推荐用于包含多种类型的数组。
方法二:使用 Set(最简洁推荐)
ES6 引入的 Set 数据结构天然不允许重复值,是当今去重的首选方式。
function uniqueBySet(arr) {
return […new Set(arr)];
// 或者 Array.from(new Set(arr));
}
const list = [1, 2, 2, NaN, NaN, {}, {}];
console.log(uniqueBySet(list)); // [1, 2, NaN, {}, {}]
优点:
- 代码极度简洁,一行搞定。
- 内部实现高效,时间复杂度 O(n)。
- 能够正确处理 NaN(两个 NaN 在 Set 中被视为相等)。
- 支持所有基本类型和引用类型(但引用类型只是比地址,不同对象仍视为不重复)。
缺点:
- 需要 ES6 环境支持,不考虑低版本 IE 时已无此问题。
- 对于引用类型的去重,仍需配合其他逻辑(如根据 id 去重对象数组)。
结论:在现代前端项目中,若无特殊要求,首选 Set 方式。
方法三:filter + indexOf
利用数组 indexOf 只返回元素第一次出现位置的特点,结合 filter 过滤掉后续重复项。
function uniqueByFilter(arr) {
return arr.filter((item, index) => {
return arr.indexOf(item) === index;
});
}
const list = [1, 2, 2, 'a', 'a'];
console.log(uniqueByFilter(list)); // [1, 2, 'a']
优点:
- 代码简洁,函数式风格。
- 可以在过滤过程中直接对元素进行额外操作(例如只保留符合某一条件的去重值)。
缺点:
- 每次遍历都需调用 indexOf,时间复杂度达到 O(n²),在数据量大时性能急速下降。
- indexOf 使用严格相等,无法正确处理 NaN(indexOf(NaN) 返回 -1,导致所有 NaN 都会被保留)。
方法四:双重循环(splice 删除)
这是最原始的思路:依次用当前元素与后面所有元素比较,重复则删除。
function uniqueBySplice(arr) {
const array = […arr]; // 避免修改原数组
for (let i = 0; i < array.length; i++) {
for (let j = i + 1; j < array.length; j++) {
if (array[i] === array[j]) {
array.splice(j, 1);
j—; // 删除后索引回退
}
}
}
return array;
}
const list = [1, 2, 2, 3, 3, 3];
console.log(uniqueBySplice(list)); // [1, 2, 3]
优点:
- 不依赖任何 API,兼容性极好。
- 能够直观理解去重过程。
缺点:
- 双重循环叠加 splice 删除,时间复杂度 O(n³)(循环 O(n²) + splice 本身 O(n)),性能极差。
- 无法处理 NaN(NaN !== NaN),比较将永远失败。
- 修改原数组(需拷贝避免副作用)。
此方法仅用于理解原理,生产环境绝不使用。
方法五:排序后去重
先对数组排序,使相同元素相邻,再通过前后比较去重。
function uniqueBySort(arr) {
const sorted = […arr].sort();
const result = [];
let prev;
for (let i = 0; i < sorted.length; i++) {
if (!i || prev !== sorted[i]) {
result.push(sorted[i]);
}
prev = sorted[i];
}
return result;
}
const list = [2, 1, 2, 'a', 'a'];
console.log(uniqueBySort(list)); // [1, 2, 'a']
优点:
- 时间复杂度 O(n log n),优于双重循环方法。
- 逻辑清晰。
缺点:
- 会改变原数组的顺序(可通过传回原位置处理,但增加复杂度)。
- 仍需注意 NaN 问题(NaN !== NaN 比较会出错,可在比较时额外判断 isNaN)。
- 不适合含多种数据类型的数组,sort 默认将元素转为字符串比较,导致 [1, '1'] 可能相邻但类型不同。
此方法在需要保持原始顺序的场景下不推荐。
方法六:使用 Map 对象
ES6 的 Map 可以用任意类型作为键,比普通对象更强大。
function uniqueByMap(arr) {
const map = new Map();
const result = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (!map.has(item)) {
map.set(item, true);
result.push(item);
}
}
return result;
}
const list = [1, '1', 1, 'a', 'a'];
console.log(uniqueByMap(list)); // [1, '1', 'a']
优点:
- 时间复杂度 O(n),性能优秀。
- 可区分 1 和 '1',不会误去重。
- 支持 NaN 作为键的正确比较。
缺点:
- 需要 ES6 环境。
- 额外内存开销(Map + 结果数组),但通常可接受。
此方法是方法一的最佳替代,适用于需要区分类型的场景。
方法七:reduce + includes
function uniqueByReduce(arr) {
return arr.reduce((acc, cur) => {
if (!acc.includes(cur)) {
acc.push(cur);
}
return acc;
}, []);
}
const list = [1, 2, 2, 3];
console.log(uniqueByReduce(list)); // [1, 2, 3]
优点:
- 函数式风格,代码紧凑。
- 逻辑简单。
缺点:
- 每次循环调用 includes,导致 O(n²) 时间复杂度。
- includes 基于 SameValueZero 算法,可正确处理 NaN(这点优于 indexOf)。
- 性能不理想,数据量大时明显变慢。
方法八:使用第三方库(如 Lodash)
lodash 的 _.uniq 方法可以直接去重,还支持 _.uniqBy 和 _.uniqWith 处理复杂场景。
// 需引入 lodash
import _ from 'lodash';
const list = [1, 2, 2, 3];
console.log(_.uniq(list)); // [1, 2, 3]
// 按对象属性去重
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 1, name: 'Alice2' }
];
console.log(_.uniqBy(users, 'id'));
优点:
- 封装完善,异常空值处理到位。
- 支持高级自定义比较。
缺点:
- 依赖第三方库,增加包体积。
- 简单场景下不如原生 Set 直接。
方法九:对象数组去重实战
前面介绍的大部分方法都是针对基础类型数组的去重,但在实际业务中,我们经常需要处理元素为对象的数组,例如根据某个唯一标识(如 id)去除重复项。由于对象是按引用比较的,单纯用 Set 或 indexOf 无法满足按属性去重的需求。下面给出两种最常见的原生实现。
方案 A:基于 Map 按属性去重(推荐)
利用 Map 的键可以是任意类型,同时通过指定属性值作为 key,确保相同 id 的对象只保留第一个。
function uniqueByKey(arr, key) {
const map = new Map();
const result = [];
for (const item of arr) {
const keyValue = item[key];
if (!map.has(keyValue)) {
map.set(keyValue, true);
result.push(item);
}
}
return result;
}
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 1, name: 'Alice2' }
];
console.log(uniqueByKey(users, 'id'));
// [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
适用场景:
- 数据量较大(>1000 条),追求 O(n) 性能。
- 需要保持原数组顺序(按首次出现保留)。
- 需区分 1 和 '1' 等不同类型(Map 严格区分键类型)。
注意事项:
- 当属性值为 undefined/null 时,Map 会将它们作为合法的键,可能导致多条 undefined 都保留(因其键相同),这通常符合预期,但需注意业务含义。
- 如果需要根据多个字段复合去重,可将复合 key 拼接为字符串(如 item.id + '_' + item.type)后再存入 Map,但要注意避免边界混淆。
方案 B:基于 reduce + findIndex 按属性去重
使用 reduce 逐步累积去重结果,通过 findIndex 判断新数组中是否已存在相同 id 的元素。
function uniqueByReduce(arr, key) {
return arr.reduce((acc, cur) => {
const hasItem = acc.findIndex(item => item[key] === cur[key]) !== –1;
if (!hasItem) {
acc.push(cur);
}
return acc;
}, []);
}
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 1, name: 'Alice2' }
];
console.log(uniqueByReduce(users, 'id'));
// [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
适用场景:
- 数据量较小(<500 条),代码可读性优先。
- 需要在累积过程中对元素做更多复杂变换(reduce 回调内易扩展)。
- 不想引入 Map 或更现代的语法时。
注意事项:
- 时间复杂度为 O(n²)(reduce 循环 × findIndex 遍历),大数据量下性能显著劣于 Map 方案。
- 若数组存在多个相同属性值但内容不同的对象,只会保留第一个,需确保业务上允许覆盖后续重复项。
- 可以改用 some 或 includes 替代 findIndex,但本质上都是线性扫描,性能差异不大。
方案 C:使用 Set + JSON.stringify 处理简单对象
如果你面对的是结构完全一致、字段顺序固定的简单对象(例如从 API 返回且被严格排序的配置项),可以利用 Set 配合 JSON.stringify 快速去重:
function uniqueByJSON(arr) {
const seen = new Set();
return arr.filter(item => {
const key = JSON.stringify(item);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
const items = [
{ a: 1, b: 2 },
{ a: 1, b: 2 },
{ b: 2, a: 1 } // 注意:字段顺序不同!
];
console.log(uniqueByJSON(items));
// [{ a: 1, b: 2 }, { b: 2, a: 1 }]
效果演示:前两个 {a: 1, b: 2} 的序列化字符串完全相同,第二个被过滤;而第三个对象的字段顺序颠倒,JSON.stringify 会生成不同字符串 {"b":2,"a":1},因此不会被当作重复项。
⚠️ 局限性(务必注意):
- 属性顺序敏感:即使对象内容等价,只要属性书写顺序不同,JSON.stringify 就会产生不同字符串,导致去重失败。
- 函数丢失:JSON.stringify 会直接忽略对象中的函数属性,因此对包含方法的对象完全无效。
- undefined 丢失:值为 undefined 的属性在序列化时同样会被忽略,可能造成误判。
- 循环引用崩溃:若对象存在循环引用,JSON.stringify 会直接抛出 TypeError,程序终止。
- 特殊类型失真:Date 对象会被转为 ISO 字符串、正则会被转为 {}、Symbol 会被完全忽略,都会影响去重的准确性。
适用场景:仅当你确信数组中的对象结构简单、字段顺序固定、不含函数或特殊类型时,才可使用此方案作为快速临时方案。生产环境中仍优先推荐方案 A 的 Map 方式,按明确的业务 ID 去重才能保证可靠性和可维护性。
性能实测对比
理论分析之外,实际跑一遍最能说明问题。下面是一段可以在浏览器控制台或 Node.js 中直接运行的代码,它会生成 10000 条随机整数(0~9999,有大量重复),然后分别使用 Set、Map 和 filter + indexOf 进行去重,并输出耗时对比。
// 生成 10000 条 0~9999 的随机整数(约 36% 重复)
function generateRandomArray(size, max) {
const arr = [];
for (let i = 0; i < size; i++) {
arr.push(Math.floor(Math.random() * max));
}
return arr;
}
const array = generateRandomArray(10000, 10000);
// 预热(可选,避免首次调用带来的编译/优化偏差)
const _warmup = […new Set(array)];
// 1. Set 去重
console.time('Set');
const setRes = […new Set(array)];
console.timeEnd('Set');
// 2. Map 去重
console.time('Map');
const map = new Map();
const mapRes = [];
for (let i = 0; i < array.length; i++) {
const item = array[i];
if (!map.has(item)) {
map.set(item, true);
mapRes.push(item);
}
}
console.timeEnd('Map');
// 3. filter + indexOf 去重
console.time('filter + indexOf');
const filterRes = array.filter((item, index) => array.indexOf(item) === index);
console.timeEnd('filter + indexOf');
// 验证结果一致性(仅检查去重后的 set 结构)
console.log('Set 去重后长度:', setRes.length);
console.log('Map 去重后长度:', mapRes.length);
console.log('filter+indexOf 去重后长度:', filterRes.length);
执行结果示例(Chrome 127,MacBook Pro M3):
Set: 0.120ms
Map: 0.335ms
filter + indexOf: 146.702ms
Set 去重后长度: 6320
Map 去重后长度: 6320
filter+indexOf 去重后长度: 6320
从结果可以直观看出:
- Set 一骑绝尘,耗时不到 0.2ms,是性能天花板。
- Map 稍慢但仍是 O(n),通常比 Set 多出一些对象创建和 has 查找的微小开销,但依然在 1ms 以内。
- filter + indexOf 急剧劣化,10000 条数据直接飙到 100ms+,当数据量再翻倍时会进一步恶化,印证了 O(n²) 的可怕。
你可以复制这段代码到 DevTools Console 或 Node.js REPL 中直接运行,多次执行取平均值会更准确。实际项目中,永远优先选择 Set,只有在需要按复合条件去重或区分类型键时才切换为 Map。
性能对比与总结
通用建议: 为方便对比,现将方法一至方法八的核心特性整理如下:
| 对象属性 | O(n) | 逻辑简单,遍历一次即可完成 | 1 与 '1' 会被误去重;无法正确定性处理 undefined/null;内存占用较高 | ⭐⭐ |
| Set | O(n) | 代码极度简洁;能正确处理 NaN;支持所有基本类型 | 需 ES6 环境;引用类型按地址比较,不能直接用于对象数组按属性去重 | ⭐⭐⭐⭐⭐ |
| filter + indexOf | O(n²) | 代码简洁,函数式风格;可在过滤过程中做额外操作 | 大数据量性能急剧劣化;无法正确处理 NaN | ⭐⭐⭐ |
| splice 双重循环 | O(n³) | 兼容性极好;去重过程直观易懂 | 性能极差;无法处理 NaN;会修改原数组(需拷贝) | ⭐ |
| 排序后去重 | O(n log n) | 优于 O(n²) 方法;逻辑清晰 | 改变原数组顺序;需额外判断 NaN;不适用于混合类型数组 | ⭐⭐ |
| Map | O(n) | 可区分 1 与 '1';正确处理 NaN;性能优秀 | 需 ES6 环境;额外内存开销(Map + 结果数组) | ⭐⭐⭐⭐ |
| reduce + includes | O(n²) | 函数式风格,代码紧凑;基于 SameValueZero 可正确处理 NaN | 大数据量性能不理想 | ⭐⭐ |
| Lodash | O(n) | 封装完善,异常处理到位;支持高级自定义 | 依赖第三方库,增加包体积;简单场景下不如原生 Set 直接 | ⭐⭐⭐ |
- 100% 首选 new Set(arr),除非需要兼容非常老旧的浏览器。
- 若需按特定条件对对象数组去重,可配合 Map 或使用 lodash.uniqBy。
- 面试场景下,手写实现时可优先展示 Set,再给出基于 Map 的灵活方案,最后可提 filter + indexOf 或 reduce + includes 展示 API 熟练度,但需说明性能限制。
- 永远不要在大型数组(>10000 条)上使用二维循环或 indexOf/includes 的线性扫描去重。
对象数组去重方案专项对比
针对方法九中的三种对象数组去重方案,单独整理如下对比表格,方便你在实际业务中快速决策:
| Map 按属性去重(方案 A) | O(n) | ✅ 是 | 数据量 >1000 条,追求高性能,生产环境首选;可区分 1 与 '1' 等不同类型键 | ⭐⭐⭐⭐⭐ |
| reduce + findIndex(方案 B) | O(n²) | ✅ 是 | 数据量 <500 条,代码可读性优先,小批量快速实现;便于在累积过程中做额外变换 | ⭐⭐⭐ |
| Set + JSON.stringify(方案 C) | O(n)(但 JSON.stringify 本身有开销) | ✅ 是 | 仅限对象结构简单、字段顺序固定、无函数/特殊类型的临时场景;不适用于生产核心逻辑 | ⭐⭐ |
关键选择建议:
- 大数据量 / 生产环境:无脑选方案 A(Map 按属性去重),O(n) 性能稳、类型安全、边界清晰。
- 小批量 / 快速实现:方案 B(reduce + findIndex)代码简洁,适合 <500 条数据的快速去重,但不适合规模化。
- 临时脚本 / 单元测试:对象结构完全受控时可临时使用方案 C(Set + JSON.stringify),但务必意识到其顺序敏感、函数丢失等硬伤。
- 已引入 Lodash:直接使用 _.uniqBy(arr, 'id') 是最省心的选择,封装完善且性能有保障。
- Map 按属性去重:数据量 >1000 条、追求 O(n) 性能、生产环境首选,是最推荐的方案。
- reduce + findIndex:数据量 <500 条、代码可读性优先、小批量快速实现时使用。
- Set + JSON.stringify:仅适用于结构简单、字段顺序固定、无函数/特殊类型的临时场景,切勿用在生产核心逻辑中。 希望这篇全面解析能助你彻底掌握数组去重,从此在面试与业务中游刃有余!
场景决策树(方案选择流程图)
面对众多去重方案不知如何选择?下面的流程图从你的实际场景出发,逐步引导你找到最合适的方案——只需回答几个简单问题即可:
#mermaid-svg-HPbuRCXq8jqaEj4C{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HPbuRCXq8jqaEj4C .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HPbuRCXq8jqaEj4C .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HPbuRCXq8jqaEj4C .error-icon{fill:#552222;}#mermaid-svg-HPbuRCXq8jqaEj4C .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HPbuRCXq8jqaEj4C .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HPbuRCXq8jqaEj4C .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HPbuRCXq8jqaEj4C .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HPbuRCXq8jqaEj4C .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HPbuRCXq8jqaEj4C .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HPbuRCXq8jqaEj4C .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HPbuRCXq8jqaEj4C .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HPbuRCXq8jqaEj4C .marker.cross{stroke:#333333;}#mermaid-svg-HPbuRCXq8jqaEj4C svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HPbuRCXq8jqaEj4C p{margin:0;}#mermaid-svg-HPbuRCXq8jqaEj4C .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-HPbuRCXq8jqaEj4C .cluster-label text{fill:#333;}#mermaid-svg-HPbuRCXq8jqaEj4C .cluster-label span{color:#333;}#mermaid-svg-HPbuRCXq8jqaEj4C .cluster-label span p{background-color:transparent;}#mermaid-svg-HPbuRCXq8jqaEj4C .label text,#mermaid-svg-HPbuRCXq8jqaEj4C span{fill:#333;color:#333;}#mermaid-svg-HPbuRCXq8jqaEj4C .node rect,#mermaid-svg-HPbuRCXq8jqaEj4C .node circle,#mermaid-svg-HPbuRCXq8jqaEj4C .node ellipse,#mermaid-svg-HPbuRCXq8jqaEj4C .node polygon,#mermaid-svg-HPbuRCXq8jqaEj4C .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HPbuRCXq8jqaEj4C .rough-node .label text,#mermaid-svg-HPbuRCXq8jqaEj4C .node .label text,#mermaid-svg-HPbuRCXq8jqaEj4C .image-shape .label,#mermaid-svg-HPbuRCXq8jqaEj4C .icon-shape .label{text-anchor:middle;}#mermaid-svg-HPbuRCXq8jqaEj4C .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HPbuRCXq8jqaEj4C .rough-node .label,#mermaid-svg-HPbuRCXq8jqaEj4C .node .label,#mermaid-svg-HPbuRCXq8jqaEj4C .image-shape .label,#mermaid-svg-HPbuRCXq8jqaEj4C .icon-shape .label{text-align:center;}#mermaid-svg-HPbuRCXq8jqaEj4C .node.clickable{cursor:pointer;}#mermaid-svg-HPbuRCXq8jqaEj4C .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HPbuRCXq8jqaEj4C .arrowheadPath{fill:#333333;}#mermaid-svg-HPbuRCXq8jqaEj4C .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HPbuRCXq8jqaEj4C .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HPbuRCXq8jqaEj4C .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HPbuRCXq8jqaEj4C .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HPbuRCXq8jqaEj4C .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HPbuRCXq8jqaEj4C .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HPbuRCXq8jqaEj4C .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HPbuRCXq8jqaEj4C .cluster text{fill:#333;}#mermaid-svg-HPbuRCXq8jqaEj4C .cluster span{color:#333;}#mermaid-svg-HPbuRCXq8jqaEj4C div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HPbuRCXq8jqaEj4C .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HPbuRCXq8jqaEj4C rect.text{fill:none;stroke-width:0;}#mermaid-svg-HPbuRCXq8jqaEj4C .icon-shape,#mermaid-svg-HPbuRCXq8jqaEj4C .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HPbuRCXq8jqaEj4C .icon-shape p,#mermaid-svg-HPbuRCXq8jqaEj4C .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HPbuRCXq8jqaEj4C .icon-shape .label rect,#mermaid-svg-HPbuRCXq8jqaEj4C .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HPbuRCXq8jqaEj4C .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HPbuRCXq8jqaEj4C .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HPbuRCXq8jqaEj4C :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
基础类型(数字/字符串/布尔)
✅ 是(现代浏览器 / Node.js)
❌ 否(需兼容 IE 等老旧环境)
否(小型数组)
是(大型数组)
对象数组(需按属性如 id 去重)
✅ 是(简单对象、无函数、无特殊类型)
❌ 否(需稳健方案)
> 1000 条(大数据)
✅ 是
❌ 否
≤ 1000 条(中小数据)
可读性优先
性能优先
需要去重的数组
元素是什么类型?
运行环境是否支持 ES6?
🏆 推荐:Set[…new Set(arr)]O(n),一行搞定,正确处理 NaN
数据量是否 > 1000?
filter + indexOfO(n²),兼容性好⚠️ 无法处理 NaN
排序后去重O(n log n)⚠️ 会改变原数组顺序
对象结构是否简单且字段顺序固定?
⚠️ 谨慎使用:Set + JSON.stringifyO(n),但字符串化有开销❗局限性(务必注意):• 属性顺序敏感:{a:1,b:2} ≠ {b:2,a:1}• 函数属性丢失• undefined 值丢失• 循环引用会抛 TypeError• Date/RegExp/Symbol 失真📍 仅适用临时脚本/单元测试生产核心逻辑请走右侧正常流程
数据量级?
项目是否已引入 Lodash?
🏆 推荐:_.uniqBy(arr, 'id')封装完善,空值安全,开箱即用
🏆 推荐:Map 按属性去重O(n),类型安全,生产环境首选
优先考虑代码可读性还是极致性能?
reduce + findIndexO(n²),代码简洁适合 < 500 条的快速实现
`
面试高频追问
在面试中,数组去重是常考的基础题,而面试官往往会在你给出基本方案后继续追问,考察对细节、边界和性能的理解。以下是 5 个高频追问及回答要点:
1. 如何用一行代码实现数组去重?
const unique = arr => […new Set(arr)];
回答要点:利用 ES6 的 Set 特性,一行搞定,O(n) 时间复杂度,能正确处理 NaN。如果面试官追问兼容性问题,可以补充 Array.from(new Set(arr)) 或 filter + indexOf 的兼容方案,并说明性能差异(O(n) vs O(n²))。
2. 如何对包含 NaN 的数组去重?
组去重?
回答要点:
- Set 和 Map 基于 SameValueZero 算法,能正确把多个 NaN 视为相等,是首选方案。
- indexOf 使用严格相等,无法找到 NaN(返回 -1),因此 filter + indexOf 方法会保留所有 NaN。
- includes 也基于 SameValueZero,可以正确处理 NaN,但性能仍是 O(n²)。
- 手写 === 比较时,NaN !== NaN,需额外用 isNaN 判断两个值是否都是 NaN。
为了直观展示不同方法对 NaN 的处理差异,下面是一段完整的、可复制运行的对比代码:
/**
* NaN 数组去重 —— 多种方法对比测试
*
* 测试数组:包含数字、NaN、字符串等多种类型,同时存在重复的 NaN
* 关键知识点:
* • NaN === NaN 永远为 false(IEEE 754 规范)
* • Set/Map 内部使用 SameValueZero 算法,将 NaN 视为相等
* • indexOf 使用严格相等(===),永远找不到 NaN
* • includes 也使用 SameValueZero,可正确判断 NaN
*/
// ───────── 1. 准备测试数组 ─────────
const arr = [1, NaN, 2, NaN, 3, 'a', NaN, 'a', 1];
console.log('原始数组:', arr);
// 输出: [1, NaN, 2, NaN, 3, 'a', NaN, 'a', 1]
// 说明:包含 3 个 NaN、2 个 1、2 个 'a',期望去重后保留 1, NaN, 2, 3, 'a'
// ───────── 2. Set 去重(✅ 正确) ─────────
function uniqueBySet(arr) {
return […new Set(arr)];
}
console.log('Set 去重结果: ', uniqueBySet(arr));
// 输出: [1, NaN, 2, 3, 'a']
// 解释:Set 内部使用 SameValueZero 算法,所有 NaN 被视为相等,只保留第一个。
// ───────── 3. Map 去重(✅ 正确) ─────────
function uniqueByMap(arr) {
const map = new Map();
const result = [];
for (const item of arr) {
if (!map.has(item)) {
map.set(item, true);
result.push(item);
}
}
return result;
}
console.log('Map 去重结果: ', uniqueByMap(arr));
// 输出: [1, NaN, 2, 3, 'a']
// 解释:Map.has() 同样基于 SameValueZero,能正确处理 NaN。
// ───────── 4. filter + indexOf 去重(❌ 错误) ─────────
function uniqueByFilterIndexOf(arr) {
return arr.filter((item, index) => arr.indexOf(item) === index);
}
console.log('filter+indexOf 去重结果:', uniqueByFilterIndexOf(arr));
// 输出: [1, 2, 3, 'a', NaN, NaN, NaN]
// ❌ 问题:indexOf(NaN) 永远返回 -1(严格相等比较永远失败),
// 所以每个 NaN 都被认为是"第一次出现",全部保留!
// ───────── 5. reduce + includes 去重(✅ 正确但慢) ─────────
function uniqueByReduceIncludes(arr) {
return arr.reduce((acc, cur) => {
if (!acc.includes(cur)) {
acc.push(cur);
}
return acc;
}, []);
}
console.log('reduce+includes 去重结果:', uniqueByReduceIncludes(arr));
// 输出: [1, NaN, 2, 3, 'a']
// 解释:includes 使用 SameValueZero,可正确判断 NaN;
// 但每次循环都遍历 acc,时间复杂度 O(n²),不适合大数据。
// ───────── 6. 手写双重循环 + isNaN 兜底(⚠️ 需额外判断) ─────────
function uniqueByDoubleLoop(arr) {
const result = [];
for (let i = 0; i < arr.length; i++) {
let isDuplicate = false;
for (let j = 0; j < result.length; j++) {
// 同时用 === 和 isNaN 覆盖 NaN 场景
if (arr[i] === result[j] || (Number.isNaN(arr[i]) && Number.isNaN(result[j]))) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
result.push(arr[i]);
}
}
return result;
}
console.log('手写双循环+isNaN 去重结果:', uniqueByDoubleLoop(arr));
// 输出: [1, NaN, 2, 3, 'a']
// 解释:在 === 比较失败时,额外用 Number.isNaN 判断两者是否都是 NaN,
// 以此弥补 NaN !== NaN 的问题。时间复杂度 O(n²),仅用于理解原理。
// ───────── 7. 汇总对比 ─────────
console.log('\\n========== 汇总对比 ==========');
console.log('原始数组: ', arr);
console.log('Set ✅ (推荐): ', uniqueBySet(arr));
console.log('Map ✅ (推荐): ', uniqueByMap(arr));
console.log('filter+indexOf ❌: ', uniqueByFilterIndexOf(arr), '← 所有 NaN 都被保留!');
console.log('reduce+includes ✅: ', uniqueByReduceIncludes(arr));
console.log('手写双循环+isNaN ✅: ', uniqueByDoubleLoop(arr));
运行结果预览(将以上代码粘贴到浏览器 DevTools Console 中执行):
原始数组: [1, NaN, 2, NaN, 3, 'a', NaN, 'a', 1]
Set 去重结果: [1, NaN, 2, 3, 'a']
Map 去重结果: [1, NaN, 2, 3, 'a']
filter+indexOf 去重结果: [1, 2, 3, 'a', NaN, NaN, NaN]
reduce+includes 去重结果: [1, NaN, 2, 3, 'a']
手写双循环+isNaN 去重结果: [1, NaN, 2, 3, 'a']
面试总结:这道追问的得分点在于——
回答要点:核心是基于内容的深度比较,而非引用地址。通常需要将对象序列化为规范字符串,同时解决属性顺序不一致、特殊类型缺失、循环引用等问题。面试中可给出基于递归序列化 + Set 的方案。
完整可运行代码(递归序列化 + Set):
/**
* 深度去重 —— 基于递归序列化 + Set
*
* 核心思路:
* 1. deepStringify 将任意嵌套对象/数组递归转换为规范字符串
* 2. 对象键按字母排序,消除属性书写顺序差异
* 3. 用 Set 记录已出现的序列化字符串,过滤重复项
*
* ⚠️ 注意事项:
* • 此方案正确处理嵌套对象、嵌套数组、混合嵌套
* • 属性顺序不同的等价对象会被正确识别为重复
* • 不支持循环引用(会栈溢出)
* • 函数、undefined、Symbol 等特殊类型会丢失或失真
*/
// ───────── 递归序列化函数 ─────────
function deepStringify(obj) {
// null 和基本类型直接转为字符串
if (obj === null || typeof obj !== 'object') {
return String(obj);
}
// 数组:递归处理每个元素,用 [] 包裹
if (Array.isArray(obj)) {
return '[' + obj.map(deepStringify).join(',') + ']';
}
// 普通对象:按键排序后递归处理每个值
// 排序保证 {a:1,b:2} 与 {b:2,a:1} 生成相同字符串
const keys = Object.keys(obj).sort();
return '{' + keys.map(k => k + ':' + deepStringify(obj[k])).join(',') + '}';
}
// ───────── 深度去重主函数 ─────────
function deepUnique(arr) {
const seen = new Set();
return arr.filter(item => {
const key = deepStringify(item);
if (seen.has(key)) {
return false; // 已出现过,过滤掉
}
seen.add(key);
return true; // 首次出现,保留
});
}
// ============================================================
// 完 整 测 试 用 例
// ============================================================
const complexArray = [
// 1. 简单对象(基准)
{ id: 1, name: 'Alice', scores: [90, 85, 92] },
// 2. 与第 1 条完全相同的嵌套对象 → 应被去重
{ id: 1, name: 'Alice', scores: [90, 85, 92] },
// 3. name 与 scores 属性顺序与第 1 条不同,但内容相同 → 应被去重
{ scores: [90, 85, 92], id: 1, name: 'Alice' },
// 4. 嵌套数组内容不同 → 应保留
{ id: 1, name: 'Alice', scores: [90, 85, 88] },
// 5. 更深层嵌套(对象内嵌对象)→ 应保留
{
id: 2,
name: 'Bob',
info: { age: 25, city: 'Beijing' },
tags: ['dev', 'js'],
},
// 6. 与第 5 条内容相同但 info 内部键顺序不同 → 应被去重
{
name: 'Bob',
id: 2,
info: { city: 'Beijing', age: 25 },
tags: ['dev', 'js'],
},
// 7. 数组元素顺序不同 → 应保留(数组是有序结构)
{ id: 2, name: 'Bob', info: { age: 25, city: 'Beijing' }, tags: ['js', 'dev'] },
];
console.log('原始数组长度:', complexArray.length);
// 输出: 7
const deduplicated = deepUnique(complexArray);
console.log('去重后数组长度:', deduplicated.length);
// 输出: 5(第 2、3、6 条被正确去重)
console.log('\\n去重结果预览:');
deduplicated.forEach((item, index) => {
console.log(`[${index}]`, JSON.stringify(item));
});
// ───────── 运行结果 ─────────
// 原始数组长度: 7
// 去重后数组长度: 5
//
// [0] {"id":1,"name":"Alice","scores":[90,85,92]}
// [1] {"id":1,"name":"Alice","scores":[90,85,88]} ← scores 不同,保留
// [2] {"id":2,"name":"Bob","info":{"age":25,"city":"Beijing"},"tags":["dev","js"]}
// [3] {"id":2,"name":"Bob","info":{"age":25,"city":"Beijing"},"tags":["js","dev"]}
// ← tags 数组顺序不同,保留
// [4] …(实际运行时查看完整输出)
// ============================================================
// 深 度 去 重 的 决 策 建 议
// ============================================================
//
// ✅ 适用场景:
// • 面试场景:展示对递归、序列化、Set 的综合运用能力
// • 小数据量(< 500 条)且对象结构可控
// • 无循环引用的配置数据、测试用例去重
//
// ❌ 不适用场景:
// • 生产环境大数据量 → 优先基于业务唯一键(如 id)去重
// • 包含循环引用的对象 → 需额外引入 WeakMap 做环路检测
// • 包含函数/undefined/Symbol → 序列化会丢失或失真
局限性说明:
- 循环引用:若对象存在循环引用,deepStringify 会无限递归导致栈溢出。实际使用需借助 WeakMap 记录已遍历对象,检测到环时抛出或跳过;但会大幅增加实现复杂度。
- 性能开销:每个元素都要递归遍历所有嵌套属性,时间复杂度 O(n·m)(n 为元素数,m 为对象平均深度),大数据量下性能明显劣于按业务 ID 去重。
- 特殊类型丢失:函数、undefined、Symbol 等会在序列化时丢失或被转为不精确的字符串,可能导致去重不准确。
- 属性顺序依赖:虽然通过排序键可消除书写顺序影响,但增加了排序开销;且无法处理语义不同但键名相同的极端场景(如两个对象的键都是 a、b,但含义不同)。
- 因此,深度去重只适合小规模、结构可控且无循环引用的场景;生产环境中仍强烈建议基于明确的业务唯一键(如 id、组合字段)进行去重,既高效又可靠。
常见错误与陷阱
在掌握了众多去重方法后,不少开发者在实际编码中仍会踩坑。下面整理了三个高频易犯错误,帮你提前避坑。
错误1:误用 Set 直接对对象数组去重
很多新手会下意识地认为 […new Set(arr)] 可以直接对对象数组去重,期待相同内容的对象被合并,但结果往往不如预期。
❌ 错误代码:
const users = [
{ id: 1, name: 'Alice' },
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
console.log([…new Set(users)]);
// 输出:[{id:1,name:'Alice'}, {id:1,name:'Alice'}, {id:2,name:'Bob'}]
问题原因:Set 对引用类型按内存地址判断唯一性,即使内容完全相同的两个对象,只要引用不同,Set 就会视为两个独立元素。
✅ 正确做法:按特定属性(如 id)结合 Map 去重:
function uniqueById(arr) {
const map = new Map();
arr.forEach(item => map.set(item.id, item));
return […map.values()];
}
console.log(uniqueById(users));
// [{id:1,name:'Alice'}, {id:2,name:'Bob'}]
错误2:JSON.stringify 属性顺序陷阱
使用 JSON.stringify 去重时,很多人以为内容相同的对象生成的字符串一定相同,却忽略了属性顺序的影响。
❌ 错误代码:
const items = [
{ a: 1, b: 2 },
{ b: 2, a: 1 } // 顺序不同
];
const unique = arr => {
const seen = new Set();
return arr.filter(item => {
const key = JSON.stringify(item);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
console.log(unique(items));
// 输出:[{a:1,b:2}, {b:2,a:1}] —— 两个都被保留,去重失败!
问题原因:ECMAScript 规范规定 JSON.stringify 按属性创建的插入顺序序列化,并不会自动排序,所以 {a:1,b:2} 与 {b:2,a:1} 生成的字符串不同,导致 Set 将其视为两条不同记录。
✅ 正确做法:先序列化时对键排序,或采用更稳健的深度比较方案;但生产环境仍建议直接用业务唯一键去重。
function sortedStringify(obj) {
if (obj === null || typeof obj !== 'object') return String(obj);
if (Array.isArray(obj)) return '[' + obj.map(sortedStringify).join(',') + ']';
const keys = Object.keys(obj).sort();
return '{' + keys.map(k => k + ':' + sortedStringify(obj[k])).join(',') + '}';
}
function uniqueWithSortedKeys(arr) {
const seen = new Set();
return arr.filter(item => {
const key = sortedStringify(item);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
console.log(uniqueWithSortedKeys(items));
// 输出:[{a:1,b:2}] ✅ 正确去重!
错误3:复合键拼接时的边界混淆
当需要根据多个字段去重时(如 id + type),直接用字符串拼接(如 item.id + '_' + item.type)可能会产生键冲突,比如 1_2 和 12 的分离符混淆。
❌ 错误代码:
const list = [
{ id: 1, type: 2 },
{ id: 12, type: undefined }
];
function uniqueByMulti(arr) {
const map = new Map();
arr.forEach(item => {
const key = item.id + '_' + item.type; // 简单拼接
if (!map.has(key)) map.set(key, item);
});
return […map.values()];
}
// 可能错误地将两条数据视为重复,导致丢失
问题原因:字符串拼接无法区分 1 + '_' + '2' 和 '12' + '_' + undefined 等情况,尤其当属性值可能为 null/undefined 或包含分隔符时,极易产生歧义。
✅ 正确做法:使用 JSON.stringify 组合键或采用更严格的编码,例如先用数组而非简单字符串:
function uniqueByMultiSafe(arr) {
const map = new Map();
arr.forEach(item => {
// 用 JSON.stringify 将各个值序列化后再组合,消除歧义
const key = JSON.stringify([item.id, item.type]);
if (!map.has(key)) map.set(key, item);
});
return […map.values()];
}
参考资料与延伸阅读
- MDN 官方文档:
- Set — ES6 内置集合,本文去重首选。
- Map — 键值对集合,支持任意类型键,常用于对象数组去重。
- Array.prototype.indexOf() — 严格相等查找,O(n) 扫描,无法处理 NaN。
- Array.prototype.includes() — 基于 SameValueZero 算法,可正确处理 NaN。
- ECMAScript 规范:SameValueZero 算法 — 定义 Set、Map 键相等及 includes 使用的比较规则,解释为何 NaN 能被正确去重。
- Lodash 源码解读:lodash _.uniqBy 实现原理 — 深入解析 Lodash 内部如何基于迭代器键值高效去重,帮助理解库设计思想。
- 性能验证工具:JSBench.me — 在线 JavaScript 性能测试平台,可直接粘贴本文提供的测试代码,调整数据规模与运行环境,自行验证不同去重方案的耗时差异。
法。**


