数组去重是 JS 面试的高频题,也是日常写代码经常碰到的小需求。但「去重」看起来简单,里面藏了不少坑:NaN 算不算重复?对象数组怎么去?去重后顺序会不会变?这篇把常见方法一次讲清,并给出各自的适用场景。
一、最简单:Set
如果只是给基本类型数组去重,一行就够了:
const arr = [1, 2, 2, 3, 3, 3];
const unique = [...new Set(arr)];
// [1, 2, 3]
Set 内部用类似 SameValueZero 的规则判等,天然不允许重复。再用扩展运算符(或 Array.from)把 Set 转回数组即可。
它还能正确处理 NaN,而 indexOf / includes 的表现并不一致(后面会讲):
[...new Set([NaN, NaN, 1])]; // [NaN, 1]
代价:Set 判等是「值比较」,对对象只会比较引用。所以对象数组去重它帮不上忙:
[...new Set([{ a: 1 }, { a: 1 }])];
// [{ a: 1 }, { a: 1 }] —— 两个不同引用,都被保留
结论:基本类型首选 Set,又短又稳。
二、filter + indexOf
面向 ES5 的老写法,思路是「只保留某个值第一次出现的位置」:
const arr = [1, 2, 2, 3, 3, 3];
const unique = arr.filter((item, index) => arr.indexOf(item) === index);
// [1, 2, 3]
indexOf 返回第一次出现的位置。如果当前下标等于第一次出现的位置,说明是首次出现,保留;否则是重复项,过滤掉。
坑点:indexOf 用的是严格相等(===),认不出 NaN——因为 NaN !== NaN,每次 indexOf(NaN) 都返回 -1,结果所有 NaN 都会被保留:
[NaN, NaN].filter((item, i) => [NaN, NaN].indexOf(item) === i);
// [NaN, NaN] —— 没去干净
三、filter + 已见集合(Set)
如果不想让 filter 每次都从头 indexOf(O(n²)),可以用一个「已见集合」边遍历边记录,复杂度降到 O(n):
const arr = [1, 2, 2, NaN, NaN];
const seen = new Set();
const unique = arr.filter((item) => !seen.has(item) && seen.add(item));
// [1, 2, NaN]
seen.add(item) 返回 Set 本身(真值),所以逻辑是「没见过 → 记录并保留」。它比纯 Set 多一步 filter,但好处是能顺手带上自定义判等条件,比如:
const seen = new Set();
const unique = arr.filter((item) => {
const key = typeof item === "object" ? item.id : item;
return !seen.has(key) && seen.add(key);
});
这就把「那类值只保留第一条」的规则直接写在了过滤逻辑里。缺点和 Set 一样:默认判等对对象只比引用。
四、reduce + includes
用 reduce 累积结果,逻辑一眼能读:
const arr = [1, 2, 2, 3];
const unique = arr.reduce((acc, cur) => {
if (!acc.includes(cur)) acc.push(cur);
return acc;
}, []);
// [1, 2, 3]
includes 同样解决不了对象去重,而且 acc.includes(cur) 在长数组上是 O(n²)——数据量大时明显变慢。
五、原地去重:双层循环 + splice
前面几种都返回新数组。如果要在原数组上改(不新建),可以用双层循环配 splice:
function dedupeInPlace(arr) {
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] === arr[j]) {
arr.splice(j, 1);
j--; // splice 后下标前移,要回退
}
}
}
return arr;
}
容易写错的地方:splice 删掉元素后,后面的元素整体前移,所以 j 必须 j--,否则会漏掉相邻的重复项。复杂度 O(n²),只在小数组上合适。
六、排序后去重:先 sort 再比较相邻
先排序让相同的值挨在一起,再和上一个比较:
const arr = [3, 1, 2, 2, 3];
const sorted = [...arr].sort((a, b) => a - b); // 先复制再排序,别破坏原数组
const unique = sorted.filter((item, i) => i === 0 || item !== sorted[i - 1]);
// [1, 2, 3]
代价是顺序被打乱。如果业务要求「保留原始出现顺序」,这个方法不能用。它适合「结果本来就要排序」的场景。
七、对象数组去重:按字段取唯一
对象数组是 Set / indexOf 都搞不定的重灾区。办法是挑一个能代表唯一性的字段当 key:
const users = [
{ id: 1, name: "张三" },
{ id: 2, name: "李四" },
{ id: 1, name: "张三(重复)" },
];
const unique = [
...new Map(users.map((u) => [u.id, u])).values(),
];
// [{ id: 1, name: "张三(重复)" }, { id: 2, name: "李四" }]
用 Map 以 id 为键:后出现的会覆盖先出现的(上面 id: 1 保留的是最后一条)。如果希望保留第一条,就不用 Map,改用「已见 id 集合」过滤:
const seen = new Set();
const unique = users.filter((u) => !seen.has(u.id) && seen.add(u.id));
// [{ id: 1, name: "张三" }, { id: 2, name: "李四" }] ← 保留首次出现
如果对象没有天然的唯一字段,可以用整体内容的序列化当 key:
const uniqueByContent = [
...new Map(
objects.map((o) => [JSON.stringify(Object.entries(o).sort()), o])
).values(),
];
Object.entries(o).sort() 是为了让键顺序不同但内容相同的对象也能被判等。但要注意:JSON.stringify 忽略函数、undefined、Symbol,且嵌套对象仍然比引用,所以它只适用于简单、可序列化的对象。
八、几种方法横向对比
| 方法 | 返回 | 支持 NaN | 支持对象 | 保序 | 复杂度 |
|---|---|---|---|---|---|
[...new Set(arr)] | 新数组 | ✅ | ❌(引用) | ✅ | O(n) |
filter + indexOf | 新数组 | ❌ | ❌ | ✅ | O(n²) |
filter + 已见 Set | 新数组 | ✅ | ❌(引用) | ✅ | O(n) |
reduce + includes | 新数组 | ✅ | ❌ | ✅ | O(n²) |
splice 原地 | 原数组 | ❌ | ❌ | ✅ | O(n²) |
| 排序 + 相邻比较 | 新数组 | ❌* | ❌ | ❌ | O(n log n) |
Map 按字段 | 新数组 | ✅(按 key) | ✅ | ✅ | O(n) |
*排序去重对NaN的处理依赖比较函数,实践中不建议用它处理含NaN的数组。
九、小结
- 基本类型、要保序:
[...new Set(arr)],没有之一。 - 对象数组:挑一个唯一字段(如
id),用Map或「已见 Set」去重;Map保留最后一条,「已见 Set」保留第一条。 - 必须在原数组上改:双层循环 +
splice(记得j--)。 - 想要稳定顺序且数据量大:避免
indexOf/includes这类 O(n²) 写法,用Set/Map做到 O(n)。
一句话记忆:判等要值比较 → 用 Set;要按字段/内容判重 → 用 Map;要保留哪一条,取决于你用 Map 还是 Set。