JavaScript对象数组去重:从Set局限到Map实战,性能对比与生产级方案

📅 2026/8/12 22:16:06
JavaScript对象数组去重:从Set局限到Map实战,性能对比与生产级方案
1. 项目概述为什么“去重”是前端开发中的高频痛点“JS对象数组去重”这个标题乍一看平平无奇甚至有点老生常谈。但如果你在一线业务开发中摸爬滚打过就会知道这绝对是一个高频、刚需且极易踩坑的“基础”问题。我处理过无数个因为数据重复导致的页面渲染异常、统计结果翻倍、甚至后端接口压力倍增的线上Case。一个简单的列表展示如果源头数据包含了重复的对象轻则影响用户体验重则引发逻辑错误。比如从多个接口聚合用户列表或者对本地缓存数据进行合并时去重就成了保证数据纯净度的第一道关卡。问题的核心在于JavaScript 中的数组去重对于基本数据类型如数字、字符串非常简单一个Set或者filter配合indexOf就能轻松搞定。然而一旦数组元素变成了对象情况就复杂了。{id: 1, name: ‘Alice’}和{id: 1, name: ‘Alice’}在肉眼看来是同一个对象但在JS引擎看来这是两个独立的对象引用直接使用Set或indexOf会判定为不重复。这就迫使我们必须根据对象的某个或某几个属性业务键来定义“唯一性”从而衍生出各种各样的解决方案。本文将彻底拆解这个“经典问题”不仅会罗列几种常见的实现方法更重要的是我会结合多年实战经验深入分析每种方法背后的原理、适用场景、性能边界以及那些教科书上不会写的“坑”。无论你是刚入门的新手还是希望优化现有代码的资深开发者都能从中找到可直接“抄作业”的方案和避免踩坑的 insights。2. 核心思路拆解从“引用相等”到“业务相等”的思维转换在动手写代码之前我们必须先完成一次关键的思维转换将去重的判断依据从 JavaScript 默认的“引用相等”转变为符合业务逻辑的“业务相等”。2.1 理解“引用相等”的局限性JavaScript 中对象和数组是引用类型。比较两个对象是否相等操作符比较的是它们在内存中的地址是否相同。const obj1 { id: 1 }; const obj2 { id: 1 }; const obj3 obj1; console.log(obj1 obj2); // false 虽然内容相同但引用不同 console.log(obj1 obj3); // true 指向同一个内存地址因此如果你有一个对象数组[obj1, obj2, obj1]直接用Set去重const arr [obj1, obj2, obj1]; const uniqueArr [...new Set(arr)]; console.log(uniqueArr); // 输出[{id: 1}, {id: 1}] obj1和obj2都被保留了这显然不符合业务预期。我们希望的是根据id属性去重最终只留下一个{id: 1}。2.2 定义“业务键”Key“业务相等”意味着我们需要自己定义哪些属性组合起来能唯一标识一个对象。这个或这些属性我称之为“业务键”。常见的业务键有唯一ID如user.id,product.sku。复合键如[‘department’, ‘employeeId’]组合才能唯一确定一个员工。所有属性极少数情况下需要对象的所有属性值都完全相同才视为重复。确定业务键是去重的前提它完全取决于你的数据结构与业务逻辑。没有这一步任何技术实现都是空中楼阁。2.3 方法选型的核心考量因素确定了业务键后我们面临多种实现方法。选择哪一种你需要权衡以下几个维度可读性与简洁性代码是否清晰易懂便于团队协作和维护性能数据量有多大几十条、几千条、还是十万条不同方法的时间复杂度差异很大。功能需求是否需要保留首次出现的元素是否需要保留最后一次出现的元素去重后是否需要保持原数组顺序运行环境你的代码需要支持旧的浏览器如 IE吗还是仅运行在现代浏览器或 Node.js 环境中接下来我们将深入几种主流实现方案并围绕这些维度进行详细对比。3. 主流实现方案深度解析与实操我将从最直观的方法开始逐步深入到高性能和功能强大的方案并给出每种方案的具体实现、原理剖析和选型建议。3.1 方案一基于Array.filter与Map的经典组合推荐首选这是目前我认为在可读性、性能和功能性上取得最佳平衡的方案适用于绝大多数场景。实现代码function uniqueByKey(arr, key) { const seen new Map(); return arr.filter(item { const keyValue item[key]; // 如果Map中还没有这个key值则保留该元素并记录到Map中 if (!seen.has(keyValue)) { seen.set(keyValue, true); return true; } // 如果已存在则过滤掉 return false; }); } // 使用示例 const users [ {id: 1, name: Alice}, {id: 2, name: Bob}, {id: 1, name: Alice}, // 重复ID {id: 3, name: Charlie}, {id: 2, name: Bob} // 重复ID ]; const uniqueUsers uniqueByKey(users, id); console.log(uniqueUsers); // 输出: [{id:1, name:Alice}, {id:2, name:Bob}, {id:3, name:Charlie}] // 保留了第一次出现的顺序原理解析与优势Map数据结构Map的has和set操作的平均时间复杂度是 O(1)这使得整个去重算法的时间复杂度接近 O(n)n 为数组长度性能优异。Array.filter用于创建一个新数组包含通过所提供函数测试的所有元素。它不会改变原数组符合函数式编程的“无副作用”思想更安全。顺序保留此方法天然保留了原始数组中第一次出现该业务键的元素。因为当Map中已存在该键时后续元素直接被filter过滤掉。清晰直观逻辑一目了然——用一个“登记簿”Map记录已经出现过的键没登记过的就放行并登记。实操要点与变体变体保留最后一次出现的元素只需将Map.set操作移到判断之外让它每次都更新记录。这样最后存入Map的索引对应的元素将在filter的逻辑中成为被保留的那个但实现起来稍绕更直观的做法是反转数组后使用原逻辑再去转回来。function uniqueByKeyKeepLast(arr, key) { const indexMap new Map(); // 遍历记录每个key最后一次出现的索引 arr.forEach((item, index) { indexMap.set(item[key], index); }); // 再次遍历只保留索引与Map中记录一致的即最后一次出现的 return arr.filter((item, index) indexMap.get(item[key]) index); }复合键处理如果业务键由多个属性组成可以将其序列化为一个字符串作为Map的键。function uniqueByKeys(arr, keys) { const seen new Map(); return arr.filter(item { // 例如 keys [‘dept’, ‘empId’] 生成键 ‘dept|empId’ const compositeKey keys.map(k item[k]).join(‘|’); if (!seen.has(compositeKey)) { seen.set(compositeKey, true); return true; } return false; }); }注意使用join(‘|’)要确保属性值本身不包含分隔符否则可能产生冲突。更严谨的做法是用JSON.stringify(keys.map(k item[k]))或使用Map嵌套Map的结构。3.2 方案二使用Array.reduce进行累积去重reduce是一个功能强大的数组方法非常适合这种“遍历并累积结果”的场景。实现代码function uniqueByKeyReduce(arr, key) { const resultMap arr.reduce((acc, current) { const keyValue current[key]; // 如果累积对象中还没有这个key则添加进去 if (!acc[keyValue]) { acc[keyValue] current; } // 如果需要保留最后一次出现的可以不加判断直接赋值acc[keyValue] current return acc; }, {}); // 初始值是一个空对象 // Object.values 将对象的值提取出来形成数组 return Object.values(resultMap); }原理解析reduce遍历数组其回调函数的第一个参数acc是累积器我们将其初始化为一个空对象{}。以对象的属性名作为业务键属性值作为对象本身。在遍历过程中如果当前键不存在于累积器中就添加进去。遍历结束后acc是一个键值对对象再通过Object.values()将其转换回数组。优缺点分析优点代码相对紧凑思路另一种形式的清晰。缺点顺序问题Object.values()提取出的数组其元素顺序取决于 JavaScript 引擎对对象属性的枚举顺序。虽然现代引擎通常按创建顺序维护但这并非语言规范强制保证对于整数键会有特殊处理。如果严格依赖原数组顺序此方法有风险。键名冲突使用普通对象作为Map要求业务键的值必须是字符串或 Symbol。如果键值是数字、布尔值等会被自动转换为字符串。虽然大多数ID是字符串或数字影响不大但不如Map通用。性能对于极大数组最后一步Object.values()需要额外遍历对象的所有属性。选型建议在明确业务键为字符串、且对元素顺序不敏感的场景下这是一个可选的简洁方案。但在生产环境中方案一filterMap通常更可靠。3.3 方案三双重循环与find/findIndex适用于极小数据量或特殊比较这是最传统、最直观但也是性能最差的方法。实现代码// 使用 findIndex function uniqueByKeyFindIndex(arr, key) { return arr.filter((item, index, self) { // 在当前元素之前的部分数组中查找是否有相同key的元素 return self.findIndex(el el[key] item[key]) index; }); } // 使用双重循环 function uniqueByKeyNestedLoop(arr, key) { const result []; for (let i 0; i arr.length; i) { let isDuplicate false; for (let j 0; j result.length; j) { if (result[j][key] arr[i][key]) { isDuplicate true; break; } } if (!isDuplicate) { result.push(arr[i]); } } return result; }性能陷阱分析这两种方法的时间复杂度都是O(n²)。findIndex内部也是一个循环。假设数组有 1000 个元素在最坏情况下比较操作会执行大约 50 万次1000 * 1000 / 2。当数据量达到万级别时页面会出现明显的卡顿。唯一适用场景数据量极小例如少于50条且代码简单至上。需要进行复杂的自定义比较逻辑而不仅仅是比较某个键值。例如需要判断两个对象的多个属性满足某种条件才算重复。此时可以将复杂的比较逻辑封装成一个isEqual函数替换掉el[key] item[key]这部分。强烈建议除非是上述第二种特殊情况否则在2023年以后的现代前端开发中应避免使用这种方法处理任何可能增长的数据集。3.4 方案四利用Set对序列化字符串去重快速但有限制这是一种“奇技淫巧”思路是将对象序列化为字符串如 JSON 字符串利用Set对字符串去重再反序列化回来。实现代码function uniqueByStringify(arr, key) { const seen new Set(); return arr.filter(item { const keyValue item[key]; // 或者序列化整个对象const serialized JSON.stringify(item); if (!seen.has(keyValue)) { seen.add(keyValue); return true; } return false; }); } // 注意这个例子实际上退化成了方案一因为Set存储的是keyValue。真正的序列化去重如下 function uniqueByFullStringify(arr) { const stringSet new Set(); const result []; for (const item of arr) { const str JSON.stringify(item); if (!stringSet.has(str)) { stringSet.add(str); result.push(item); } } return result; }深度解析与致命缺陷uniqueByFullStringify看起来很巧妙但它有一个致命问题它比较的是对象的全等序列化。const arr [ {id: 1, name: ‘Alice‘, age: 20}, {id: 1, name: ‘Alice‘, age: 20}, // 属性顺序、值完全一致会被去重 {id: 1, name: ‘Alice‘, age: 30}, // age不同不会被去重 {name: ‘Alice‘, id: 1, age: 20} // 属性顺序不同JSON.stringify结果不同不会被去重 ];问题总结属性顺序敏感JavaScript 对象本身是无序的但JSON.stringify的输出顺序在 ECMAScript 规范中并未严格规定虽然大多数实现是按定义顺序但这不可依赖。两个内容相同但属性定义顺序不同的对象会被判定为不重复。无法指定业务键它只能进行“完全匹配”去重无法实现基于单个id字段的去重实用性大打折扣。性能开销JSON.stringify和JSON.parse如果还需要还原是相对昂贵的操作尤其是对象结构复杂时。结论不推荐使用。除非你的需求就是精确匹配两个完全相同的对象包括所有属性的值和结构且能保证对象属性顺序一致但这种场景极少。4. 性能对比与实战选型指南纸上谈兵终觉浅我们通过一个简单的性能测试来直观感受差异。假设有一个包含10000个对象的数组每个对象有id和value属性其中包含约30%的重复id。// 生成测试数据 const dataSize 10000; const testArray []; for (let i 0; i dataSize; i) { // 生成1-7000之间的ID人为制造重复 const id Math.floor(Math.random() * 7000) 1; testArray.push({ id, value: Value for ${id} }); }我们测试方案一filterMap、方案二reduceObject、方案三findIndex在Chrome浏览器下的表现单位ms。方法时间复杂度10000条数据耗时(ms)特点推荐指数filterMapO(n)~1.5 - 3.0性能最佳顺序稳定功能灵活。可轻松变体以实现保留首次/末次。★★★★★ (首选)reduceObjectO(n)~2.0 - 4.0性能接近方案一但顺序可能不稳定键名需为字符串。★★★☆☆ (条件使用)findIndex/ 双循环O(n²)~150 - 300性能极差数据量翻倍耗时呈平方级增长。★☆☆☆☆ (避免使用)Set 序列化O(n)~5.0 - 10.0性能尚可但功能受限全量比较、顺序敏感不实用。★★☆☆☆ (不推荐)实战选型决策树默认选择无脑使用filterMap。它在性能、可读性、功能性和兼容性Map在现代浏览器和Node.js中支持良好上做到了最佳平衡。如果需要支持极旧环境如IE考虑使用对象模拟Map即方案二或者使用Array.reduce配合一个普通对象作为映射但要注意顺序和键类型问题。更好的办法是引入Map的polyfill。如果数据量极小100且逻辑简单你甚至可以使用findIndex但请务必添加注释说明未来数据增长时需要重构。如果需要基于非常复杂的条件去重可以将双循环中的比较逻辑抽离成一个自定义的comparator函数虽然性能是 O(n²)但在复杂逻辑下是唯一直接的选择。5. 常见问题、边界案例与排查技巧在实际开发中除了选择算法还会遇到各种边界情况和疑难杂症。这里记录了我踩过的坑和解决方案。5.1 问题一业务键的值为null或undefined如果作为去重依据的属性在某些对象上不存在或为null/undefined会发生什么const arr [ {id: 1, name: ‘A‘}, {name: ‘B‘}, // 无id属性 {id: null, name: ‘C‘}, {id: undefined, name: ‘D‘}, {name: ‘E‘} // 无id属性 ]; const unique uniqueByKey(arr, ‘id‘); // 结果会是什么在uniqueByKey函数中item[key]对于不存在的属性返回undefined对于存在的null返回null。Map可以接受任何值作为键包括null和undefined。所以第一个{id: 1}被加入键是1。第一个{name: ‘B‘}被加入键是undefined。{id: null}被加入键是null。第二个{id: undefined}因为键undefined已存在被过滤掉。第二个{name: ‘E‘}因为键undefined已存在被过滤掉。最终结果[{id:1}, {name:‘B‘}, {id:null}, {id:undefined}]。这可能符合预期也可能不符合。关键在于你的业务逻辑是否允许id为空以及是否将所有空值视为等同。解决方案在去重前进行数据清洗或者修改去重函数对空值进行特殊处理。function uniqueByKeyHandleNull(arr, key) { const seen new Map(); return arr.filter(item { let keyValue item[key]; // 将 null 和 undefined 统一视为 ‘NULL_KEY‘ if (keyValue null) { // 这里用 可以同时匹配 null 和 undefined keyValue ‘NULL_KEY‘; } if (!seen.has(keyValue)) { seen.set(keyValue, true); return true; } return false; }); }5.2 问题二去重后需要保持原数组“非业务键”部分的顺序有时数组中的对象除了业务键还有其他会变化的属性。去重时我们可能希望保留重复项中某个属性最新的值。const logs [ {userId: 1, status: ‘login‘, time: ‘10:00‘}, {userId: 2, status: ‘login‘, time: ‘10:01‘}, {userId: 1, status: ‘logout‘, time: ‘10:05‘}, // 同一用户状态更新了 ]; // 希望根据userId去重并保留最后一条记录最新的状态使用标准的filterMap保留首次出现会得到login状态而非logout。解决方案使用方案一中提到的“保留最后一次出现”的变体uniqueByKeyKeepLast。它的原理是先遍历一遍记录最后出现的索引再根据索引过滤从而保留最后出现的完整对象。5.3 问题三超大数组10万条的性能优化当数据量巨大时即使是 O(n) 的算法单次遍历也可能造成界面卡顿如果在前端处理。优化策略分治处理将大数组拆分成多个小块使用 Web Worker 或setTimeout进行异步分批处理避免阻塞主线程。考虑后端处理如果数据来自后端优先考虑在数据库查询时使用DISTINCT或GROUP BY进行去重从源头解决问题。使用更高效的数据结构Map已经足够高效。在极端性能敏感场景可以确保业务键是简单类型数字、短字符串以减少哈希计算开销。避免不必要的操作如果数组本身可能已大部分有序可以尝试在特定条件下提前跳出循环但通常收益不大。5.4 问题四深拷贝与引用保留我们的去重函数返回的是一个新的数组但数组中的对象元素仍然是原始对象的引用。const original [{id: 1, data: {a: 1}}]; const unique uniqueByKey(original, ‘id‘); unique[0].data.a 999; console.log(original[0].data.a); // 输出 999原始数据被修改了是否需要深拷贝这取决于你的业务场景。大多数情况下不需要去重通常是为了展示或传递一个无重复的列表修改列表中的对象属性是允许的且共享引用可以节省内存。如果需要完全隔离在去重后你对新数组的操作不希望影响原数组则需要对每个对象进行深拷贝。import cloneDeep from ‘lodash.clonedeep‘; // 使用lodash的深拷贝 function uniqueByKeyWithClone(arr, key) { const seen new Map(); return arr.filter(item { const keyValue item[key]; if (!seen.has(keyValue)) { seen.set(keyValue, true); return true; } return false; }).map(item cloneDeep(item)); // 深拷贝生成新对象 }注意深拷贝是一个昂贵的操作尤其是对象嵌套很深时需谨慎评估性能开销。6. 封装与进阶打造一个健壮的工具函数基于以上所有分析我们可以封装一个更健壮、功能更全面的工具函数用于生产环境。/** * 对象数组去重工具函数 * param {Array} arr - 待去重的对象数组 * param {String|Function|Array} identifier - 定义唯一性的标识符 * String: 对象中作为键的属性名 * Function: (item) key自定义生成唯一键的函数 * Array: [‘key1‘, ‘key2‘]多个属性组成的复合键 * param {Object} [options] - 配置选项 * param {Boolean} [options.keepLastfalse] - 是否保留最后一次出现的元素默认保留第一次 * param {Boolean} [options.deepClonefalse] - 是否对结果中的对象进行深拷贝 * returns {Array} 去重后的新数组 */ function uniqueObjectArray(arr, identifier, options {}) { const { keepLast false, deepClone false } options; const getKey (item) { if (typeof identifier ‘string‘) { return item[identifier]; } else if (typeof identifier ‘function‘) { return identifier(item); } else if (Array.isArray(identifier)) { // 处理复合键使用特殊分隔符连接注意分隔符要在业务中确保不会出现在属性值里 return identifier.map(key item[key]).join(‘$|$‘); } else { throw new Error(‘identifier 参数类型错误‘); } }; if (keepLast) { // 保留最后一次出现的逻辑 const indexMap new Map(); arr.forEach((item, index) { indexMap.set(getKey(item), index); }); const result arr.filter((item, index) indexMap.get(getKey(item)) index); return deepClone ? result.map(item JSON.parse(JSON.stringify(item))) : result; } else { // 保留第一次出现的逻辑默认 const seen new Map(); const result arr.filter(item { const key getKey(item); if (!seen.has(key)) { seen.set(key, true); return true; } return false; }); return deepClone ? result.map(item JSON.parse(JSON.stringify(item))) : result; } } // 使用示例 const data [/* ... */]; // 1. 根据‘id‘去重保留首次出现 const unique1 uniqueObjectArray(data, ‘id‘); // 2. 根据‘id‘去重保留最后一次出现 const unique2 uniqueObjectArray(data, ‘id‘, { keepLast: true }); // 3. 根据复合键[‘dept‘, ‘code‘]去重 const unique3 uniqueObjectArray(data, [‘dept‘, ‘code‘]); // 4. 自定义键生成函数例如不区分大小写的名称去重 const unique4 uniqueObjectArray(data, item item.name.toLowerCase()); // 5. 去重并深拷贝 const unique5 uniqueObjectArray(data, ‘id‘, { deepClone: true });这个函数提供了清晰的参数和配置涵盖了单键、复合键、自定义逻辑、保留顺序选择以及深拷贝需求是一个可以直接投入项目使用的实用工具。记住在真实项目中将它放入你的utils文件夹并配上详细的 JSDoc 注释和单元测试会极大地提升团队协作的效率和代码的可靠性。