当前位置: 首页 > news >正文

JavaScript对象数组去重:从核心原理到高性能工程实践

1. 项目概述:为什么“去重”是前端开发者的基本功

在JavaScript的日常开发里,处理数据是家常便饭,而数组和对象又是其中最核心的数据结构。我敢说,几乎每个前端开发者都遇到过这样的场景:从后端拿到一个用户列表,里面可能有重复的用户ID;或者处理一组商品数据,需要合并不同来源但可能重复的商品信息。这时候,“去重”就成了一个绕不开的操作。

简单数组去重,比如[1, 2, 2, 3]变成[1, 2, 3],方法很多,SetfilterindexOf,信手拈来。但问题一旦升级到对象数组,事情就变得棘手了。[{id: 1}, {id: 1}],这两个对象看起来一样,但在JavaScript引擎眼里,它们是两个独立的内存引用,直接比较{} === {}结果是false。这就意味着,那些对简单数组行之有效的方法,在对象数组面前几乎全部失效。

“JS对象数组去重”这个标题,背后直指的就是这个高频且具体的痛点。它不是一个炫技的算法题,而是一个实实在在的工程问题。处理不好,轻则导致前端展示重复,用户体验下降;重则可能在数据统计、状态同步时引发逻辑错误。因此,掌握一套可靠、高效且适应不同场景的对象数组去重方案,是区分一个合格前端和熟练前端的重要标志之一。接下来,我就结合自己多年的踩坑经验,把对象数组去重的门道给你彻底讲透。

2. 核心思路拆解:从“相等”的定义出发

对象数组去重的核心,在于如何定义两个对象“相等”。对于计算机来说,没有模糊的概念,我们必须给出精确、可执行的判断规则。根据业务场景的不同,这个“相等”的定义通常分为几个层次,选择的方案也截然不同。

2.1 基于唯一标识符的去重

这是最常见、也最实用的场景。对象数组中每个对象都有一个或多个属性可以唯一标识它自己,比如用户的id、商品的sku、文章的postId。我们的目标就是保留这些唯一标识符首次出现的对象。

为什么这是首选方案?因为在真实的业务数据中,对象往往是复杂且动态的。除了核心ID,其他属性(如name,price,status)可能会因为数据来源不同、更新时间不同而有细微差异。如果我们追求所有属性完全一致,反而可能丢失有效的数据版本。基于唯一标识符去重,逻辑清晰,符合大多数业务语义(例如,同一个用户不应该在列表中出现两次)。

实现思路:我们需要一个临时存储(通常用Map或普通对象)来记录已经出现过的“键”。遍历数组,为每个对象生成一个“键”(通常是标识符属性的值),检查这个键是否已存在。如果不存在,则记录该键,并将当前对象放入结果数组;如果已存在,则跳过。

2.2 基于对象全等比较的去重

这种场景相对较少,但确实存在。比如,你需要确保数组中的每个对象引用都是唯一的,或者你的数据对象结构简单且稳定,要求所有属性值必须完全一致才视为重复。

为什么使用场景有限?因为JavaScript中对象是引用类型。即使两个对象的内容一模一样,它们也是不同的引用。因此,直接比较obj1 === obj2只有在它们指向内存中同一地址时才为真。要实现“内容全等”比较,就需要深度遍历对象的每一个属性,进行递归或序列化比较,性能开销较大,且对于包含函数、循环引用的对象处理起来很麻烦。

实现思路:通常采用序列化的方式,将对象转换为字符串(如JSON.stringify),然后用字符串去重的方法。但这种方法有局限性(函数、undefined、特定对象类型会被忽略或转换),且性能不是最优。更严谨的做法是实现一个深度比较函数,但复杂度高,一般只在特殊需求下使用。

2.3 基于自定义比较函数的去重

这是最灵活的方式。当“相等”的逻辑不能用简单的属性名或深度比较概括时,就需要自定义。例如,去重规则是“姓名和城市相同即视为同一人”,或者“价格相差在5元以内视为相同商品”(当然,这严格来说不是去重,是聚类,但逻辑类似)。

为什么需要灵活性?业务逻辑是千变万化的。框架和库提供的是通用能力,而自定义比较函数是将业务规则注入到工具方法中的桥梁。它把判断两个对象是否“重复”的权力完全交给了开发者。

实现思路:实现一个通用的去重函数,它接受一个数组和一个比较函数作为参数。这个比较函数接收两个对象,返回一个布尔值表示它们是否相等。在内部,仍然需要通过遍历和临时存储来记录已经出现过的“等价类”,但比较逻辑由外部函数决定。

3. 方案实现与深度解析

理论讲完了,我们直接上代码,看看每种思路具体怎么实现,并深入分析其中的细节和陷阱。

3.1 方案一:使用 Map 与唯一键(推荐)

这是目前性能最佳、语义最清晰的方案,适用于绝大多数基于标识符去重的场景。

/** * 根据对象中指定的唯一键进行去重 * @param {Array} arr - 待去重的对象数组 * @param {String|Function} key - 唯一键的属性名,或一个生成唯一键的函数 * @returns {Array} 去重后的新数组 */ function uniqueByKey(arr, key) { // 参数校验 if (!Array.isArray(arr)) { throw new TypeError('Expected an array as the first argument'); } if (arr.length === 0) return []; const map = new Map(); const result = []; for (const item of arr) { // 处理key为函数的情况,允许动态生成唯一标识 const identifier = typeof key === 'function' ? key(item) : item[key]; // 关键:检查标识符是否为有效值(避免undefined或null作为键导致的问题) if (identifier == null) { // 宽松相等,检查 null 和 undefined // 处理策略:可以选择跳过、抛出错误或允许其通过。这里我们选择跳过并给出警告(生产环境可记录日志) console.warn(`Item with invalid key (${identifier}) encountered and skipped:`, item); continue; } // 如果Map中还没有这个标识符,则存入并加入结果数组 if (!map.has(identifier)) { map.set(identifier, true); // 值存true即可,我们只关心键是否存在 result.push(item); } // 如果已存在,则跳过。这里可以根据需要保留第一次或最后一次出现的项。 // 当前逻辑保留第一次出现的项。 } return result; } // 使用示例 const users = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 1, name: 'Alice Again' }, // 重复的id { id: 3, name: 'Charlie' }, { id: 2, name: 'Bob the Second' }, // 重复的id { name: 'NoID' } // 缺少id属性的对象 ]; console.log(uniqueByKey(users, 'id')); // 输出: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }] // 注意:'NoID'对象被跳过并警告 // 使用函数生成复杂键 const orders = [ { userId: 1, productId: 'A', date: '2023-10-01' }, { userId: 1, productId: 'B', date: '2023-10-01' }, { userId: 1, productId: 'A', date: '2023-10-02' }, { userId: 2, productId: 'A', date: '2023-10-01' }, ]; // 去重逻辑:同一用户在同一日期下的同一产品只保留第一单 const uniqueOrders = uniqueByKey(orders, (order) => `${order.userId}-${order.productId}-${order.date}`); console.log(uniqueOrders);

深度解析与注意事项:

  1. 为什么用Map而不用普通对象{}

    • 键的类型Map的键可以是任何类型(包括对象、函数),而普通对象的键只能是字符串或 Symbol。虽然我们的标识符通常是字符串或数字,但使用Map更具通用性和严谨性,避免了数字键被自动转换为字符串等隐式转换问题。
    • 性能:在频繁的增删查操作中,Map的性能通常优于普通对象,尤其是在键的数量较多时。
    • 顺序Map会记住键的原始插入顺序,这在某些需要保持去重后顺序的场景下是个优点(虽然我们这里用数组本身来保证顺序)。
  2. key参数的处理:支持字符串和函数两种形式,极大地增强了灵活性。函数形式让你可以处理复合键、计算键等复杂场景。

  3. 空值处理:这是非常关键的一点!如果对象的标识符属性是undefinednullMap可以存储它们(Map可以存undefinednull作为键),但这通常意味着数据有问题。上面的实现选择跳过并警告,防止无效数据污染结果集。在实际项目中,你需要和业务方确认对此类数据的处理策略。

  4. 保留首次还是末次?上述代码保留首次出现的项,这是最常见的需求。如果你想保留最后一次出现的项,只需将result.push(item)的逻辑改为更新对应位置,但这会更复杂。一个简单的技巧是反向遍历数组,然后反转结果,但会改变相对顺序。更清晰的做法是在Map里存储对象本身,最后用Array.from(map.values()),但这会丢失首次出现之后、末次出现之前其他对象的顺序。

3.2 方案二:使用 JSON.stringify 与 Set(慎用)

这个方案常被新手想到,因为它代码非常简短。

function uniqueByJSON(arr) { if (!Array.isArray(arr)) return []; const stringSet = new Set(); const result = []; for (const obj of arr) { const str = JSON.stringify(obj); if (!stringSet.has(str)) { stringSet.add(str); result.push(obj); } } return result; }

深度解析与严重缺陷:

警告:此方法不推荐用于生产环境,仅适用于非常特定的、可控的简单场景。

  1. 序列化陷阱JSON.stringify有众所周知的局限性:

    • undefined、函数、Symbol 类型的属性值会被完全忽略,不会出现在字符串中。{a: undefined, b: 1}{b: 1}会被认为是相同的。
    • 如果对象有循环引用,直接调用会报错。
    • NaNInfinity会被转换成null
    • Date对象会被转换成字符串。
    • 属性的顺序可能会影响序列化结果(虽然ECMA规范未定义对象属性的枚举顺序,但大多数现代引擎会按创建顺序,不过依赖这个并不安全)。
  2. 性能问题:对于大对象或大数组,序列化整个对象是昂贵的操作,尤其是当对象结构复杂时。

  3. 什么情况下可以用?仅当你100%确定数组中的对象是简单的、平面的(没有嵌套对象/数组)、不包含上述特殊值、并且属性顺序稳定时,可以作为一种“快速原型”手段。即便如此,我也建议用更明确的方案一。

3.3 方案三:使用 reduce 与 find/findIndex(理解思路,但不推荐)

这是一种更“函数式”的写法,但在性能上存在隐患。

// 使用 findIndex 进行深度比较(假设有 deepEqual 函数) function uniqueByDeepCompare(arr) { return arr.reduce((acc, current) => { // 在累积数组acc中查找是否已存在“深度相等”的对象 const isDuplicate = acc.some(item => deepEqual(item, current)); if (!isDuplicate) { acc.push(current); } return acc; }, []); } // 使用 findIndex 基于某个键 function uniqueByKeyWithReduce(arr, key) { return arr.reduce((acc, current) => { const isDuplicate = acc.findIndex(item => item[key] === current[key]) > -1; if (!isDuplicate) { acc.push(current); } return acc; }, []); }

深度解析与性能瓶颈:

  1. 算法复杂度:这是这种方法最大的问题。对于数组中的每一个元素(n个),都要在结果数组(最坏情况下也是n个)中遍历查找(findIndexsome是 O(n) 操作)。这导致了 O(n²) 的时间复杂度。当数组长度超过几百时,性能下降会非常明显。
  2. deepEqual的代价:如果使用深度比较,每次比较的代价 O(k)(k为对象大小)会叠加在 O(n²) 上,使得性能雪上加霜。
  3. 可读性:虽然reduce很强大,但这段代码的逻辑不如方案一中的for...of循环配合Map那样直观易懂,尤其是对不熟悉函数式编程的同事。

结论不推荐在需要处理可能较大数组的场景下使用此方法。方案一(Map)的时间复杂度是 O(n),空间复杂度也是 O(n),性能优势巨大。

3.4 方案四:终极灵活方案——自定义比较函数

将比较逻辑抽象出来,提供一个通用的去重工具函数。

/** * 通用对象数组去重函数 * @param {Array} arr - 待去重的对象数组 * @param {Function} comparator - 比较函数,接收两个对象,返回true表示相等 * @returns {Array} 去重后的新数组 */ function uniqueByComparator(arr, comparator) { if (!Array.isArray(arr)) return []; if (typeof comparator !== 'function') { throw new TypeError('Comparator must be a function'); } const result = []; // 这里我们仍然需要一个机制来记录“已见过”的对象。 // 但由于比较规则自定义,我们无法简单地用一个键来记录。 // 一种方法是:对于result中的每个新元素,都遍历result中已存在的元素进行比较。 // 但这又回到了O(n²)的复杂度。 // 更优的方法是要求comparator能生成一个可哈希的“签名”,或者接受一个额外的`keyGetter`函数。 // 下面提供一个更实用的变体,它结合了key生成器和比较器。 return result; // 基础框架,实现见下方变体 } /** * 增强版:结合键生成器和比较器,优先使用键进行高效去重,键冲突时使用比较器 * @param {Array} arr * @param {Function} keyGetter - 生成用于快速查找的键的函数 * @param {Function} [comparator] - 可选,当键冲突时,用于精细比较的函数 * @returns {Array} */ function uniqueAdvanced(arr, keyGetter, comparator) { const map = new Map(); const result = []; for (const item of arr) { const key = keyGetter(item); // 如果键无效,处理策略同方案一 if (key == null) { console.warn(`Invalid key generated, item skipped:`, item); continue; } if (!map.has(key)) { // 如果这个键第一次出现,直接存入 map.set(key, item); result.push(item); } else if (comparator) { // 如果键已存在,并且提供了比较器,则用比较器判断当前对象和已存储的对象是否“重复” const existingItem = map.get(key); if (!comparator(existingItem, item)) { // 如果比较器认为不重复(注意:这里逻辑是“不重复才添加”,根据comparator语义调整) // 但通常,相同的key我们已经认为是同一类,这里comparator用于处理“key相同但实际不同”的边缘情况。 // 更常见的需求是:key相同,且comparator也认为相同,才去重。否则,我们需要一个新的、不冲突的key? // 这揭示了设计上的复杂性。通常,keyGetter应该能生成绝对唯一的标识。 // 因此,comparator在这里可能不是必须的,或者用于二次确认。 // 一个更简单的通用设计是只使用comparator,但用Map存储序列化后的比较结果,这又回到了性能问题。 // 结论:对于极度复杂的去重逻辑,可能需要专门定制算法,而非通用函数。 } } // 如果键已存在且没有提供comparator,或comparator认为重复,则跳过(保留首次出现的) } return result; }

深度解析与设计权衡:

这个方案展示了设计通用工具的复杂性。纯comparator的方案会导致性能低下(O(n²))。而keyGetter方案本质上就是我们的方案一keyGetter+comparator的混合模式试图在效率和灵活性间取得平衡,但逻辑变得复杂,且comparator的调用场景(键冲突时)可能很少。

实操建议:

  • 99%的场景,使用方案一(uniqueByKey)就足够了。确保你的数据有一个可靠的主键或复合键。
  • 对于那1%需要复杂判等逻辑的场景,认真评估是否真的需要通用的去重函数。也许针对那个特定场景写一个特殊的去重逻辑更简单、更高效。
  • 如果一定要写通用函数,可以考虑让comparator函数同时返回一个用于快速查找的“哈希码”(不要求严格唯一,但能大大减少需要深度比较的候选对),但这实现起来就更复杂了。

4. 性能对比与实战选型

光说不练假把式,我们写个简单的测试来对比一下方案一(Map)、方案三(Reduce+findIndex)和方案二(JSON)的性能差异。我们构造一个包含10000个对象的数组,其中约有30%的重复项。

// 生成测试数据 function generateTestData(size, duplicateRate) { const data = []; for (let i = 0; i < size; i++) { data.push({ id: i, value: `Value${i}`, nested: { prop: Math.random() } }); } // 添加一些重复项 const duplicateCount = Math.floor(size * duplicateRate); for (let i = 0; i < duplicateCount; i++) { const randomIndex = Math.floor(Math.random() * size); data.push({ ...data[randomIndex] }); // 浅拷贝,创建内容相同但引用不同的对象 } return data.sort(() => Math.random() - 0.5); // 打乱顺序 } const testData = generateTestData(10000, 0.3); console.log(`测试数据量:${testData.length}`); // 方案一:Map console.time('uniqueByKey-Map'); const result1 = uniqueByKey(testData, 'id'); console.timeEnd('uniqueByKey-Map'); console.log(`结果长度:${result1.length}`); // 方案三:Reduce + findIndex (基于键) console.time('uniqueByKey-Reduce'); const result3 = testData.reduce((acc, current) => { const isDuplicate = acc.findIndex(item => item.id === current.id) > -1; if (!isDuplicate) acc.push(current); return acc; }, []); console.timeEnd('uniqueByKey-Reduce'); console.log(`结果长度:${result3.length}`); // 方案二:JSON (仅作对比,数据符合其要求) // 注意:我们的测试数据包含`nested`对象和`Math.random`,JSON序列化后,由于nested.prop值不同,重复项可能无法被正确识别。 // 为了公平对比,我们使用一个更简单的数据。 const simpleData = generateTestData(10000, 0.3).map(({id, value}) => ({id, value})); // 只保留id和value console.time('uniqueByJSON'); const result2 = uniqueByJSON(simpleData); console.timeEnd('uniqueByJSON'); console.log(`结果长度:${result2.length}`);

在我的环境中运行一次,结果可能类似:

测试数据量:13000 uniqueByKey-Map: 2.5ms 结果长度:10000 uniqueByKey-Reduce: 150.0ms 结果长度:10000 uniqueByJSON: 15.0ms (在简单数据上) 结果长度:10000

结果分析:

  • Map方案(~2.5ms)速度最快,时间复杂度 O(n),与数据量成线性关系,即使数据量增大,性能衰减也最平缓。
  • Reduce + findIndex方案(~150ms)慢了两个数量级,这是因为其 O(n²) 的复杂度。当数据量翻倍时,耗时可能增加近4倍。
  • JSON方案(~15ms)在简单数据上表现尚可,但如前所述,它有严格的适用条件,且序列化本身也有开销。

实战选型指南:

  1. 默认选择Map方案:无论是性能、代码清晰度还是安全性,都是最佳选择。用它处理基于唯一标识符的去重。
  2. 永远避免Reduce + findIndex(全量查找)方案:除非你能绝对保证数组长度永远很小(比如小于50),否则不要使用。
  3. 谨慎使用JSON方案:仅用于临时性的、数据格式极其简单的场景,并且要充分了解其缺陷。不要将其作为默认方案。
  4. 复杂逻辑定制化:如果去重逻辑异常复杂(无法用单一键表示),优先考虑在数据源头进行处理,或者编写专门的、非通用的函数来解决。牺牲一定的通用性来换取可读性和性能是值得的。

5. 特殊场景与边界情况处理

在实际项目中,数据从来都不是完美的。下面是一些常见的“坑”以及如何处理它们。

5.1 处理可能为空的标识符

我们在方案一的代码中已经初步处理了。这里再强调一下策略:

  • 跳过并记录:如上所示,这是比较安全的做法,避免无效数据影响主要结果。适用于标识符缺失为异常情况的场景。
  • 保留并视为特殊值:如果nullundefined本身就是有意义的标识(虽然不常见),你可以允许它们作为Map的键。但要注意,Map可以区分nullundefined和不存在,而普通对象{}做不到。
  • 抛出错误:如果标识符是必填的,缺失属于数据错误,应该尽早抛出异常,让调用者处理。

5.2 需要保留最后一次出现的对象

业务需求有时是“保留最新的那条记录”。这时,方案一稍作修改即可。

function uniqueByKeyKeepLast(arr, key) { const map = new Map(); // 第一遍遍历,用Map记录每个键最后一次出现的对象 for (const item of arr) { const identifier = typeof key === 'function' ? key(item) : item[key]; if (identifier != null) { map.set(identifier, item); // 始终用最新的对象覆盖 } } // 第二遍遍历,按原始顺序(或标识符顺序)输出,但每个键只取最后一次的值 // 注意:如果要严格保持原数组中“最后一次出现”的相对顺序,需要更复杂的逻辑。 // 简单的方法是直接返回Map的值,但顺序是Map的插入顺序(即键第一次出现的顺序)。 // 如果顺序不重要: // return Array.from(map.values()); // 如果需要按照键的最后一次出现在原数组中的顺序: const result = []; const seenKey = new Set(); // 倒序遍历原数组,这样先遇到的是最后一次出现 for (let i = arr.length - 1; i >= 0; i--) { const item = arr[i]; const identifier = typeof key === 'function' ? key(item) : item[key]; if (identifier != null && !seenKey.has(identifier)) { seenKey.add(identifier); // 因为我们是倒序插入,所以需要插入到结果数组的头部,或者最后反转数组 result.unshift(item); // unshift在数组头部插入,但大数据量下性能差 } } // 或者用正序遍历,但用Map存储索引,最后排序,逻辑更复杂。 // 一个平衡性能和逻辑清晰的做法是:用Map存储对象,再用一个数组记录顺序。 const orderMap = new Map(); const orderArr = []; for (const item of arr) { const identifier = typeof key === 'function' ? key(item) : item[key]; if (identifier != null) { orderMap.set(identifier, item); // 记录顺序,如果重复,更新索引?不,我们需要最后一次的顺序。 // 更简单:遍历完成后,再逆序处理。 } } // ... 代码会变得冗长。根据具体性能要求和数据规模选择实现。 // 对于大多数情况,如果顺序不是严格必须,`Array.from(map.values())` 是可接受的。 return Array.from(map.values()); }

可以看到,保留末次的逻辑比保留首次要复杂,尤其是对顺序有要求时。在需求评审时,尽量明确“保留首次”,这更符合直觉和大多数场景。

5.3 超大数组的性能与内存考虑

当数组长度达到十万甚至百万级别时,即使是 O(n) 的算法也需要考虑优化。

  • 使用Map而非{}:如前所述,Map在大量键值对时性能更好。
  • 避免在循环中创建临时对象:比如key是函数且返回新对象,这会导致大量小对象被创建和垃圾回收。
  • 流式处理:如果数据来自文件或网络流,可以考虑边读取边去重,而不是全部加载到内存中再处理。这需要数据源支持迭代。
  • 使用更高效的数据结构:在极端性能要求下,如果键是数字或特定范围的字符串,可以考虑使用ArrayTypedArray作为哈希表,但这牺牲了通用性。

5.4 嵌套对象与循环引用

如果你的对象非常深,且基于嵌套属性去重,keyGetter函数需要能安全地访问深层次属性。可以使用lodash_.get或自己写一个安全访问函数。

function getSafe(obj, path, defaultValue) { return path.split('.').reduce((acc, key) => (acc && acc[key] !== undefined) ? acc[key] : defaultValue, obj); } const data = [{ user: { profile: { id: 123 } } }, { user: { profile: { id: 456 } } }]; const key = (item) => getSafe(item, 'user.profile.id', null); const uniqueData = uniqueByKey(data, key);

对于循环引用,JSON.stringify会直接报错。如果去重逻辑涉及序列化,必须确保数据中没有循环引用,或者使用可以处理循环引用的序列化库(如flatted)。

6. 在现代JS项目中的集成与实践

掌握了核心方法,我们来看看如何将它优雅地集成到你的项目中。

6.1 封装为工具函数或类方法

在你的项目工具库(例如src/utils/array.js)中,导出稳定的去重函数。

// utils/array.js export const uniqueBy = (arr, key) => { // ... 实现方案一,包含健壮的错误处理 }; export const uniqueByKeepLast = (arr, key) => { // ... 实现保留末次的版本 }; // 或者提供一个配置更全的函数 export const unique = (arr, { key, comparator, keep = 'first' } = {}) => { // 根据参数选择不同的内部实现 };

6.2 与 Lodash 或 Ramda 等工具库对比

lodash这样的库提供了_.uniqBy_.uniqWith函数。

  • _.uniqBy(array, [iteratee=_.identity]):类似于我们的uniqueByKeyiteratee可以是属性名字符串或函数。
  • _.uniqWith(array, [comparator]):使用自定义比较函数,但注意它内部可能也是 O(n²) 的复杂度,用于小型数组或特殊比较。

使用建议:

  • 如果你的项目已经引入了lodash,并且其体积不是问题,直接使用_.uniqBy是很好的选择,它经过充分测试,处理了各种边界情况。
  • 如果你追求极致的包体积,或者想避免引入大型工具库,那么自己实现一个轻量级的uniqueByKey是更优解。我们的实现通常只有十几行代码,功能完全够用。

6.3 在Vue/React状态管理中的应用

在前端框架中,去重操作经常发生在处理状态时。

Vue (Pinia) 示例:

// stores/userStore.js import { defineStore } from 'pinia'; import { uniqueBy } from '@/utils/array'; export const useUserStore = defineStore('user', { state: () => ({ userList: [], }), actions: { // 从API合并用户列表,并去重 mergeUsers(newUsers) { const merged = [...this.userList, ...newUsers]; this.userList = uniqueBy(merged, 'id'); }, // 或者作为一个getter }, getters: { // 获取去重后的用户列表(计算属性) uniqueUsers: (state) => uniqueBy(state.userList, 'id'), }, });

React (Redux Toolkit) 示例:

// features/users/usersSlice.js import { createSlice } from '@reduxjs/toolkit'; import { uniqueBy } from '../../utils/array'; const usersSlice = createSlice({ name: 'users', initialState: { list: [] }, reducers: { usersReceived(state, action) { // 假设action.payload是新获取的用户数组 const merged = [...state.list, ...action.payload]; state.list = uniqueBy(merged, 'id'); }, }, }); // 在组件中 import { useSelector } from 'react-redux'; const uniqueUserList = useSelector(state => uniqueBy(state.users.list, 'id'));

关键点:在状态管理中,去重应该作为一个纯函数被调用,确保相同的输入永远得到相同的输出,不产生副作用。这符合Redux和Vuex/Pinia的设计原则。

6.4 与异步数据流结合(RxJS)

在处理流数据时,去重也是一个常见操作。

import { from, of } from 'rxjs'; import { mergeMap, toArray, reduce } from 'rxjs/operators'; // 假设有一个发出用户对象数组的Observable const userObservable = from([ [{id: 1, name: 'A'}, {id: 2, name: 'B'}], [{id: 2, name: 'B'}, {id: 3, name: 'C'}], // 包含重复的id:2 [{id: 1, name: 'A'}, {id: 4, name: 'D'}], // 包含重复的id:1 ]); // 我们需要合并所有发出的数组并去重 userObservable.pipe( // 将每个发出的数组合并成一个数组 reduce((acc, currentArray) => acc.concat(currentArray), []), // 对最终合并的数组进行去重 mergeMap(combinedArray => of(uniqueBy(combinedArray, 'id'))) ).subscribe(uniqueUsers => { console.log('去重后的用户列表:', uniqueUsers); // 输出: [{id:1,name:'A'}, {id:2,name:'B'}, {id:3,name:'C'}, {id:4,name:'D'}] });

在RxJS中,还有distinctdistinctUntilChanged等操作符用于流中单个值的去重,但针对对象数组的合并去重,通常还是需要在最终阶段使用我们实现的工具函数。

7. 总结与个人心得

对象数组去重,这个看似简单的问题,深入下去却涉及数据结构选择、算法复杂度、API设计、边界处理以及与现代开发流的结合。经过上面一番梳理,我的核心建议可以总结为三点:

第一,明确“相等”语义是前提。在动手写代码之前,一定要和产品经理或后端同事确认清楚:到底什么叫“重复”?是基于ID,还是基于几个字段的组合,抑或是所有字段完全一致?这个定义直接决定了实现方案。

第二,Map+ 唯一键是王道。对于99%的业务场景,基于Map和对象唯一标识符的方案是最佳选择。它性能好(O(n)),代码清晰,易于理解和维护。自己封装一个uniqueByKey函数,处理好null/undefined键的边界情况,就能覆盖绝大部分需求。

第三,警惕性能陷阱和语法糖诱惑。JSON.stringify虽然写起来短,但坑太多,不要用它处理重要数据。array.reduce配合array.find看起来很“函数式”,但 O(n²) 的复杂度在数据量稍大时就会成为性能瓶颈。在追求代码简洁的同时,一定要心里有性能这根弦。

最后分享一个我自己的习惯:在工具函数中,永远加上参数类型校验和简单的错误提示。就像我们在uniqueByKey里做的那样,检查输入是否为数组。这行代码可能一辈子都不会触发,但一旦触发(比如有人不小心传了个null进来),它能为你节省大量的调试时间。好的工具函数不仅是能干活,还要能“友好地”告诉调用者哪里用错了。

http://www.jsqmd.com/news/1380096/

相关文章:

  • 2026年保定矿山托辊厂家挑选攻略:中茂机械等优质企业信息梳理 - 拜了拜了
  • 佛山有没有靠谱香港移民中介?内行人教你3步筛出正规机构 - 商讯
  • 基于WorkBuddy与Obsidian的自动化知识管理流水线搭建实践
  • 从想法到可运行系统仅需30分钟?XDevelop让我信了
  • 亚马逊A10算法深度解析:从交易引擎到生态体系的运营策略革命
  • 常州网站建设推广公司哪家好?别盲目比价!2026多家建站机构实测测评推荐 - 商业新知
  • 苏州拎包入驻办公室选择与入驻全流程指南:精装全配空间、费用明细与快速入驻 - 优企甄选
  • 如何在线解压 ZIP、RAR 等格式文件?无需下载软件直接在线使用
  • AI模型灰度回归与分阶段发布:开发者应对策略与工程实践
  • AI模型竞技场:基于世界杯场景的模型评测与工程实践
  • 保姆级教程:2026免费工具手把手教你音频转MP3,无需任何参数设置,点几下鼠标即可批量导出320k高品质 - 今日咨询
  • 2026年杭州音乐艺考集训机构**:3家实力派精选盘点 - 生活动态圈
  • AI 流程精细化管理与 MES、ERP 有什么本质区别?从功能、颗粒度、价值三维对比
  • Windows NTFS链接全解析:符号链接、硬链接与交接点实战指南
  • 极简产品不是藏按钮:用真实任务决定渐进式暴露
  • 高校体育场馆微信小程序开发实践与优化
  • Web安全实战:用户输入处理中的转义、验证与清理机制详解
  • 2026年优选西安专业的楼盘地产服务商 - 装修教育财税推荐2026
  • 深度图与视差图伪彩色可视化:原理、实现与工程实践
  • 2026年浙江碳钢镀镍厂家哪家靠谱?放心厂家甄选 - 商业新知
  • eNSP交换机Telnet远程登录完整实验教程(真机Cloud桥接方案)
  • 游戏剧情与过场怎么测:分支条件、跳过、恢复、字幕与资源版本
  • 一体化智能制造方案,生产自动化与管理 AI 能分开采购实施吗?
  • PTA基础编程题目集 7-30字符串的冒泡排序(C++语言实现)
  • Java接入大模型的三层路径从API调用到智能体编排
  • Java 开源商城系统怎么选?先回答这 4 个问题,再去看代码
  • 中小企业必看:上海本地搜索引擎优化公司怎么选?2026甄选测评推荐 - 商业新知
  • 【8.22截稿提醒】CPNN 2026计算机感知与神经网络国际会议|深圳线下 EI/Scopus双检索
  • Android开发进阶:贝塞尔曲线原理、绘制与动画实战
  • 2026年荃净环保和希望树除醛效果哪家好?家用除醛选购实用指南 - 亚东说