数组去重的多种实现方式深度解析
一句话概括
数组去重是一场关于”相等”的博弈——你能不能判断两个元素是同一个东西,决定了你的方案能对付基本类型、NaN、还是对象。[...new Set(arr)] 是最快最干净的起手式,但要搞定对象数组或自定义比较,你需要自己上场。
核心知识点
1. Set 去重——面试开场的”标准答案”
1
2
3
4
5
6
7
8
9
const arr = [1, 2, 2, 3, NaN, NaN, '1', '1', -0, 0];
const unique = [...new Set(arr)];
console.log(unique);
// [1, 2, 3, NaN, '1', 0]
// ★ NaN 被合并了(Set 内部用 SameValueZero,认为 NaN 等于 NaN)
// ★ '1' 和 1 是两个不同类型,各自保留
// ★ -0 和 0 被当作同一个值
// 时间 O(n),空间 O(n),一行搞定
2. indexOf 与 includes——两个”查重官”的微妙分歧
1
2
3
4
5
6
7
8
9
const data = [1, 2, 1, NaN];
// indexOf:用 === 比较,NaN === NaN 是 false → NaN 被误杀
const byIndexOf = data.filter((v, i) => data.indexOf(v) === i);
console.log(byIndexOf); // [1, 2] ← NaN 丢了!
// includes:用 SameValueZero,认得出 NaN
const byIncludes = data.filter((v, i) => !data.slice(0, i).includes(v));
console.log(byIncludes); // [1, 2, NaN]
⚠️ 两种方案都是 O(n²),10 万条数据下 indexOf 方案可能跑到 3 秒以上,面试一定要能说出这个性能代价。
3. Map 做标记——混合类型的”安全网”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const mixed = [1, '1', null, undefined, NaN, { id: 1 }, { id: 1 }];
const uniqueMap = arr => {
const seen = new Map();
return arr.filter(item => {
if (seen.has(item)) return false;
seen.set(item, true);
return true;
});
};
console.log(uniqueMap(mixed));
// [1, '1', null, undefined, NaN, {id:1}, {id:1}]
// ★ 1 和 '1' 各自保留(类型不同,Map 严格区分)
// ★ NaN 正确处理
// ★ 但两个 {id:1} 都留下了——Map 比较的是引用,不是内容
4. 对象数组去重——按”身份证”说话
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const users = [
{ id: 3, name: '王五' },
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 1, name: '张三三' }, // id 重复,但 name 变了
];
const uniqueBy = (arr, key) => {
const seen = new Set();
return arr.filter(item =>
seen.has(item[key]) ? false : (seen.add(item[key]), true)
);
};
console.log(uniqueBy(users, 'id'));
// [{id:3,name:'王五'}, {id:1,name:'张三'}, {id:2,name:'李四'}]
// 保留首次出现,后面的同名 id 丢弃
5. 手写一个”万能”去重——让调用者定义什么是”相等”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const uniqueWith = (arr, isEqual) =>
arr.filter((item, idx) =>
arr.findIndex(other => isEqual(item, other)) === idx
);
// 按 id 相等
const byId = uniqueWith(users, (a, b) => a.id === b.id);
// 按内容深比较(简单场景)
const byContent = uniqueWith(
[{ a: 1 }, { a: 1 }, { a: 2 }],
(a, b) => JSON.stringify(a) === JSON.stringify(b)
);
console.log(byContent); // [{a:1}, {a:2}]
这是 O(n²),但胜在灵活度拉满。面试官看到你能从一行 Set 一路写到自定义比较器,印象分直接拉高。
其实你每天都在用
📋 列表页兜底去重——后端分页偶尔返回重叠数据,前端拿到数据第一件事就是按 id 去个重,比祈祷后端修 Bug 快得多。
🔍 搜索联想词合并——多个搜索源(历史记录 + 热门推荐 + 联想词)合并到一起,用 Set 把重复的建议词干掉,用户看到的列表干干净净。
📊 图表 ECharts 数据清洗——接口返回的 xAxis 数据有时带重复日期,直接扔给图表会出两条一样的柱子,先 [...new Set(dates)] 洗一遍。
🏷️ 标签/分类去重展示——一篇文章多个标签,多篇文章的标签拼在一起展示”全部标签”时,Set 一行代码搞定,不用自己写双重循环嵌套。
📝 表单多选防重复——用户在下拉多选里手快点了两次同一个选项,提交前 new Set(selectedValues) 兜底,比去拦截 click 事件更稳。
常见误解
❌ 误区一:”Set 能去重对象数组”
1
2
const arr = [{ id: 1 }, { id: 1 }];
console.log(new Set(arr).size); // 2,不是 1!
Set 比较的是 引用地址,两个 {id:1} 是不同的堆对象,所以 Set 认为它们不一样。对象数组去重必须按 key 或自定义比较。
❌ 误区二:”indexOf 和 includes 去重效果一样”
1
2
const arr = [NaN, NaN];
console.log(arr.filter((v, i) => arr.indexOf(v) === i)); // [] —— NaN 全灭
indexOf 用的是 ===,而 NaN === NaN 是 false,所以永远找不到 NaN 的”第一次出现”。includes 用的是 SameValueZero,能正确处理。这个坑是面试官最爱挖的。
❌ 误区三:”用对象属性做去重又快又好”
1
2
3
4
5
6
const obj = {};
[1, '1', null, undefined, {}].forEach(v => { obj[v] = true; });
console.log(Object.keys(obj));
// ['1', 'null', 'undefined', '[object Object]']
// 数字 1 和字符串 '1' 被压成同一个 key!
// 两个不同的对象被压成同一个 '[object Object]'!
对象的 key 永远是字符串(或 Symbol),任何非字符串值都会触发隐式 toString(),这会导致类型信息全部丢失。要安全地做映射,用 Map。
❌ 误区四:”去重一定会保留第一个出现的元素”
用 Set、filter + indexOf 确实保留首次出现。但如果你用的是 reduce 从右往左构建、或者先 reverse() 再去重,结果顺序可能正好相反。面试时如果被问到”如果要保留最后一次出现呢”,能答出来才是加分项:
1
const keepLast = arr => [...new Set([...arr].reverse())].reverse();
一句话总结
Set 是起点,Map 是底线,自定义比较器是你和面试官拉开差距的终点——数组去重的关键从来不是”怎么去掉”,而是”你凭什么说它们一样”。