创见博客
JS 数组去重有哪些方法?一次讲清常见写法和坑
七崽爱吃小饼干2026/10/16阅读 0

数组去重是 JS 面试的高频题,也是日常写代码经常碰到的小需求。但「去重」看起来简单,里面藏了不少坑:NaN 算不算重复?对象数组怎么去?去重后顺序会不会变?这篇把常见方法一次讲清,并给出各自的适用场景。

一、最简单:Set

如果只是给基本类型数组去重,一行就够了:

js
const arr = [1, 2, 2, 3, 3, 3];
const unique = [...new Set(arr)];
// [1, 2, 3]

Set 内部用类似 SameValueZero 的规则判等,天然不允许重复。再用扩展运算符(或 Array.from)把 Set 转回数组即可。

它还能正确处理 NaN,而 indexOf / includes 的表现并不一致(后面会讲):

js
[...new Set([NaN, NaN, 1])]; // [NaN, 1]

代价:Set 判等是「值比较」,对对象只会比较引用。所以对象数组去重它帮不上忙:

js
[...new Set([{ a: 1 }, { a: 1 }])];
// [{ a: 1 }, { a: 1 }] —— 两个不同引用,都被保留

结论:基本类型首选 Set,又短又稳。

二、filter + indexOf

面向 ES5 的老写法,思路是「只保留某个值第一次出现的位置」:

js
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 都会被保留:

js
[NaN, NaN].filter((item, i) => [NaN, NaN].indexOf(item) === i);
// [NaN, NaN] —— 没去干净

三、filter + 已见集合(Set)

如果不想让 filter 每次都从头 indexOf(O(n²)),可以用一个「已见集合」边遍历边记录,复杂度降到 O(n):

js
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,但好处是能顺手带上自定义判等条件,比如:

js
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 累积结果,逻辑一眼能读:

js
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:

js
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 再比较相邻

先排序让相同的值挨在一起,再和上一个比较:

js
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:

js
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 集合」过滤:

js
const seen = new Set();
const unique = users.filter((u) => !seen.has(u.id) && seen.add(u.id));
// [{ id: 1, name: "张三" }, { id: 2, name: "李四" }] ← 保留首次出现

如果对象没有天然的唯一字段,可以用整体内容的序列化当 key:

js
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。

评论
0/100