JavaScript数组去重全解析:从Set到对象数组的实战方案
1. 从“重复”到“唯一”为什么数组去重是基本功在编程世界里处理数据集合是家常便饭。无论是从后端接口拉取的用户列表还是前端页面渲染的商品卡片数据常常以数组的形式存在。而“重复”数据就像混入米缸的几粒沙子看似不起眼却可能引发一系列问题统计数据失真、页面渲染异常、甚至导致业务逻辑错误。想象一下一个电商网站的购物车如果因为数据重复而计算了两次同一商品用户体验会大打折扣一份基于用户ID的统计报表如果包含了重复项得出的结论可能完全偏离事实。因此数组去重这项将数组中重复元素剔除只保留每个元素唯一副本的操作是每一位开发者必须掌握的基本功。它不仅仅是应付面试题的知识点更是日常开发中保证数据质量、提升程序健壮性的关键一环。随着JavaScript语言的发展从ES5到ES6乃至更新版本去重的方法也日益丰富和高效。同时当数组元素不再是简单的数字或字符串而是复杂的对象时数组对象去重又带来了新的挑战需要我们更深入地理解比较逻辑。本文将抛开教科书式的罗列从一个实际开发者的视角系统梳理从古至今、从简到繁的各种去重方案。我们会深入每种方法背后的原理分析其性能优劣和适用场景并分享那些在真实项目中踩过的“坑”和总结出的“最佳实践”。无论你是刚入门的新手还是希望巩固知识的老手都能在这里找到可直接“抄作业”的代码和知其所以然的思考。2. 基础数据类型数组去重八仙过海各显神通当我们面对一个由数字、字符串等基础数据类型构成的数组时去重的思路相对直接核心在于如何高效地判断一个元素是否已经出现过。下面我们从最朴素的思路开始逐步深入到最高效的现代方案。2.1 经典双循环与IndexOf理解算法的起点最直观的想法是遍历数组对于每一个元素检查它是否在之前已经出现过的元素中。这催生了双重循环法。function uniqueDoubleLoop(arr) { const result []; for (let i 0; i arr.length; i) { let isDuplicate false; // 内层循环检查arr[i]是否在result中已存在 for (let j 0; j result.length; j) { if (arr[i] result[j]) { isDuplicate true; break; // 发现重复立即跳出内层循环 } } // 如果遍历完result都没找到相同的则不是重复元素 if (!isDuplicate) { result.push(arr[i]); } } return result; } // 示例 const arr [1, 2, 2, 3, 4, 4, 5]; console.log(uniqueDoubleLoop(arr)); // 输出: [1, 2, 3, 4, 5]为什么这样写这是最基础的算法思想体现时间复杂度为O(n²)在数据量小的时候没问题但数据量大时性能急剧下降。它帮助我们理解去重的本质——查找与比较。一个常见的优化是使用indexOf或includes方法来替代内层循环它们本质也是线性查找但代码更简洁。function uniqueIndexOf(arr) { const result []; for (let i 0; i arr.length; i) { if (result.indexOf(arr[i]) -1) { // 如果result中找不到当前元素 result.push(arr[i]); } } return result; }注意indexOf使用严格相等进行比较。这意味着NaN是一个特例因为NaN NaN的结果是false。所以对于包含NaN的数组[1, NaN, 2, NaN]上述方法无法去重NaN。2.2 排序预处理法用空间换时间的权衡另一种思路是如果先将数组排序那么相同的元素必然会相邻。这样我们只需要一次遍历比较当前元素和上一个放入结果的元素是否相同即可。function uniqueSort(arr) { if (arr.length 2) return arr.slice(); // 边界情况处理 const sortedArr arr.slice().sort(); // 先拷贝再排序避免影响原数组 const result [sortedArr[0]]; for (let i 1; i sortedArr.length; i) { if (sortedArr[i] ! sortedArr[i - 1]) { result.push(sortedArr[i]); } } return result; }为什么选择先拷贝这是函数式编程和避免副作用的好习惯。arr.sort()会直接修改原数组原地排序这可能导致调用函数后外部的数组变量也被意外改变引发难以调试的Bug。使用slice()进行浅拷贝能确保原数据无损。性能分析这种方法的时间复杂度主要取决于排序算法通常为O(n log n)。去重遍历是O(n)。总体优于O(n²)的双重循环尤其在大数组上。但缺点也很明显它改变了元素的原始顺序。对于不关心顺序或后续需要排序的场景这是一个不错的选择。2.3 对象Key法哈希思想的朴素应用这是ES5时代非常流行的高效方法。利用JavaScript对象的属性名Key是唯一的这一特性。我们把数组元素作为对象的Key利用对象来快速查重。function uniqueObjectKey(arr) { const obj {}; const result []; for (let i 0; i arr.length; i) { const item arr[i]; if (!obj[item]) { // 如果对象中没有以该元素为key的属性 obj[item] true; // 标记该元素已存在 result.push(item); } } return result; }巨大的隐患这个方法有一个致命的缺陷。对象的Key只能是字符串或Symbol。当我们执行obj[item]时如果item不是字符串会发生隐式类型转换。例如obj[1]和obj[‘1’]会被视为同一个Key。obj[true]和obj[‘true’]也会被混淆。对于对象、数组、函数等引用类型它们都会被转换成类似[object Object]的字符串导致完全不同的对象被误判为重复。console.log(uniqueObjectKey([1, ‘1‘, true, ‘true‘, {a:1}, {a:1}])); // 输出: [1, true, {a:1}] // 结果严重错误因此对象Key法仅适用于元素全部是字符串类型且你确信不需要区分数字1和字符串‘1’的场景。在实际项目中这种理想情况很少所以此法已基本被淘汰。2.4 ES6的终极武器Set与Spread/Rest语法ES6引入的Set数据结构是数组去重的“福音”。Set对象允许你存储任何类型的唯一值无论是原始值还是对象引用。function uniqueSet(arr) { return [...new Set(arr)]; } // 或者使用 Array.from function uniqueSet2(arr) { return Array.from(new Set(arr)); }一行代码解决战斗。这可能是目前最简洁、最易读、且性能优异的方法。原理解析new Set(arr)将数组传入Set构造函数。Set内部机制会自动遍历数组并利用其值唯一的特性过滤掉所有重复项。...扩展运算符或Array.from将去重后的Set对象重新转换回数组。优势代码极简意图清晰一目了然。类型安全Set使用“SameValueZero”算法进行比较。这意味着NaN与NaN被视为相等解决了indexOf的问题。0和-0被视为相等。对象比较的是引用地址所以两个内容相同但引用不同的对象不会被去重这通常是符合预期的。高性能Set基于哈希表或类似结构实现添加和查找元素的时间复杂度接近O(1)因此整个去重过程接近O(n)。实测中的小技巧虽然Set去重很快但在超大规模数组例如数十万条去重时创建新数组和Set对象会有内存开销。如果对性能有极致要求且允许修改原数组可以尝试原地去重但代码复杂度会急剧上升。对于99.9%的场景[...new Set(arr)]都是最佳选择。3. 数组对象去重当元素变得复杂现实开发中我们更常处理的是对象数组。例如从API获取的用户列表[{id: 1, name: ‘Alice‘}, {id: 2, name: ‘Bob‘}, ...]。此时Set无法直接使用因为它比较的是对象的引用。我们需要基于对象中某个或某几个属性如id的唯一性来进行去重。3.1 基于单一关键属性的去重假设我们根据对象的id属性去重。方法一Reduce 查找表Map思想这是最优雅和高效的方法之一结合了Array.prototype.reduce和一个临时对象作为查找表。function uniqueByKey(arr, key) { const lookup {}; return arr.reduce((result, currentItem) { // 以当前对象的key属性值作为查找表的键 if (!lookup[currentItem[key]]) { lookup[currentItem[key]] true; // 标记已存在 result.push(currentItem); } // 注意这里会保留第一次出现的对象。如果需要保留最后一次出现的逻辑需调整。 return result; }, []); } const users [ {id: 1, name: ‘Alice‘}, {id: 2, name: ‘Bob‘}, {id: 1, name: ‘Alice‘}, // 重复ID {id: 3, name: ‘Charlie‘} ]; console.log(uniqueByKey(users, ‘id‘)); // 输出: [{id: 1, name: ‘Alice‘}, {id: 2, name: ‘Bob‘}, {id: 3, name: ‘Charlie‘}]为什么用reducereduce非常适合这种需要遍历数组并累积一个结果这里是去重后的新数组的场景。配合查找表lookup对象我们可以在O(1)时间内判断某个key是否已存在整个算法时间复杂度是O(n)。方法二Map数据结构的现代方案ES6的Map比普通对象更适合作为查找表因为它能保持键的原始类型。function uniqueByKeyMap(arr, key) { const map new Map(); return arr.filter(item { // 检查Map中是否已有该key值 if (!map.has(item[key])) { map.set(item[key], true); // 没有则存入Map并返回true保留该元素 return true; } return false; // 已有则过滤掉 }); }使用filter方法使得代码更加函数式意图清晰过滤出那些其key值第一次出现的元素。Map的has和set操作也都是高效的。3.2 基于多个关键属性的复合去重有时唯一性需要由多个属性共同决定。例如去重一个课程列表需要课程号和班级号都相同才算重复。function uniqueByKeys(arr, keys) { const map new Map(); return arr.filter(item { // 根据提供的keys数组生成一个复合键字符串 const compositeKey keys.map(k item[k]).join(‘|‘); // 用‘|‘连接需确保分隔符不会出现在属性值中 if (!map.has(compositeKey)) { map.set(compositeKey, true); return true; } return false; }); } const courses [ {courseId: ‘CS101‘, classId: ‘A‘, name: ‘Intro‘}, {courseId: ‘CS101‘, classId: ‘B‘, name: ‘Intro‘}, {courseId: ‘CS101‘, classId: ‘A‘, name: ‘Intro‘}, // 重复 {courseId: ‘MATH201‘, classId: ‘A‘, name: ‘Calc‘} ]; console.log(uniqueByKeys(courses, [‘courseId‘, ‘classId‘])); // 输出前三个对象中的第一个和第二个以及最后一个。关键点生成compositeKey时选择合适的分隔符至关重要。如果属性值本身可能包含分隔符如‘|‘就会导致键冲突。更稳健的做法是使用JSON.stringify(keys.map(k item[k]))或者考虑使用Map的键本身可以是数组但数组比较的是引用需要额外处理。3.3 深比较去重应对复杂对象的挑战在极少数情况下你可能需要根据对象所有属性的深度相等性来判断是否重复而不仅仅是比较引用或个别key。这被称为“深比较去重”代价非常高。function uniqueDeep(arr) { const result []; for (const item of arr) { // 检查当前item是否在result中已存在深度相等 const isDuplicate result.some(existingItem isDeepEqual(existingItem, item)); if (!isDuplicate) { result.push(item); } } return result; } // 需要一个深比较函数 isDeepEqual这里不展开实现可使用lodash的_.isEqual强烈警告深比较去重的性能开销巨大O(n²) * 深比较开销通常应避免。在业务设计中尽量为对象定义一个唯一标识符如id然后基于此标识符进行去重这才是合理的做法。4. 性能对比与实战场景选择了解了各种方法后我们该如何选择没有绝对最好的只有最适合当前场景的。4.1 基础数据类型数组性能考量我们可以设计一个简单的性能测试使用console.timeconst largeArray Array.from({length: 100000}, () Math.floor(Math.random() * 1000)); console.time(‘Set‘); const r1 [...new Set(largeArray)]; console.timeEnd(‘Set‘); console.time(‘Sort‘); const r2 uniqueSort(largeArray); console.timeEnd(‘Sort‘); console.time(‘ObjectKey‘); const r3 uniqueObjectKey(largeArray); // 注意仅当元素为数字或可安全转为字符串时 console.timeEnd(‘ObjectKey‘);在我的测试环境中10万条随机整数结果通常是Set ≈ ObjectKey Sort DoubleLoop/IndexOf。Set方法综合性能最好代码简洁类型安全。ObjectKey在纯数字或字符串数组上可能略快但有类型混淆风险不推荐。Sort方法在数据量巨大时优于双循环但会改变顺序。双循环或indexOf在小数组如少于100条时差异不大但数据量上去后不可接受。4.2 数组对象去重性能考量对于对象数组基于Map或查找表对象的方法是O(n)线性复杂度性能远好于使用find、some进行嵌套查找的O(n²)方法。const largeObjArray Array.from({length: 50000}, (_, i) ({id: i % 1000, value: ‘test‘})); // 大量重复 console.time(‘Map‘); uniqueByKeyMap(largeObjArray, ‘id‘); console.timeEnd(‘Map‘); // 对比一个低效的find方法 function uniqueByKeyFind(arr, key) { return arr.filter((item, index, self) index self.findIndex(t t[key] item[key]) ); } console.time(‘FindIndex‘); uniqueByKeyFind(largeObjArray, ‘id‘); console.timeEnd(‘FindIndex‘);你会发现Map方法可能只需要几十毫秒而FindIndex方法可能需要数秒甚至更久。4.3 根据场景做决策现代项目基础数组去重无脑选择[...new Set(arr)]。清晰、安全、高效。需要兼容老旧环境如IE如果不能用Set对于基础类型采用filter indexOf注意NaN问题或排序法。对于对象数组采用reduce 查找表的polyfill实现。数组对象去重有明确唯一键使用Map配合filter的方案。代码既现代又高效。需要保留最后一次出现的重复项只需将遍历顺序反转或者调整查找逻辑将“检查是否存在”改为“记录最后一次出现的位置”。超大规模数据流去重考虑使用更专业的数据结构如布隆过滤器Bloom Filter进行概率去重但这属于特定领域优化。5. 特殊案例、边界条件与踩坑实录掌握了核心方法在实际编码中还会遇到一些“坑”这里分享几个常见的。5.1 NaN的处理NaN是JavaScript中一个特殊的存在它是唯一一个不自等的值。console.log(NaN NaN); // false console.log([1, NaN, 2, NaN].indexOf(NaN)); // -1 (找不到)因此所有依赖比较的方法双循环、indexOf、includes、filterindexOf都无法去重NaN。解决方案使用SetSet内部使用“SameValueZero”比较算法认为NaN等于NaN。console.log([...new Set([1, NaN, 2, NaN])]); // [1, NaN, 2]自定义去重函数时需要单独判断NaN。function uniqueWithNaN(arr) { const result []; let hasNaN false; for (const item of arr) { if (item ! item) { // 判断NaN的经典方法NaN是唯一一个不等于自身的值 if (!hasNaN) { result.push(item); hasNaN true; } } else if (result.indexOf(item) -1) { result.push(item); } } return result; }5.2 混合类型数组的去重数组[1, ‘1‘, true, ‘true‘, null, undefined]中数字、字符串、布尔值等混合在一起。Set可以完美处理因为它区分类型。但如果你使用对象Key法就会得到错误结果如前所述。结论在类型不确定的情况下避免使用依赖隐式类型转换的方法。5.3 数组对象去重时属性值为undefined或null当使用item[key]作为Map的键时如果item[key]是undefined或null它们会被Map正常存储为键。但如果你使用普通对象lookup[item[key]]undefined和null作为键会被转换成字符串‘undefined‘和‘null‘这通常没问题但如果你同时有值为‘undefined‘的字符串属性就会冲突。建议使用Map可以避免这类隐式转换问题。5.4 保持原始顺序大多数去重方法Set、reduce查找表、filterMap都保留第一次出现的顺序。排序法会改变顺序。这是一个重要的行为差异选择方法时需要根据业务需求考虑。5.5 一个真实的踩坑案例API数据合并去重我曾遇到一个场景需要合并两个从不同接口返回的用户列表并去重。直接使用[...new Set([...listA, ...listB])]不行因为元素是对象。 使用基于id的Map去重发现两个列表中有id相同但其他字段如status不同的对象。业务要求保留listB的数据后覆盖前。最终解决方案function mergeAndDeduplicate(listA, listB, key ‘id‘) { const map new Map(); // 先遍历listA全部存入 listA.forEach(item map.set(item[key], item)); // 再遍历listB覆盖相同key的值 listB.forEach(item map.set(item[key], item)); // 返回Map中所有的值 return Array.from(map.values()); }这个案例说明去重逻辑需要紧密贴合业务。有时“去重”不仅仅是剔除还涉及到合并策略保留第一个还是最后一个或者更复杂的合并规则。数组去重这个看似简单的任务背后涉及了数据类型、比较算法、数据结构Set、Map、性能分析和具体的业务逻辑。从最简单的循环到一行Set解决再到应对复杂的对象数组每一步的演进都体现了对问题更深层次的理解和更优雅的解决思路。在日常开发中养成数据处理的洁癖在数据流入的关键节点做好去重、校验和格式化能为后续的业务逻辑扫清很多障碍。下次当你面对一堆重复数据时希望你能自信地选出最适合的那把“手术刀”。