尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JavaScript数组去重全攻略:从基础类型到对象数组的实战方法

JavaScript数组去重全攻略:从基础类型到对象数组的实战方法 1. 项目概述为什么“去重”是每个开发者的必修课在数据处理和前端开发中数组去重是一个高频出现且看似基础的操作。无论是从后端接口拿到一列用户数据需要过滤重复项还是在处理表单提交时避免重复的选项甚至是进行数据统计前的清洗工作“去重”都扮演着关键角色。这个项目标题“数组去重以及数组对象去重方法总结”直指一个核心痛点开发者们往往对简单数组的去重方法如数家珍比如Set、filter但一旦面对元素是对象的数组这些方法就立刻失效或者需要更精巧的处理逻辑。这背后反映的是对JavaScript中“相等性”理解的深浅。简单数组的去重依赖的是值类型的严格相等比较而对象数组的去重则涉及到引用比较和深度比较的抉择。很多新手会在这里踩坑直接用Set去处理对象数组发现毫无效果因为两个内容完全相同的对象在内存中是两个不同的引用。因此一篇系统总结从简单值到复杂对象的去重方案不仅是一份工具手册更是一次对JavaScript核心概念值类型、引用类型、相等性判断的梳理。这篇文章适合所有阶段的JavaScript开发者新手可以建立起完整的知识图谱老手则可以查漏补缺优化自己项目中可能存在的低效去重代码。2. 核心思路拆解从“相等性”出发的解决方案图谱处理去重问题万变不离其宗的核心就是如何定义“重复”。不同的数据类型和业务场景对“重复”的判断标准截然不同这直接决定了我们采用哪种方法。2.1 理解去重的本质比较算法对于数字、字符串这类基本类型Primitive Types判断重复相对简单通常使用严格相等即可。例如数字1和字符串1在严格相等下是不相同的。然而对于对象Object、数组Array这类引用类型比较的是它们在内存中的引用地址而非内容。即使两个对象拥有完全相同的键值对它们也是不相等的。因此我们的方法总结需要分为两大阵营基本类型数组去重利用值本身的唯一性。对象数组去重需要自定义“唯一性”的判定依据通常基于一个或多个属性Key。2.2 方案选型考量性能、可读性与场景适配选择哪种方法不能只看代码是否简短更需要权衡数据规模处理几十条数据和几万条数据对性能的要求天差地别。双重循环在小数据量下直观在大数据量下则是性能灾难。运行环境你的代码需要支持古老的IE浏览器吗还是只运行在现代浏览器或Node.js高版本这决定了能否使用Set、Array.from、includes等ES6的新特性。去重标准对象数组是根据id去重还是根据name和age的组合去重是否需要深度比较递归比较所有嵌套属性代码可维护性团队协作中一段使用了巧妙但晦涩难懂的技巧的代码可能远不如一段清晰明了但稍长的代码。基于这些考量我们将方法分为“基本类型去重”和“对象数组去重”两大类并在每类中从最传统的方法讲到最现代、最优雅的方法同时分析其优缺点。3. 基本类型数组去重方法全解析假设我们有一个混合了数字和字符串的数组const arr [1, 2, 2, 3, 3, 4, 5, 1, 2];。我们的目标是得到[1, 2, 3, 4, 5, 2]。注意数字2和字符串2是不同的。3.1 经典双循环与indexOf法这是最原始、兼容性最好的方法其核心思路是创建一个新数组遍历原数组只有当当前元素在新数组中“找不到”时才将其加入。function uniqueByLoop(arr) { const result []; for (let i 0; i arr.length; i) { // 或者使用 arr.indexOf(arr[i]) i 的判断 if (result.indexOf(arr[i]) -1) { result.push(arr[i]); } } return result; }原理与注意事项indexOf方法返回在数组中找到给定元素的第一个索引如果不存在则返回-1。这里我们检查当前元素arr[i]是否已经存在于结果数组result中。时间复杂度是O(n²)因为indexOf本身也是一个遍历操作。对于大型数组性能极差。一个常见的坑indexOf使用的是严格相等比较这正好符合我们的需求能区分2和2。3.2 利用对象属性Key的唯一性JavaScript对象的属性名Key是字符串类型的且是唯一的。我们可以利用这个特性来实现去重。function uniqueByObject(arr) { const obj {}; const result []; for (let i 0; i arr.length; i) { const item arr[i]; // 使用 typeof item item 作为key避免数字1和字符串1被混淆为同一个key const key typeof item item; if (!obj[key]) { obj[key] true; result.push(item); } } return result; }实操心得这种方法的时间复杂度接近O(n)性能比双循环好很多。关键技巧在于构建唯一的key。typeof item item将类型信息也包含进去使得(number)1和(string)1分别对应number1和string1两个不同的key从而被正确区分。潜在问题如果数组元素是对象、数组、函数等引用类型typeof item item会得到如object[object Object]这样的key这会导致所有不同的对象被误判为同一个。因此此方法仅适用于纯基本类型数组。3.3 ES6的明星方案Set与Spread/Array.fromES6引入的Set数据结构它允许你存储任何类型的唯一值无论是基本类型还是对象引用。这正是为去重量身定做的工具。// 方法1使用扩展运算符(...) const uniqueBySet1 (arr) [...new Set(arr)]; // 方法2使用Array.from const uniqueBySet2 (arr) Array.from(new Set(arr));为什么这是现代开发的首选简洁优雅一行代码解决问题意图清晰。高性能Set内部实现通常基于哈希表add和has操作的时间复杂度接近O(1)因此整体去重算法时间复杂度为O(n)。功能正确Set使用“SameValueZero”算法判断相等性它类似于严格相等但认为NaN等于自身而NaN NaN是false。这处理了一个边界情况。注意Set去重对象数组时比较的仍然是引用地址。Set无法自动根据对象内容去重。const set new Set([{id:1}, {id:1}]);这个set的size是2。3.4 利用filter与indexOf的巧妙组合这个方法结合了filter的高阶函数特性和indexOf的查找能力代码非常简洁。function uniqueByFilter(arr) { return arr.filter((item, index) arr.indexOf(item) index); }核心逻辑拆解filter会遍历数组只保留那些使得回调函数返回true的元素。回调函数中arr.indexOf(item)返回的是元素item在数组中第一次出现的位置。如果这个位置等于当前遍历的index说明当前元素是第一次出现应该保留否则它就是重复项因为它的第一次出现位置一定小于当前索引应该被过滤掉。性能提示这个方法虽然简洁但内部indexOf的调用导致其时间复杂度依然是O(n²)不适用于大数据量场景。它更像是函数式编程风格的一种体现在数据量不大时追求代码的简洁性。4. 对象数组去重方法深度剖析当数组的元素是对象时游戏的规则就变了。const users [{id:1,name:A}, {id:2,name:B}, {id:1,name:A}]。肉眼可见最后两个对象重复但JavaScript不这么认为。4.1 基于唯一标识符Key的Map/Object字典法这是处理对象数组去重最常用、最高效的方法。思路是以一个能够唯一标识对象的属性如id作为键Key将对象本身作为值Value存入一个Map或普通对象中。利用键的唯一性自动去重。// 使用Map (ES6 推荐) function uniqueByMap(arr, key) { const map new Map(); return arr.filter(item { // 获取当前对象作为唯一标识的属性值 const keyValue item[key]; // 如果Map中还没有这个key则设置并返回true保留 if (!map.has(keyValue)) { map.set(keyValue, item); return true; } return false; }); } // 使用Object function uniqueByObjectKey(arr, key) { const obj {}; const result []; for (const item of arr) { const keyValue item[key]; if (!obj[keyValue]) { obj[keyValue] true; result.push(item); } } return result; }实操要点与选择MapvsObject优先使用Map。因为Map的键可以是任何类型数字、字符串、甚至对象而Object的键只能是字符串或Symbol。如果我们的唯一标识id是数字Map会直接使用数字1作为键而Object会将其转换为字符串1。虽然在这个场景下影响不大但Map在语义和严谨性上更胜一筹且它维护了元素的插入顺序。filter方法上述Map的示例使用了filter代码非常函数式易于理解。它遍历原数组根据Map.has的结果决定是否保留当前项。多属性联合去重有时唯一性需要由多个属性共同决定比如根据name和age去重。这时可以构造一个复合键。function uniqueByMultipleKeys(arr, keys) { const map new Map(); return arr.filter(item { // 将多个属性值用特殊符号连接成一个字符串作为复合键 const compositeKey keys.map(k item[k]).join(|); if (!map.has(compositeKey)) { map.set(compositeKey, item); return true; } return false; }); } // 使用uniqueByMultipleKeys(users, [name, age])保留哪一项上述方法保留的是第一次出现的项。如果需要保留最后一次出现的项可以反向遍历数组或者稍微修改逻辑用新值覆盖旧值。4.2 使用JSON序列化的取巧方法与巨大隐患网络上常见一种方法将每个对象用JSON.stringify转换成字符串然后利用Set对字符串去重最后再JSON.parse回来。function uniqueByJSON(arr) { const jsonStrings arr.map(item JSON.stringify(item)); const uniqueStrings [...new Set(jsonStrings)]; return uniqueStrings.map(str JSON.parse(str)); }为什么强烈不推荐性能低下序列化和反序列化整个对象是昂贵的CPU操作尤其是对象很大或数组很长时。键顺序问题JSON.stringify的输出依赖于对象属性的枚举顺序。{a:1, b:2}和{b:2, a:1}在逻辑上是相同的对象但序列化后的字符串不同{a:1,b:2}vs{b:2,a:1}导致错误地去重失败。功能缺失对象中如果包含undefined、函数或Symbol类型它们在序列化时会被忽略undefined、函数或抛出错误Symbol导致信息丢失。特殊值NaN、Infinity、null等值在序列化后都会变成null可能引发错误。结论除非你百分之百确定你的对象数组结构极其简单、稳定且不关心性能否则不要使用这种方法。它更像一个“面试题解法”而非“工程实践”。4.3 终极方案通用型深度比较去重函数在某些极其特殊的场景下你可能需要真正的“深度去重”——即两个对象在所有嵌套层级上的属性值都完全相同时才被视为重复。这需要递归比较实现复杂且性能开销大。function isDeepEqual(obj1, obj2) { // 这是一个简化的深度比较示例生产环境建议使用lodash的isEqual if (obj1 obj2) return true; if (typeof obj1 ! object || obj1 null || typeof obj2 ! object || obj2 null) { return false; } const keys1 Object.keys(obj1); const keys2 Object.keys(obj2); if (keys1.length ! keys2.length) return false; for (const key of keys1) { if (!keys2.includes(key) || !isDeepEqual(obj1[key], obj2[key])) { return false; } } return true; } function uniqueByDeepCompare(arr) { const result []; for (const item of arr) { // 检查当前项是否在结果数组中已存在深度相等 const isDuplicate result.some(existingItem isDeepEqual(existingItem, item)); if (!isDuplicate) { result.push(item); } } return result; }重要建议在真实项目中几乎不需要自己实现深度比较去重。首先这种需求本身是否合理值得商榷。其次如果确实需要强烈建议使用成熟的第三方库如lodash的_.uniqWith或_.isEqual。import _ from lodash; const uniqueUsers _.uniqWith(users, _.isEqual);使用库函数可以保证算法的正确性、健壮性和性能库函数通常有大量的优化和边界情况处理。5. 性能对比与实战场景选择指南了解了所有方法后我们该如何选择下面通过一个简单的性能测试和场景分析来给出决策路径。5.1 方法性能横向对比我们构造一个包含10000个随机整数的数组其中包含大量重复进行粗略测试环境现代Chrome浏览器。方法描述时间复杂度实测耗时近似适用场景双循环/indexOf传统方法兼容性好O(n²)~150ms极小数据量兼容性要求极高的环境如旧版IE对象Key法利用对象属性唯一性O(n)~0.5ms纯基本类型数组且环境不支持ES6filterindexOf函数式简洁写法O(n²)~120ms数据量小追求代码简洁的非性能关键路径Set扩展运算符ES6现代方法O(n)~0.2ms基本类型数组首选代码简洁性能优Map字典法基于唯一KeyO(n)~0.8ms对象数组去重首选灵活高效JSON序列化取巧方法O(n) 序列化开销~5ms不推荐用于生产环境隐患多深度比较递归比较所有属性O(n²) * 比较开销极慢1000ms特殊需求建议直接使用lodash等库结论一目了然对于基本类型无脑用[...new Set(arr)]。对于对象数组用Map或Object构建字典根据唯一Key来过滤。5.2 根据业务场景选择最佳实践场景一处理后端返回的用户列表根据userId去重。分析数据是对象数组唯一标识是userId数据量可能从几十到上万。选择Map字典法。代码清晰性能好能明确保留第一条或最后一条数据。const uniqueUsers Array.from(new Map(users.map(user [user.userId, user])).values()); // 这个技巧更精简用userId作为Map的键最后取Map的值。它保留的是同一userId最后出现的项。 // 如需保留第一条请使用前面filterMap的示例。场景二从多个来源合并标签数组字符串并去重。分析数据是基本类型字符串数组需要简单合并并去重。选择Set扩展运算符法。这是最语义化、最简洁的方式。const allTags [...new Set([...tags1, ...tags2, ...tags3])];场景三在需要支持IE9的古老项目中对数字ID数组去重。分析环境限制无法使用ES6特性。选择对象Key法。性能优于双循环。function uniqueLegacy(arr) { var obj {}, result []; for (var i 0; i arr.length; i) { var key typeof arr[i] arr[i]; if (!obj[key]) { obj[key] true; result.push(arr[i]); } } return result; }场景四需要根据对象的多个属性进行联合去重。分析例如去重购物车商品需要同时匹配productId和skuId。选择扩展的Map字典法使用复合键。const uniqueCartItems uniqueByMultipleKeys(cart, [productId, skuId]);6. 常见问题与避坑实录在实际开发中除了选择算法还会遇到一些意想不到的问题。6.1NaN去重的陷阱NaN在JavaScript中是一个特殊的存在它不等于任何值包括它自己。这会让一些去重方法失效。const arrWithNaN [1, NaN, 2, NaN, 1]; console.log([...new Set(arrWithNaN)]); // 输出[1, NaN, 2, NaN] ? 等等不对 // 实际输出是[1, NaN, 2]惊喜吗实际上Set以及Map使用的“SameValueZero”比较算法认为NaN是等于自身的所以Set能正确去重NaN。而使用indexOf的方法就会失败因为arrWithNaN.indexOf(NaN)永远返回-1。避坑指南如果你的数据可能包含NaN并且你需要兼容旧环境不能用Set需要在去重逻辑中单独处理NaN。function uniqueIncludesNaN(arr) { const result []; let hasNaN false; for (let i 0; i arr.length; i) { const item arr[i]; if (item ! item) { // 判断NaN的经典方法NaN是唯一一个不等于自身的值 if (!hasNaN) { result.push(item); hasNaN true; } } else if (result.indexOf(item) -1) { result.push(item); } } return result; }6.2 引用类型去重与“深拷贝”的混淆有时开发者会犯这样的错误他们去重了一个对象数组然后修改了结果数组中的某个对象却发现原数组中对应的对象也变了。const original [{id: 1}, {id: 2}]; const unique uniqueByMap(original, id); // 假设使用Map法 unique[0].name newName; console.log(original[0].name); // 输出newName原数组也被修改了。问题根源去重操作无论是Map法还是filter法并没有创建新的对象它只是将原数组中对象的引用复制到了新数组。两个数组中的元素指向内存中的同一个对象。解决方案如果去重后需要独立修改应该在去重过程中或之后进行深拷贝。// 在去重函数中加入深拷贝使用JSON方法注意其局限性 function uniqueAndCloneByMap(arr, key) { const map new Map(); return arr.filter(item { const keyValue item[key]; if (!map.has(keyValue)) { // 这里进行深拷贝 map.set(keyValue, JSON.parse(JSON.stringify(item))); return true; } return false; }); } // 或者去重后再统一深拷贝 const uniqueCloned uniqueByMap(original, id).map(item ({...item})); // 浅拷贝只解决一层6.3 超大数组去重的性能优化思考当数组长度达到十万、百万级别时即使是O(n)的算法也可能有压力。此时可以考虑空间换时间Map/Set/Object字典法已经是O(n)时间复杂度主要开销在内存。确保你的代码是最优实现避免在循环内进行不必要的操作。分批处理如果数据可以分批处理例如来自分页接口应在每一批数据到达时就进行去重最后再合并去重而不是累积所有数据后再一次性处理。Web Worker将去重任务放到后台线程避免阻塞主线程导致页面卡顿。服务端去重如果数据来源于服务端最根本的优化是让服务端返回去重后的数据从源头上解决问题。6.4 第三方库的选用对于生产环境如果项目已经引入了lodash或underscore直接使用它们的去重方法是省心且可靠的选择。_.uniq(array)基本类型数组去重。_.uniqBy(array, [iteratee_.identity])对象数组根据迭代器返回值去重。_.uniqBy(users, id)。_.uniqWith(array, [comparator])使用自定义比较函数去重可用于深度比较。_.uniqWith(users, _.isEqual)。这些函数经过千锤百炼考虑了各种边界情况性能也有保障。不要重复造轮子除非你有非常特殊的定制化需求。
返回列表