深入讲解JavaScript合并数组并自动去重的实用方法,涵盖基本数据类型使用Set的高效解法、对象数组基于Map主键去重的实践技巧、SameValueZero底层原理与性能复杂度剖析。
JavaScript怎么合并数组并自动去重?
时间:2026-11-02
浏览:448
文章目录
... 与 ES6 提供的 Set 集合数据结构:const result = [...new Set([...arrA, ...arrB])];。该方案对于数值、字符串、布尔值等基本类型数组具备 O(N) 的线性时间复杂度。但在面对包含对象的复杂数组时,由于 Set 遵循引用相等性比较规则,内部属性相同的对象无法被自动消除,此时需改用 Map 建立主键映射。本文将拆解各场景下的完整实现、底层原理与避坑要点。
一、基础类型数组:展开运算符与 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 引擎中被清晰划分为三个阶段:
- 展开并合并(Spread):
[...arrA, ...arrB]读取两个数组的所有项并将其按序推入一个新的暂存数组中,此时重复元素(如3和4)仍完整保留。 - 集合唯一化(Set Instance):将暂存数组作为实参传入
new Set()构造器。Set 内部以哈希表机制管理键,插入新元素时会依据SameValueZero判定准则快速查重,若已存在相同值则直接忽略。 - 恢复为数组(Array Restoration):外层的
[...set]再次利用展开运算符触发 Set 实例的迭代器协议,将集合中的无重复项重新打包为一个崭新的原生数组,使得后续仍可继续使用map、filter等数组原型方法。
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 中,字面量数值或字符串直接比较其栈内存中存放的“值本身”;而对象在变量中存储的仅仅是“指向堆内存的指针地址”。即使两个对象内部的所有属性名和属性值完全一模一样,如果它们是通过字面量分别创建的,其在堆内存中的分配地址也是截然不同的(即 { 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' }
]
*/
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),是工业级代码的唯一可靠选择。
五、全场景选型指南与决策树
针对各种复杂的项目环境与数据结构,以下决策树与对照方案可作为直接参考:
常见特殊场景处理方案
场景一:必须完全无主键对象的深度全字段去重
如果对象没有 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这种隐式双重循环的高消耗做法。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。