尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

JavaScript数组去重全解析:从Set到Map,从基础类型到对象属性去重

JavaScript数组去重全解析:从Set到Map,从基础类型到对象属性去重 1. 从“去重”这个看似简单的问题说起在编程世界里处理数据集合是家常便饭。无论是前端从后端接口拿到一长串商品列表还是后端处理数据库查询结果我们总会遇到一个绕不开的经典问题数组去重。乍一听这问题简单得让人不屑一顾——“不就是把重复的去掉吗用个Set不就行了” 我刚开始也是这么想的直到在实际项目中被各种复杂的数据结构“教做人”。真正让“去重”变得棘手往往不是简单的数字或字符串数组而是对象数组。比如你拿到一个用户列表每个用户是一个包含id、name、email等属性的对象。这时候直接用Set就失效了因为两个内容完全相同的对象在JavaScript中引用不同Set会认为它们是两个独立的元素。更常见的业务场景是你需要根据对象的某个特定属性去重。例如一个订单列表里可能因为系统bug或数据同步问题包含了orderId相同的多条记录你需要只保留其中一条。或者在一个商品数组中根据skuId去重确保展示给用户的商品不重复。今天我们就来彻底聊聊数组去重的“三段式”进化从最基础的基本类型数组去重到进阶的对象数组整体去重再到最贴合业务需求的根据对象特定属性去重。我会结合我这些年在前端和后端Node.js开发中踩过的坑分享不同场景下的最优解、背后的原理以及那些官方文档里不会写的“骚操作”和注意事项。无论你是刚入门的新手还是想寻找更优雅方案的老手这篇文章都能给你带来直接的参考价值。2. 基础篇基本类型数组去重的“十八般武艺”我们先从最简单的开始。假设你有一个数组const arr [1, 2, 2, 3, 4, 4, 5];目标很明确得到[1, 2, 3, 4, 5]。方法有很多但性能和可读性各有千秋。2.1 经典双循环与indexOf最直观但效率堪忧这是很多初学者最容易想到的方法遍历原数组将元素放入一个新数组但在放入前检查新数组中是否已存在该元素。function uniqueBasic(arr) { const result []; for (let i 0; i arr.length; i) { if (result.indexOf(arr[i]) -1) { result.push(arr[i]); } } return result; }为什么这样写逻辑清晰符合人类直觉。indexOf方法返回元素在数组中首次出现的位置如果没找到则返回-1。踩坑点与性能分析这个方法在数据量小的时候没问题但当数组长度n较大时它的时间复杂度是O(n²)。因为外层遍历n次内层indexOf在最坏情况下结果数组几乎包含所有元素也需要遍历接近n次。对于上万条数据就会有明显的性能瓶颈。我曾经在无意中对一个包含数万条用户ID的数组使用此方法导致页面渲染卡顿了好几百毫秒被测试同学提了性能缺陷。2.2 利用对象属性唯一性一种巧妙的哈希思路JavaScript中对象的key是唯一的我们可以利用这个特性来实现去重。function uniqueByObject(arr) { const obj {}; const result []; for (let i 0; i arr.length; i) { const key arr[i]; if (!obj[key]) { obj[key] true; result.push(key); } } return result; }为什么这样写我们将数组元素作为对象的属性名。当试图给同一个key重复赋值时对象本身会保证key的唯一性。检查!obj[key]实际上是在检查这个key是否第一次出现。重大缺陷与类型转换坑这个方法有个致命问题对象的key只能是字符串或Symbol。如果你传入的数组是[1, 1, 2]数字1会被转换成字符串1导致数字1和字符串1被错误地认为是相同的元素最终结果变成[1, 2]。同样如果数组包含null、undefined或复杂类型也会出现问题。所以除非你100%确定数组元素都是同类型的字符串或数字否则不要用这个方法。我早期就曾用这个方法处理混合了数字ID和字符串ID的数组造成了难以察觉的数据丢失Bug。2.3 ES6的Set数据结构简洁高效的现代方案ES6引入的Set是专门用于存储唯一值的集合它完美契合了去重的需求。function uniqueBySet(arr) { return [...new Set(arr)]; // 或者 Array.from(new Set(arr)) }为什么这是最佳实践代码极其简洁一行代码解决问题意图明确。性能优异Set基于哈希表或类似结构实现add和has操作的平均时间复杂度接近O(1)。因此整个去重过程的时间复杂度接近O(n)远优于双循环。类型安全Set严格区分不同类型。Set可以同时包含数字1和字符串1它们被认为是两个不同的值。对于NaNSet也能正确处理在Set中NaN等于NaN这与NaN ! NaN的常规表现不同。实操心得这已经是处理基本类型数组去重的事实标准。无论是Vue/React项目中的前端数据处理还是Node.js服务端逻辑都可以放心使用。需要注意Set会保留元素第一次出现的顺序。另外Array.from和扩展运算符...的性能略有差异但在大多数场景下可忽略不计选择你或团队更习惯的即可。3. 进阶篇当数组元素变成了对象业务数据很少是简单的数字或字符串更多的是对象。比如const users [{id: 1, name: Alice}, {id: 1, name: Alice}, {id: 2, name: Bob}]。肉眼看去前两个对象完全一样我们需要去重。3.1 为什么Set和基本方法失效了直接使用[...new Set(users)]会得到原数组因为Set判断两个对象是否相同用的是严格相等运算符即比较的是引用地址。两个内容相同的字面量对象在内存中是两个不同的引用所以Set认为它们不重复。同理使用indexOf、includes或者对象key的方法也都是在比较引用地址无法达到基于对象内容去重的目的。3.2 序列化大法JSON.stringify的妙用与局限一个常见的思路是将对象转换成字符串序列化利用字符串的可比性进行去重。function uniqueObjectsByStringify(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; }为什么这样写JSON.stringify将对象转换为确定的JSON字符串。只要两个对象的内容、键的顺序完全一致生成的字符串就相同从而可以被Set识别为重复。致命的局限性与坑键顺序问题JSON.stringify({a:1, b:2})和JSON.stringify({b:2, a:1})生成的字符串不同但对象内容是等价的。这会导致本应去重的对象被保留。特殊值处理JSON.stringify会忽略值为undefined、函数Function和 Symbol 的属性。如果对象{a: undefined, b: function(){}}和{}会被序列化成相同的字符串{}造成错误去重或信息丢失。循环引用对象如果存在循环引用如obj.self objJSON.stringify会直接抛出错误。性能开销对于大型或嵌套深的复杂对象序列化和反序列化虽然这里没反序列化的CPU开销不小。注意因此JSON.stringify方案仅适用于对象结构简单、稳定且不包含函数、undefined等值的场景。在大多数生产环境中它都是一个脆弱的选择。3.3 自定义哈希函数更可控的解决方案为了解决序列化方案的弊端我们可以自己编写一个哈希函数根据我们认为重要的对象属性来生成一个唯一的键。function uniqueObjectsByHash(arr, hashFn) { const map new Map(); // 使用Map键可以是任意类型 const result []; for (const item of arr) { const key hashFn(item); if (!map.has(key)) { map.set(key, item); result.push(item); } // 如果需要保留最后一次出现的对象可以在这里覆盖map.set(key, item); } return result; } // 使用示例假设我们根据id和name的组合来去重 const users [ {id: 1, name: Alice, age: 20}, {id: 1, name: Alice, age: 21}, // age不同但根据我们的规则仍视为重复 {id: 2, name: Bob, age: 22} ]; const uniqueUsers uniqueObjectsByHash(users, (user) ${user.id}-${user.name}); console.log(uniqueUsers); // 输出前两个对象中的一个和第三个对象为什么这样写我们使用Map来存储映射关系hashFn是一个由调用者提供的函数它从对象中提取或计算出一个值作为唯一键。这给予了我们最大的灵活性。实操心得与选择何时用Map何时用普通对象当哈希键可能是非字符串类型如数字、对象引用时必须用Map。如果哈希键确定是字符串两者皆可但Map的API更友好has,get,set。哈希函数的设计是关键它决定了“怎样算重复”。你可以连接多个字段也可以对字段进行标准化处理如.toLowerCase()处理姓名。性能此方案时间复杂度为O(n)性能很好。哈希函数的复杂度决定了整体速度应尽量保持简单。4. 实战篇根据对象特定属性去重业务中最常见这是最具实战价值的场景。我们并不关心两个对象是否完全一致只关心它们的某个属性如id、orderNo、productCode是否相同。业务逻辑通常是“相同ID的订单只保留最新的一条”或“相同SKU的商品只显示一个”。4.1 利用Map的键唯一性最高效的方案Map的键是唯一的我们可以把目标属性值作为Map的键对象本身作为值。后出现的同名键会覆盖先前的这天然实现了“保留最后一次出现”的需求。function uniqueByProp(arr, prop) { const map new Map(); // 顺序遍历后出现的会覆盖先出现的 for (const item of arr) { map.set(item[prop], item); } // 通过Map的值构建结果数组 return Array.from(map.values()); } // 示例保留最后一条重复记录 const orders [ { orderId: A001, amount: 100, time: 10:00 }, { orderId: A002, amount: 200, time: 10:05 }, { orderId: A001, amount: 150, time: 10:10 } // 相同ID更新 ]; const uniqueOrders uniqueByProp(orders, orderId); console.log(uniqueOrders); // 输出: [ // { orderId: A002, amount: 200, time: 10:05 }, // { orderId: A001, amount: 150, time: 10:10 } // ]为什么这是最优解一次遍历O(n)复杂度只需遍历数组一次Map的set和get操作效率极高。代码简洁逻辑清晰易于理解。结果可控顺序遍历自然实现了“保留最后一次出现”。如果想“保留第一次出现”可以反向遍历数组或者先检查map.has(key)再决定是否set。4.2 处理复杂判断保留“更优”的记录有时去重规则更复杂不是简单地保留第一次或最后一次而是要根据其他属性判断哪条记录“更好”。例如保留金额更大的订单或者状态更“新”的记录。function uniqueByPropWithCompare(arr, prop, compareFn) { const map new Map(); for (const item of arr) { const key item[prop]; if (!map.has(key)) { map.set(key, item); } else { const existingItem map.get(key); // 使用比较函数决定保留哪一个 map.set(key, compareFn(existingItem, item)); } } return Array.from(map.values()); } // 示例保留金额更大的订单 const ordersWithAmount [ { orderId: A001, amount: 100 }, { orderId: A002, amount: 200 }, { orderId: A001, amount: 150 } ]; const uniqueOrdersMaxAmount uniqueByPropWithCompare( ordersWithAmount, orderId, (a, b) a.amount b.amount ? a : b // 比较函数返回更“优”的那个 ); console.log(uniqueOrdersMaxAmount); // 输出 orderId为A001的金额150那条和A002那条设计思路我们抽象出一个compareFn函数它接收当前已存储的对象和新的候选对象返回你认为应该保留的那一个。这赋予了函数极大的灵活性可以应对各种业务规则。4.3 使用Reduce实现函数式编程风格reduce方法非常适合这种“遍历集合并累积出一个结果”的场景代码风格更函数式。function uniqueByPropWithReduce(arr, prop) { const resultMap arr.reduce((acc, current) { acc.set(current[prop], current); return acc; }, new Map()); return Array.from(resultMap.values()); }两种风格的对比for循环更命令式性能稍好微乎其微对初学者可能更直观。reduce更声明式体现了“将数组归约为一个Map”的思维是函数式编程爱好者的首选。 在实际项目中选择团队更熟悉的风格即可性能差异通常可以忽略。5. 边界情况、性能考量与高级话题掌握了核心方法我们还需要关注一些边界情况和进阶用法才能写出健壮的代码。5.1 处理属性值为null或undefined的对象当用于去重的属性值可能为null或undefined时直接用作Map的键是允许的Map支持这些值作为键。但需要警惕业务逻辑你是否认为所有id为null的对象都是同一类应该只保留一个这需要和产品经理或业务方确认。const list [ { id: null, name: NoName1 }, { id: null, name: NoName2 }, { id: 1, name: Alice } ]; const unique uniqueByProp(list, id); console.log(unique); // 输出: [{id: null, name: NoName2}, {id: 1, name: Alice}] // 两个id为null的对象只保留了后一个5.2 超大数组的性能优化与内存考虑当处理数十万甚至百万级别的数组时我们需要考虑内存和速度。时间复杂度基于Set或Map的方案都是O(n)已经是理论最优。空间复杂度Set/Map需要额外存储所有唯一键对于对象属性去重Map还存储了对象引用空间复杂度也是O(n)。这是无法避免的。内存敏感场景如果数组极大且对象也很大使用Map存储对象引用可能导致内存压力。一种优化思路是如果后续只需要唯一键的列表而不需要完整对象可以在遍历时只将键存入Set。终极优化如果数据来自数据库最根本的优化是在查询时使用DISTINCT或GROUP BY在数据库层面去重避免将海量重复数据拉到应用层。5.3 在TypeScript中的优雅实现在TypeScript项目中为这些工具函数添加类型可以极大地提升开发体验和代码安全性。// 根据属性去重保留最后一次出现 function uniqueByPropT, K extends keyof T(arr: T[], prop: K): T[] { const map new MapT[K], T(); for (const item of arr) { map.set(item[prop], item); } return Array.from(map.values()); } // 带比较函数的版本 function uniqueByPropWithCompareT, K extends keyof T( arr: T[], prop: K, compareFn: (a: T, b: T) T ): T[] { const map new MapT[K], T(); for (const item of arr) { const key item[prop]; if (!map.has(key)) { map.set(key, item); } else { map.set(key, compareFn(map.get(key)!, item)); } } return Array.from(map.values()); } // 使用示例 interface User { id: number; name: string; } const users: User[] [...]; const uniqueUsers uniqueByProp(users, id); // 类型安全自动推导类型带来的好处编译器会确保你传入的属性名prop确实是对象类型T的键并且比较函数compareFn的参数和返回值类型正确避免了运行时错误。6. 实际项目中的综合应用与封装在实际项目中我们很少会每次手写去重逻辑。更常见的做法是进行合理的封装并在适当的时机应用。6.1 封装成工具函数或类方法你可以在项目的工具库如utils/array.ts中封装这些函数。// utils/array.js export const ArrayUtils { // 基本类型去重 uniqueBasic: (arr) [...new Set(arr)], // 根据属性去重默认保留后出现的 uniqueByProperty: (arr, prop, keepFirst false) { const map new Map(); const iterable keepFirst ? arr.slice().reverse() : arr; // 保留首次出现则反转遍历 for (const item of iterable) { map.set(item[prop], item); } const result Array.from(map.values()); return keepFirst ? result.reverse() : result; // 再反转回来保持原顺序如果需要 }, // 根据复杂条件去重 uniqueByCondition: (arr, keyFn, compareFn) { const map new Map(); for (const item of arr) { const key keyFn(item); if (!map.has(key) || (compareFn compareFn(map.get(key), item) 0)) { map.set(key, item); } } return Array.from(map.values()); } };6.2 在数据流处理中的应用在现代前端框架如Vue、React或Node.js数据处理管道中去重经常是数据清洗的一环。React示例从API获取列表并去重显示import React, { useState, useEffect } from react; import { ArrayUtils } from ./utils/array; function ProductList() { const [products, setProducts] useState([]); useEffect(() { fetch(/api/products) .then(res res.json()) .then(data { // 假设API可能返回重复的SKU根据sku去重 const uniqueProducts ArrayUtils.uniqueByProperty(data, skuCode); setProducts(uniqueProducts); }); }, []); return ( ul {products.map(product ( li key{product.id}{product.name} - {product.skuCode}/li ))} /ul ); }Node.js数据处理管道示例const processOrderData (rawOrders) { // 1. 数据清洗过滤掉无效订单 const validOrders rawOrders.filter(order order.status ! invalid); // 2. 数据去重根据订单号去重保留金额最大的 const uniqueOrders uniqueByPropWithCompare(validOrders, orderNo, (a, b) a.amount b.amount ? a : b); // 3. 数据转换格式化时间等 return uniqueOrders.map(order ({ ...order, formattedTime: formatDate(order.createTime) })); };6.3 与Lodash等工具库的对比很多项目会使用Lodash这样的工具库它提供了_.uniqBy和_.uniqWith等方法。import _ from lodash; const arr [{x: 1}, {x: 2}, {x: 1}]; const uniqueByLodash _.uniqBy(arr, x); // 根据属性去重 const uniqueWithLodash _.uniqWith(arr, _.isEqual); // 深度比较去重谨慎使用性能差我的建议是如果项目已经引入了Lodash并且团队熟悉其API使用_.uniqBy是很好的选择代码简洁。如果项目没有Lodash或者你希望减少依赖、追求极致的包体积特别是在前端那么自己实现一个轻量级的uniqueByProp函数是更优解因为逻辑本身并不复杂。特别注意Lodash的_.uniqWith使用深度比较对于复杂对象性能开销很大在处理大型数组时要慎用。数组去重从简单的值比较到复杂的业务规则判断贯穿了数据处理的始终。理解每种方法背后的原理和适用场景能帮助你在面对具体问题时快速选出最优雅、最高效的解决方案。记住没有银弹最好的方法总是最贴合你当前业务场景和数据结构的那一个。在平时的代码实践中有意识地将这些工具函数封装起来并写好注释和单元测试它们会成为你项目里可靠的数据处理“瑞士军刀”。
返回列表