JavaScript怎么合并数组并自动去重?

作者:代码拾荒时间:2026-11-02 09:17:49浏览:449

深入讲解JavaScript合并数组并自动去重的实用方法,涵盖基本数据类型使用Set的高效解法、对象数组基于Map主键去重的实践技巧、SameValueZero底层原理与性能复杂度剖析。

在 JavaScript 中,合并数组并自动去重的最简洁且高效的标准解法是结合展开运算符 ... 与 ES6 提供的 Set 集合数据结构:const result = [...new Set([...arrA, ...arrB])];。该方案对于数值、字符串、布尔值等基本类型数组具备 O(N) 的线性时间复杂度。但在面对包含对象的复杂数组时,由于 Set 遵循引用相等性比较规则,内部属性相同的对象无法被自动消除,此时需改用 Map 建立主键映射。本文将拆解各场景下的完整实现、底层原理与避坑要点。
JavaScript怎么合并数组并自动去重?
图 0:JavaScript 数组合并与自动去重的核心流转流程概要

一、基础类型数组:展开运算符与 Set 的组合拳

如果待合并的数组中存放的是纯数值、字符串、布尔值或 Symbol 这类原始数据类型,现代 JavaScript(ES6+)提供了近乎单行的声明式解法。

// 准备两个包含重复数值的数组
const arrA = [1, 2, 3, 4];
const arrB = [3, 4, 5, 6];

// 执行合并与自动去重
const mergedUnique = [...new Set([...arrA, ...arrB])];

console.log(mergedUnique); 
// 运行结果: [1, 2, 3, 4, 5, 6]

除了使用数组字面量展开,也可以配合 Array.from() 达到同样的效果:

const mergedAlternative = Array.from(new Set(arrA.concat(arrB)));
console.log(mergedAlternative); // [1, 2, 3, 4, 5, 6]

这两种写法在执行效率和产出结果上是完全等价的。整个执行链路在 JavaScript 引擎中被清晰划分为三个阶段:

JavaScript怎么合并数组并自动去重?
图 1:ES6 Set 数组合并去重的三阶段执行机制拆解
  1. 展开并合并(Spread):[...arrA, ...arrB] 读取两个数组的所有项并将其按序推入一个新的暂存数组中,此时重复元素(如 3 和 4)仍完整保留。
  2. 集合唯一化(Set Instance):将暂存数组作为实参传入 new Set() 构造器。Set 内部以哈希表机制管理键,插入新元素时会依据 SameValueZero 判定准则快速查重,若已存在相同值则直接忽略。
  3. 恢复为数组(Array Restoration):外层的 [...set] 再次利用展开运算符触发 Set 实例的迭代器协议,将集合中的无重复项重新打包为一个崭新的原生数组,使得后续仍可继续使用 map、filter 等数组原型方法。
重要边界细节:SameValueZero 规则对特殊值的处理

Set 使用的并非完全等同于严格全等 === 的规则,而是 ECMAScript 规范中的 SameValueZero。在普通全等判定中,NaN === NaN 返回 false;但使用 Set 进行合并去重时,它能够正确识别 NaN 并自动去重,只保留一份 NaN。此外,+0 与 -0 在 Set 中同样被视为同一元素。

const list1 = [NaN, 1, 0];
const list2 = [NaN, 2, -0];

const cleanList = [...new Set([...list1, ...list2])];
console.log(cleanList); // 结果: [NaN, 1, 0, 2](NaN 与 0 均被成功去重)

二、为什么 Set 对对象数组会“失效”?

很多开发者在刚接触对象数组时,容易直接套用 new Set,结果发现重复的对象并没有被剔除:

const groupA = [{ id: 1, name: '张三' }, { id: 2, name: '李四' }];
const groupB = [{ id: 2, name: '李四' }, { id: 3, name: '王五' }];

const failedResult = [...new Set([...groupA, ...groupB])];
console.log(failedResult.length); // 结果依然是 4,重复的李四依然存在!

要理解这一表现,必须厘清 JavaScript 内存模型中原始类型与引用类型的实质区别:

JavaScript怎么合并数组并自动去重?
图 2:基本类型值比对与引用类型指针比对的内存机制差异

在 JavaScript 中,字面量数值或字符串直接比较其栈内存中存放的“值本身”;而对象在变量中存储的仅仅是“指向堆内存的指针地址”。即使两个对象内部的所有属性名和属性值完全一模一样,如果它们是通过字面量分别创建的,其在堆内存中的分配地址也是截然不同的(即 { id: 2 } !== { id: 2 })。

Set 无法也不应该替开发者猜测“根据哪几个字段相同才算重复”,因此它会把每一个独立创建的对象都视为完全不同的元素。

三、对象数组合并去重:利用 Map 建立主键映射

在实际项目(如前后端交互数据合并、分页加载追加等场景)中,对象几乎都具备唯一主键(例如 id 或 userId)。要以最高的执行性能合并此类数组,应当利用 ES6 的 Map。

1. 策略 A:后项覆盖前项(更新最新数据)

当后一个数组代表最新更新的状态,希望发生冲突时以新数组为主,可以直接循环使用 map.set(key, item)。因为 Map 的键不可重复,后写入的值会自然覆盖先前的旧值。

function mergeObjectsOverride(arrA, arrB, key) {
  const map = new Map();
  // 按照合并顺序依次塞入 Map
  [...arrA, ...arrB].forEach(item => {
    map.set(item[key], item);
  });
  // 取出所有对象值转回数组
  return Array.from(map.values());
}

const usersOld = [
  { id: 101, name: 'Alice', role: 'guest' },
  { id: 102, name: 'Bob', role: 'editor' }
];

const usersNew = [
  { id: 102, name: 'Bob', role: 'admin' }, // 权限变更为 admin
  { id: 103, name: 'Charlie', role: 'member' }
];

const mergedUsers = mergeObjectsOverride(usersOld, usersNew, 'id');
console.log(mergedUsers);
/* 
输出结果:
[
  { id: 101, name: 'Alice', role: 'guest' },
  { id: 102, name: 'Bob', role: 'admin' }, // 成功更新并去重
  { id: 103, name: 'Charlie', role: 'member' }
]
*/
JavaScript怎么合并数组并自动去重?
图 3:基于 Map 键值索引机制的对象数组去重处理流水线

2. 策略 B:前项优先(保留首次出现的记录)

若需求规定如果前一个数组已有某项数据,后续出现的同名项一律丢弃,只需在写入 Map 前加入 map.has() 检查:

function mergeObjectsKeepFirst(arrA, arrB, key) {
  const map = new Map();
  for (const item of [...arrA, ...arrB]) {
    if (!map.has(item[key])) {
      map.set(item[key], item);
    }
  }
  return Array.from(map.values());
}

四、算法效率辨析:为什么坚决避免 filter + indexOf

在一些旧资料中,常常能看到借助数组内置高阶函数的合并写法:

// 不推荐的写法
const legacyMerge = (arrA, arrB) => {
  const combined = arrA.concat(arrB);
  return combined.filter((item, index) => combined.indexOf(item) === index);
};

虽然该方法在只有十来个元素的小数组上看似能够运行,但存在严重的算法性能缺陷:

  • filter 需要遍历包含所有元素的合并数组,复杂度为 O(N)。
  • 在每一次回调中,indexOf() 又会从数组头部重新向后逐个比对,每次查找耗时也是 O(N)。
  • 两者嵌套后,整体算法时间复杂度飙升至 O(N²)。
合并方案 平均时间复杂度 空间复杂度 万级数据耗时表现
[...new Set([...a, ...b])] O(N) O(N) < 5 毫秒(极快)
Map 键值索引法 O(N) O(N) < 10 毫秒(极快)
filter + indexOf O(N²) O(N) 约 1500 ~ 3000 毫秒(严重卡顿)

当数据量扩展到一万项以上时,O(N²) 方案在浏览器中会导致明显的界面冻结与卡死。而 Set 与 Map 底层通过哈希散列寻址,元素的新增与查询均摊耗时仅为 O(1),是工业级代码的唯一可靠选择。

五、全场景选型指南与决策树

针对各种复杂的项目环境与数据结构,以下决策树与对照方案可作为直接参考:

JavaScript怎么合并数组并自动去重?
图 4:JavaScript 数组合并去重全场景技术选型决策树

常见特殊场景处理方案

场景一:必须完全无主键对象的深度全字段去重

如果对象没有 id 等主键,且要求“只有当内部所有键值对完全相同时才算重复”,可以先评估性能成本。在小型数据集中,可将对象序列化为规整的 JSON 字符串作为 Set 的键:

function deepUniqueObjects(arrA, arrB) {
  const combined = [...arrA, ...arrB];
  const seen = new Set();
  return combined.filter(item => {
    // 注意:属性顺序不同的对象字符串序列化可能不一致,适用于结构严格对齐的数据
    const serialized = JSON.stringify(item);
    if (seen.has(serialized)) return false;
    seen.add(serialized);
    return true;
  });
}
注意事项

JSON.stringify 对属性乱序(如 { a: 1, b: 2 } 与 { b: 2, a: 1 })无法识别为相同对象,且无法处理包含函数、undefined 或循环引用的对象。在极其复杂的深层嵌套业务中,建议使用 Lodash 的 unionWith 结合 isEqual 谓词函数,或在业务层规范化唯一标识。

场景二:兼容极老旧运行环境(ES5)

若项目需要运行在完全不支持 ES6 的遗留运行环境且未配置 Babel/Polyfill,可以利用普通对象的 Key 作为临时哈希字典辅助过滤,避免 O(N²) 的性能开销:

function es5MergePrimitive(arrA, arrB) {
  var merged = arrA.concat(arrB);
  var seen = {};
  var result = [];
  for (var i = 0; i < merged.length; i++) {
    var val = merged[i];
    // 为区分数值 1 与字符串 "1",拼接 typeof 前缀
    var key = typeof val + '_' + val;
    if (!seen[key]) {
      seen[key] = true;
      result.push(val);
    }
  }
  return result;
}

总结

针对“JavaScript 怎么合并数组并自动去重”这一核心问题:

  • 面对基础类型数组,标准答案为 [...new Set([...arrA, ...arrB])],简洁直接且具备原生的 O(N) 线性性能;
  • 面对对象数组,必须明确去重维度,首选基于 Map 提取主键建立唯一索引;
  • 在现代 Web 与 Node.js 工程中,应当始终远离 filter + indexOf 这种隐式双重循环的高消耗做法。
作者最新文章

相关标签:

相关文章
更多