JavaScript数组去重实战:从Set到Map处理对象数组的完整指南

发布时间:2026/8/23 3:09:30
JavaScript数组去重实战:从Set到Map处理对象数组的完整指南 1. 项目概述从“去重”这个高频操作说起在编程世界里处理数据集合是家常便饭而“去重”无疑是其中最基础、最高频的操作之一。无论是前端处理从接口拉回来的列表数据还是后端清洗数据库导出的原始记录甚至是写脚本分析日志你几乎都绕不开它。表面上看“数组去重”不就是把重复的元素去掉吗这有什么好讲的但真正上手时你会发现简单的数字或字符串数组去重只是入门一旦数组里装的是一个个对象问题就变得复杂起来。比如一个用户列表里可能有多个id相同的记录你需要根据id去重一个商品数组里可能有skuCode重复的条目你需要保留最新价格的那一个。这时候你还能用一句new Set()轻松搞定吗这就是今天要深入探讨的核心数组去重特别是数组中对象的去重以及如何根据对象中某个特定的属性进行去重。这不仅仅是写出一行能跑的代码更是理解不同场景下的性能考量、边界情况处理和代码的健壮性。我见过太多因为去重逻辑不严谨导致的线上Bug比如误删了数据或者去重后顺序错乱影响用户体验。接下来我会从最简单的场景开始逐步深入到复杂对象数组的处理分享几种主流方法的实现原理、适用场景以及我踩过的那些坑。无论你是刚入门的新手还是想巩固细节的熟手相信都能从中找到有用的东西。2. 核心思路与方案选型为什么不用一种方法走天下面对去重问题很多人的第一反应是“我用Set不就行了” 的确对于基本类型的数组Set是完美选择。但编程的魅力或者说麻烦就在于边界情况。当元素是对象时Set判断两个对象是否相同的依据是引用是否相同而不是对象的内容是否相同。这意味着{id: 1}和{id: 1}在Set眼里是两个不同的对象因为它们分别指向不同的内存地址。所以我们必须根据数据的类型和去重的逻辑选择不同的策略。整体上我们可以把去重问题分为三个层次基本类型数组去重数组元素是数字、字符串、布尔值、null、undefined、Symbol等。这是最简单的一层。对象数组去重浅比较需要判断两个对象的所有自身可枚举属性是否完全相等。这里又分两种情况对象结构完全一致以及我们只关心某几个关键属性是否相同。对象数组去重根据某个属性这是实际开发中最常见的需求。我们不在乎两个对象是否完全一样只关心它们某个特定的属性如id、code值是否相同。如果相同则视为重复需要决定保留哪一个通常是第一个或最后一个。方案选型上我们主要考虑几个维度时间复杂度性能、空间复杂度内存占用、代码可读性、以及是否能保持原数组顺序。例如双重循环的O(n²)性能在数据量大时是灾难而利用Map或Object的键唯一性可以达到O(n)。对于需要保持顺序的场景filterMap的组合就比先sort再遍历要好。3. 基础夯实基本类型数组的去重方法在深入对象之前让我们先把基础打牢。处理[1, 2, 2, 3, 4, 4, 5]或[‘a‘, ‘b‘, ‘a‘, ‘c‘]这样的数组有哪些成熟的方法呢3.1 利用 ES6 的 Set 数据结构这是现代JavaScript中最简洁、最推荐的方法。const arr [1, 2, 2, 3, 4, 4, 5]; const uniqueArr [...new Set(arr)]; // 或者 Array.from(new Set(arr)) console.log(uniqueArr); // 输出: [1, 2, 3, 4, 5]原理与优势Set是ES6引入的一种新的数据结构它类似于数组但成员的值都是唯一的没有重复的值。new Set(arr)会自动过滤掉arr中的重复项然后我们通过扩展运算符...或Array.from将其转回数组。时间复杂度O(n)因为Set的add和has操作平均时间复杂度是O(1)。空间复杂度O(n)最坏情况下需要存储所有元素。顺序Set会保持元素插入的顺序所以去重后的数组顺序与原数组一致。代码简洁性极高一行代码搞定。注意Set判断值是否相等的算法类似于Object.is()但认为NaN等于自身Object.is(NaN, NaN)返回true而传统的运算符认为NaN ! NaN。这意味着Set可以正确地对包含NaN的数组去重这是一个小优点。3.2 使用 Array.filter 与 indexOf这是一种比较传统但兼容性更好的方法在不支持ES6的环境如某些旧浏览器或特定运行环境中可以使用。const arr [1, 2, 2, 3, 4, 4, 5]; const uniqueArr arr.filter((item, index) arr.indexOf(item) index); console.log(uniqueArr); // 输出: [1, 2, 3, 4, 5]原理filter方法创建一个新数组其包含通过所提供函数实现的测试的所有元素。这里的测试条件是当前元素item在原数组arr中第一次出现的索引 (indexOf(item)) 是否等于当前索引index。如果相等说明它是第一次出现保留如果不相等说明它之前已经出现过了过滤掉。时间复杂度O(n²)。因为indexOf方法本身的时间复杂度是O(n)它在filter的每次迭代中都会被调用导致最坏情况下的平方级复杂度。对于大型数组此方法性能较差。空间复杂度O(n)。顺序保持原顺序。3.3 使用 Array.reduce 累加器reduce是一个功能强大的方法也可以用于去重虽然在此场景下不如Set直观。const arr [1, 2, 2, 3, 4, 4, 5]; const uniqueArr arr.reduce((accumulator, currentValue) { if (!accumulator.includes(currentValue)) { accumulator.push(currentValue); } return accumulator; }, []); console.log(uniqueArr); // 输出: [1, 2, 3, 4, 5]原理reduce遍历数组将每个元素与累加器初始为空数组[]进行比较。如果累加器中不包含当前元素则将其加入。时间复杂度同样是O(n²)因为includes方法需要遍历累加器数组。空间复杂度O(n)。顺序保持原顺序。实操心得对于基本类型数组无脑用Set是最佳实践。它简洁、高效、语义清晰。只有在必须兼容极老环境且无法使用 polyfill 时才考虑filter indexOf的方法并务必清楚其性能瓶颈。reduce方法在这里展示了一种思路但在对象去重时它的灵活性才会真正发挥出来。4. 进阶挑战对象数组的去重逻辑当数组元素变成对象时问题就变得有趣了。{id: 1, name: ‘Alice‘}和{id: 1, name: ‘Alice‘}在Set看来是不同的。我们需要自己定义“重复”的规则。4.1 基于对象序列化JSON.stringify的暴力去重一种直观的想法是把对象转换成字符串然后用Set对字符串去重。const arr [ {id: 1, name: ‘Alice‘}, {id: 2, name: ‘Bob‘}, {id: 1, name: ‘Alice‘}, // 重复对象 {id: 3, name: ‘Charlie‘} ]; const uniqueArr Array.from( new Set(arr.map(item JSON.stringify(item))) ).map(str JSON.parse(str)); console.log(uniqueArr); // 输出: [{id: 1, name: ‘Alice‘}, {id: 2, name: ‘Bob‘}, {id: 3, name: ‘Charlie‘}]原理先通过map将每个对象序列化为JSON字符串然后用Set对字符串数组去重最后再将去重后的字符串数组解析回对象。优点代码相对简单能处理结构完全相同的对象。致命缺点性能开销大序列化和反序列化是昂贵的操作尤其对于大对象或大数组。顺序敏感JSON.stringify对对象的键值对顺序是不稳定的尽管在现代JS引擎中通常按定义顺序输出但这不是语言规范保证的。{a:1, b:2}和{b:2, a:1}序列化后的字符串不同但它们是等价的。这会导致误判。函数和特殊类型丢失对象中包含函数、undefined、Symbol或循环引用时JSON.stringify会丢失这些信息或直接报错。注意这种方法不推荐在生产环境使用除非你百分百确定你的对象结构简单、键顺序固定且不包含任何函数等非JSON安全的值。它更像是一个快速验证想法的“玩具”方案。4.2 利用 Map 或 Object 的键唯一性进行自定义去重这是处理对象数组去重最健壮、最通用的方法核心。思路是遍历数组以一个能唯一标识对象的“键”作为Map的键key对象本身作为值value。当遇到相同的键时根据策略保留第一个或最后一个决定是否覆盖。我们先看一个根据对象所有属性进行浅比较去重的通用函数function uniqueByProps(arr, keep ‘first‘) { const map new Map(); for (const item of arr) { // 生成一个唯一标识该对象内容的键 const key JSON.stringify(Object.keys(item).sort().map(k [k, item[k]])); // 或者使用更稳定的库来生成键这里用简化版示意 if (!map.has(key) || keep ‘last‘) { map.set(key, item); } } return Array.from(map.values()); }但这个函数依然有序列化的问题。更常见的需求是根据一个或多个特定属性去重。这才是实战中的主角。5. 实战核心根据对象属性去重的多种实现假设我们有一个用户数组需要根据id去重。const users [ {id: 1, name: ‘Alice‘, age: 25}, {id: 2, name: ‘Bob‘, age: 30}, {id: 1, name: ‘Alice‘, age: 26}, // 重复的id但age不同 {id: 3, name: ‘Charlie‘, age: 35} ];5.1 使用 Array.filter 与 临时对象/Map 标记这是最经典、性能较好的方法之一。function uniqueByKey(arr, key) { const seen new Map(); // 或者用 const seen {}; return arr.filter(item { const keyValue item[key]; // 使用Map if (seen.has(keyValue)) { return false; // 已经见过过滤掉 } else { seen.set(keyValue, true); // 标记为已见 return true; // 保留 } // 如果使用普通对象 if (seen[keyValue]) { return false; } else { seen[keyValue] true; return true; } }); } const uniqueUsers uniqueByKey(users, ‘id‘); console.log(uniqueUsers); // 输出: [{id: 1, name: ‘Alice‘, age: 25}, {id: 2, name: ‘Bob‘, age: 30}, {id: 3, name: ‘Charlie‘, age: 35}] // 保留了第一个id为1的对象原理创建一个Map或普通对象seen作为哈希表来记录已经遇到过的键值。遍历数组时检查当前对象的key属性值是否已经在seen中存在。如果存在说明重复被filter过滤掉如果不存在则将其记录到seen中并保留。时间复杂度O(n)。Map的has和set操作平均是O(1)filter遍历是O(n)。空间复杂度O(n)用于存储键。顺序保留原数组中第一次出现的元素顺序。5.2 使用 Array.reduce 构建结果数组reduce方法同样能优雅地实现这个功能逻辑更集中于“构建”的过程。function uniqueByKeyReduce(arr, key) { const map new Map(); return arr.reduce((acc, current) { const keyValue current[key]; if (!map.has(keyValue)) { map.set(keyValue, true); acc.push(current); } // 如果需要保留最后一个可以改为 // map.set(keyValue, current); // 始终覆盖 // 最后返回return Array.from(map.values()); return acc; }, []); } const uniqueUsers2 uniqueByKeyReduce(users, ‘id‘); console.log(uniqueUsers2); // 结果同上原理与filter版本异曲同工都是在遍历过程中利用Map去重并将不重复的元素累积到结果数组中。我个人觉得reduce的版本意图更明确——“累积一个去重后的数组”而filter的版本更侧重于“过滤”这个动作。两者性能相当选择哪种更多是编码风格偏好。5.3 处理需要保留“最后一个”重复项的场景有时业务逻辑要求保留最后一条记录比如日志数据中后面的记录更新。我们只需要稍微修改一下逻辑。function uniqueByKeyKeepLast(arr, key) { const map new Map(); // 正向遍历用后遇到的覆盖先遇到的 for (const item of arr) { map.set(item[key], item); } // Map会保留最后一次set的值 return Array.from(map.values()); } const uniqueUsersLast uniqueByKeyKeepLast(users, ‘id‘); console.log(uniqueUsersLast); // 输出: [{id: 2, name: ‘Bob‘, age: 30}, {id: 1, name: ‘Alice‘, age: 26}, {id: 3, name: ‘Charlie‘, age: 35}] // 注意id为1的对象保留的是第二个age:26并且Map的遍历顺序是插入顺序所以Bob在前。关键点这里我们直接使用map.set(key, item)后出现的item会覆盖先出现的。由于Map的values()方法返回的迭代器遵循键的插入顺序所以最终数组的顺序是每个唯一键最后一次出现时其对应对象在原数组中的位置顺序。这有时会导致结果数组的顺序与原始顺序有较大差异如果需要严格保持原数组中“最后一个”元素出现的位置顺序可能需要更复杂的逻辑比如反向遍历。5.4 根据多个属性联合去重更复杂的情况是需要根据多个属性共同决定是否重复。例如根据department和team两个字段去重。const employees [ {name: ‘Alice‘, department: ‘Eng‘, team: ‘Frontend‘}, {name: ‘Bob‘, department: ‘Eng‘, team: ‘Backend‘}, {name: ‘Charlie‘, department: ‘Eng‘, team: ‘Frontend‘}, // 与Alice部门、团队相同视为重复 {name: ‘David‘, department: ‘Product‘, team: ‘Design‘} ]; 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)) { return false; } map.set(compositeKey, true); return true; }); } const uniqueEmps uniqueByKeys(employees, [‘department‘, ‘team‘]); console.log(uniqueEmps); // 输出: Alice, Bob, David 三条记录。Charlie被去掉了。原理将多个属性的值拼接成一个字符串作为复合键。这里选择‘|‘作为分隔符是假设属性值中不包含这个字符。这是一个潜在的坑点如果属性值本身可能包含分隔符就会导致错误的键生成。更稳健的做法是使用JSON.stringify(keys.map(k item[k]))或者像lodash这样的工具库来处理。6. 性能对比与边界情况处理了解了各种方法我们该如何选择来做一些分析和测试。6.1 时间复杂度对比方法基本类型数组对象数组根据属性说明[...new Set(arr)]O(n)不适用仅适用于基本类型性能最佳arr.filter((v,i,a)a.indexOf(v)i)O(n²)不适用性能差仅作兼容备用arr.reduce(包含includes)O(n²)不适用性能差逻辑清晰filter Map(根据属性)-O(n)推荐性能好逻辑清晰reduce Map(根据属性)-O(n)推荐性能好意图明确JSON.stringify Set-O(n)不推荐序列化开销大有功能缺陷对于对象数组根据属性去重filter Map和reduce Map是性能最优的选择。6.2 边界情况与注意事项属性值为undefined或nullconst arr [{id: null}, {id: undefined}, {id: null}]; // 使用对象 {} 作为 seen 时要注意 const seen {}; seen[null] true; // 可以键为 ‘null‘ seen[undefined] true; // 可以键为 ‘undefined‘ // 但 Map 没有这个问题它可以接受任何值作为键。 // 因此在处理可能为 undefined 的键时使用 Map 比 Object 更安全。属性值为对象嵌套对象const arr [{key: {a: 1}}, {key: {a: 1}}]; // 如果直接用 item[‘key‘] 作为 Map 的键由于两个 {a:1} 引用不同会被认为是不同的键。 // 如果需要根据深层对象去重必须递归或序列化生成键这非常复杂且性能低下。 // 通常业务设计应避免将复杂对象作为去重键。保持原数组顺序filterMap和reduceMap的方案天然保留第一次出现的顺序。如果需要保留最后一次出现的顺序如 5.3 节所示顺序可能会变。如果要求“去重但保留每个重复项最后一次出现的原始位置”则需要记录索引并进行排序复杂度会增加。大数据量下的内存Map方案需要额外存储所有唯一键如果数组极大例如千万级需要考虑内存消耗。在极端情况下可能需要流式处理或分批处理。6.3 一个健壮的通用函数实现结合以上所有考虑我们可以实现一个更健壮的通用函数/** * 根据指定键单个或多个对对象数组进行去重 * param {Array} arr - 待去重的对象数组 * param {string|string[]} key - 用于判断重复的键名可以是字符串或字符串数组 * param {‘first‘ | ‘last‘} [keep‘first‘] - 保留策略‘first‘保留首次出现的‘last‘保留最后一次出现的 * returns {Array} 去重后的新数组 */ function uniqueBy(arr, key, keep ‘first‘) { if (!Array.isArray(arr) || arr.length 0) { return []; } const isMultiKey Array.isArray(key); const map new Map(); // 定义一个函数来生成唯一标识键 const getKey (item) { if (isMultiKey) { // 对于多键使用JSON.stringify确保唯一性但需注意性能 return JSON.stringify(key.map(k item[k])); } // 对于单键直接使用值。Map支持任意类型作为键。 return item[key]; }; if (keep ‘last‘) { // 保留最后一个直接覆盖 for (const item of arr) { map.set(getKey(item), item); } return Array.from(map.values()); } else { // 保留第一个只有不存在时才设置 const result []; for (const item of arr) { const itemKey getKey(item); if (!map.has(itemKey)) { map.set(itemKey, true); result.push(item); } } return result; } }这个函数提供了单键/多键支持、保留首尾选项并使用了更安全的Map。对于多键情况使用了JSON.stringify在键值简单的情况下可以工作如果键值复杂则需要根据实际情况调整getKey函数。7. 常见问题与排查技巧实录在实际开发中除了写出核心逻辑处理各种边界情况和调试问题同样重要。7.1 问题去重后数据少了但好像不该少的也少了可能原因1键值提取错误。检查传入的key参数是否正确对象是否确实拥有这个属性。特别是从动态数据如API响应中获取时某些对象的属性可能为undefined或缺失。排查在去重函数内部打印item[key]的值看看是不是你期望的样子。可能原因2键值类型不一致导致误判。例如数字1和字符串‘1‘在Map里是不同的键但肉眼看起来一样。排查确保作为键的属性值类型是稳定的。可以在提取键值时进行强制类型转换比如String(item[key])或Number(item[key])但要谨慎评估业务逻辑是否允许。可能原因3多键去重时分隔符冲突。如果你用join(‘|‘)生成复合键而某个属性值里恰好有|就会导致键错误。解决使用更不可能出现的分隔符如‘\u0001‘或者直接使用JSON.stringify。7.2 问题去重后顺序乱了可能原因你使用了“保留最后一个”的策略并且是简单的map.set覆盖法。Map的迭代顺序虽然是插入顺序但当你用后一个覆盖前一个时键的“插入”时间并没有变变的是对应的值。Array.from(map.values())的顺序是键的插入顺序。例如原数组[A(id:1), B(id:2), C(id:1)]处理过程是插入键1-A插入键2-B遇到键1-覆盖为C。最终值的顺序是[C, B]A丢失了C出现在了B前面因为键1先于键2插入。期望保留最后一个且整体顺序维持原样即[B, C]。解决需要更复杂的逻辑比如反向遍历原数组或者记录索引后再排序。function uniqueByKeyKeepLastStable(arr, key) { const map new Map(); // 反向遍历这样后出现的元素会先被处理但插入Map的顺序是反向的 for (let i arr.length - 1; i 0; i--) { const item arr[i]; if (!map.has(item[key])) { map.set(item[key], item); } } // 此时map中键的顺序是反向的从最后一个唯一键到第一个唯一键 // 需要反转回来 const result Array.from(map.values()); result.reverse(); // 反转以匹配原数组中“最后一个”出现的位置顺序 return result; } // 测试: users [A(id:1), B(id:2), C(id:1)] // 结果: [B, C] 符合预期7.3 问题性能瓶颈处理几万条数据就很慢可能原因你使用了O(n²)的算法如双重循环、indexOfinsidefilter。解决立即切换到基于Map或Object的O(n)算法。这是最大的性能提升点。进阶优化如果数据量真的非常大百万级并且内存紧张可以考虑如果数据源是数据库优先在查询层使用DISTINCT或GROUP BY去重。在JavaScript层面如果键是简单类型使用Object{} 可能比Map在V8引擎中性能稍好但要注意键名冲突问题如__proto__。通常Map是更安全的选择。采用分治策略将大数组分割成小块分别去重后再合并去重。但这会增加复杂度。7.4 一个实用的调试技巧当你不确定去重逻辑是否正确时不要只看结果数组的长度。可以写一个辅助函数来标记被删除的元素function uniqueByKeyWithLog(arr, key) { const seen new Map(); const result []; const removed []; arr.forEach((item, index) { const keyValue item[key]; if (seen.has(keyValue)) { removed.push({ index, item, duplicateOf: seen.get(keyValue) }); } else { seen.set(keyValue, index); // 存储的是索引 result.push(item); } }); console.log(‘去重结果:‘, result); console.log(‘被移除的元素:‘, removed); return result; }这样你可以清晰地看到哪个位置的哪个元素因为和哪个索引的元素重复而被移除了对于验证逻辑非常有帮助。数组去重尤其是对象数组的去重是一个看似简单却蕴含许多细节的课题。从选择合适的数据结构Set,Map,Object到处理各种边界情况undefined、嵌套对象、顺序要求再到权衡性能与代码可读性每一步都需要根据实际业务场景做出判断。记住没有放之四海而皆准的“最佳”方法只有最适合当前场景的“最优”解。希望这些详细的拆解和实战经验能让你下次再遇到去重问题时能够胸有成竹快速写出既正确又高效的代码。