JS数组去重最佳实践:Set、filter与reduce实现方案

作者:听澜时间:2026-11-04 10:57:15浏览:174

深入讲解 JavaScript 数组去重的三大核心实现方案:ES6 Set、filter 与 reduce,解析 SameValueZero 判定机制、O(N) 与 O(N²) 性能差异及对象数组按属性去重的工程实践。

JS数组去重最佳实践:Set、filter与reduce实现方案
导读图:JavaScript 数组去重核心技术路线与选型基准

在 JavaScript 日常开发与数据清洗过程中,数组去重是一项高频出现的任务。解决这一问题的核心逻辑取决于数据形态(基础类型值还是复杂对象)以及运行环境的性能边界。现代 JavaScript 环境中,基础类型去重的标准解法是 ES6 的 Set,它能提供极简的语法与 \(O(N)\) 的线性执行效率;需要兼顾旧浏览器兼容或定制简单过滤规则时,可以使用 filter 结合 indexOf;而在处理具备唯一标识(如 id)的对象数组或复杂复合键场景时,使用 reduce 搭配 Map 则是稳定性与性能俱佳的工程首选。本文将依次剖析这三类方案的具体实现、底层比对算法、执行效率及边界规避要点。

一、ES6 Set 方案:基础类型数组的极简首选

对于只包含数字、字符串、布尔值等基本类型(Primitives)的数组,利用 ES6 规范引入的 Set 集合是目前最标准、代码量最少的去重方式。Set 本身是一种键值集合,其天生要求成员值必须唯一。

1. 标准实现代码

// 方式一:利用 ES6 展开运算符(Spread Operator)
const dedupeWithSpread = (arr) => [...new Set(arr)];

// 方式二:利用 Array.from 方法转换
const dedupeWithArrayFrom = (arr) => Array.from(new Set(arr));

// 运行示例
const primitiveNumbers = [1, 2, 2, 3, 4, 3, 5];
console.log(dedupeWithSpread(primitiveNumbers));
// 示例输出: [1, 2, 3, 4, 5]

const stringsList = ['apple', 'orange', 'apple', 'banana'];
console.log(dedupeWithArrayFrom(stringsList));
// 示例输出: ['apple', 'orange', 'banana']

在语法层面,[...new Set(arr)] 与 Array.from(new Set(arr)) 在最终结果上完全等价。二者均在初始化 new Set() 时遍历原数组构建唯一哈希集,随后将具备可迭代协议(Iterable)的 Set 结构重构为新的原生数组。

2. 底层机制与 SameValueZero 判定规则

许多开发者认为 Set 去重使用的就是严格全等运算符(===),这是一种看似合理但在关键细节上不准确的认知。ECMAScript 规范明确规定,Set 内部使用的是 SameValueZero 算法,其与 === 存在显著差别:

  • 对 NaN 的处理:在标准全等比较中,NaN === NaN 返回 false。但 SameValueZero 认为 NaN 与 NaN 是完全相等的值。因此数组中的多个 NaN 传入 Set 后会被正确合并,只保留一个。
  • 对零的处理:+0 与 -0 在 SameValueZero 判定中同样被视为相等值,Set 会执行合并。
  • 对引用对象的处理:Set 仅比较对象的堆内存引用地址。哪怕两个对象包含完全相同的属性键值(例如 { id: 1 } 与 { id: 1 }),只要它们是分别声明的独立字面量,内存指针不同,Set 就不会将它们视为重复项。
JS数组去重最佳实践:Set、filter与reduce实现方案
图1:Set 集合基于 SameValueZero 规则的处理流
认知辨析:Set 能否解决所有数组的去重?

不能。“用 Set 一行代码搞定所有去重”仅在纯基础类型数组中成立。如果业务数据是后端返回的 JSON 对象数组(如 [{id: 1, name: 'Alice'}, {id: 1, name: 'Alice'}]),直接使用 [...new Set(arr)] 是完全无效的。此时必须转为基于对象唯一键或哈希特征的去重方案。

二、filter + indexOf 方案:渐进式过滤与边界陷阱

在缺乏 ES6 环境支持的早年工程中,Array.prototype.filter 配合 indexOf 是最经典的原型链去重技巧。其核心思想是:仅保留第一次出现的元素。如果一个元素当前所在的索引正好等于该值在整段数组中首次出现的下标,说明它是首次登场;反之则是重复出现的冗余项。

1. 基础实现与对象属性变体

// 基础数值/字符串数组去重
const dedupeWithFilter = (arr) => {
  return arr.filter((item, index) => arr.indexOf(item) === index);
};

// 针对对象数组指定 key 的变体(借助 findIndex)
const dedupeObjectByFindIndex = (arr, key) => {
  return arr.filter((item, index) => {
    return arr.findIndex(obj => obj[key] === item[key]) === index;
  });
};

// 运行示例
const mixedArr = [10, 20, 10, 30, 20];
console.log(dedupeWithFilter(mixedArr));
// 示例输出: [10, 20, 30]

2. 执行过程追踪与双重缺陷

为了直观展现其运转细节,假设输入数组为 [10, 25, 10, NaN, 30],filter 会对每个下标执行检测:

JS数组去重最佳实践:Set、filter与reduce实现方案
图2:filter 与 indexOf 的逐项比对与 NaN 异常剔除

通过上面的执行追踪,可以清晰看到此方案存在的两个核心硬伤:

  1. 时间复杂度为 \(O(N^2)\):filter 自身会对数组循环 \(N\) 次,而每次回调执行 arr.indexOf(item) 或 arr.findIndex() 时,又会从头到尾进行一次线性扫描。当数据量从 100 条上升至 100,000 条时,比较次数将呈平方级飙升,极易导致主线程阻塞卡顿。
  2. 丢失 NaN 的隐蔽缺陷:indexOf 内部采用的是标准全等 ===。由于在 JavaScript 中 NaN === NaN 永远为 false,导致 arr.indexOf(NaN) 的计算结果恒为 -1。无论当前 index 是多少,index === -1 永远不成立,最终导致原数组中的所有 NaN 均被错误丢弃。

三、reduce + Map 方案:对象数组按属性去重的最佳工程实践

在真实的业务场景(如管理后台表格列表、电商购物车、权限标签集合)中,最常见的去重需求是:给定一个对象数组,根据某个特定属性(如 id、code 或 skuId)剔除重复项。此时,Array.prototype.reduce 配合 Map 提供了兼具 \(O(N)\) 时间复杂度与可读性的黄金组合。

1. 标准实现代码

/**
 * 根据指定对象属性对数组去重
 * @param {Array<Object>} list 待去重对象数组
 * @param {string} prop 唯一标识属性名
 * @returns {Array<Object>} 去重后的对象数组
 */
function dedupeByProperty(list, prop) {
  const seen = new Map();
  return list.reduce((acc, currentItem) => {
    const key = currentItem[prop];
    // 检查缓存中是否存在当前标识
    if (!seen.has(key)) {
      seen.set(key, true);
      acc.push(currentItem);
    }
    return acc;
  }, []);
}

// 运行示例
const userList = [
  { id: 101, name: 'Alice', role: 'admin' },
  { id: 102, name: 'Bob',   role: 'editor' },
  { id: 101, name: 'Alice_Duplicate', role: 'guest' },
  { id: 103, name: 'Carol', role: 'viewer' }
];

const uniqueUsers = dedupeByProperty(userList, 'id');
console.log(uniqueUsers);
// 示例输出:
// [
//   { id: 101, name: 'Alice', role: 'admin' },
//   { id: 102, name: 'Bob', role: 'editor' },
//   { id: 103, name: 'Carol', role: 'viewer' }
// ]

2. 为什么选用 Map 而不是普通对象字面量(Object)?

部分历史代码会使用普通对象 const seen = {} 来充当缓存表,但使用 Map 具有决定性的工程优势:

  • 防止键名隐式字符串化:JavaScript 普通对象的属性键只能是字符串或 Symbol。如果以数值 1 和字符串 "1" 作为标识,普通对象会统一将其强制转换为 "1",从而导致类型混淆误杀;而 Map 可以精确区分各种类型的键。
  • 支持复杂键与无原型干扰:普通对象会继承 Object.prototype 上的属性(如 toString、valueOf)。若属性值恰好是 "toString",seen[key] 会得到函数真值;Map 不存在此类原型污染风险。
  • 单次遍历性能高:reduce 只对输入流执行一次遍历(\(N\) 步),而 seen.has(key) 和 seen.set(key, true) 在 Map 内部均摊时间复杂度为 \(O(1)\),总体复杂度稳定控制在严格的 \(O(N)\)。
JS数组去重最佳实践:Set、filter与reduce实现方案
图3:reduce 配合 Map 属性去重架构
语法糖补充:Map.values() 的单行解法

若需求仅是提取以某个 key 为唯一性的最后出现值或首次出现值,还可以直接将对象流映射为键值对注入 Map,再展开 values():
const dedupeByMapDirect = (arr, key) => [...new Map(arr.map(item => [item[key], item])).values()];
注意:此变体在遇到重复键时,会以后出现的元素覆盖先出现的元素;而上面讲解的 reduce 版本则保留首次出现的元素。开发时可按业务诉求选择。

四、方案横向对比与选型决策指南

为了帮助读者在不同开发场景中迅速做出技术选型,下表将三种实现方案的核心指标进行了横向汇总:

方案分类 实现方式 时间复杂度 空间复杂度 对象属性支持 NaN 与特殊值支持 典型推荐场景
ES6 Set [...new Set(arr)] \(O(N)\) \(O(N)\) 否(仅比内存地址) 原生支持(合并保留1个) 基础类型、数字、字符串无脑首选
filter + indexOf arr.filter(...) \(O(N^2)\) \(O(N)\) 需换 findIndex 不支持(全部被滤除) 兼容旧浏览器或极小规模数据(<50条)
reduce + Map reduce + Map 缓存 \(O(N)\) \(O(N)\) 原生完美支持 支持(Map键值同规则) 中大型业务数据、对象数组、表格去重
JS数组去重最佳实践:Set、filter与reduce实现方案
图4:JS 数组去重场景选型决策树与综合对比

五、工程实践中的易错提醒

在具体编写业务去重代码时,建议注意以下细节:

  • 保持纯函数特性:去重函数应返回全新的数组,绝不能直接修改传入的原数组引用(如在外部原数组上进行 splice 操作),避免引发上游组件不可预期的重渲染与副作用。
  • 注意 undefined 与 null 边界:当处理对象数组时,如果某些对象缺少待比对属性(即 item[prop] === undefined),所有缺少该属性的对象都会共享同一个 undefined 键而被过滤得只剩一个。在进入 reduce 前,应明确业务上是丢弃异常数据,还是为其赋予备用标识(例如 item[prop] ?? Symbol())。
  • 大数据量场景规避嵌套检索:凡是在循环体内调用 indexOf、includes 或 findIndex 的代码,都会在无形中将算法时间拉升至平方级。在面对数千条以上的数据集时,务必优先选择具备 \(O(1)\) 检索特性的 Set 或 Map 结构。
作者最新文章
相关文章
更多