JavaScript数组去重的几种高效实现方式

作者:巡夜人时间:2026-11-03 11:39:43浏览:298

深入解析JavaScript数组去重的高效实现方案。本文从基础类型的Set集合去重,到对象数组的Map键值映射去重,对比不同实现方式在时间复杂度、特殊值处理及业务场景下的核心差异与落地实践。

前端性能与算法基础
JavaScript数组去重的几种高效实现方式
图 0:JavaScript 数组去重从原始冗余项到唯一结果的流转模型
在 JavaScript 开发中,数组去重的核心在于平衡执行效率、代码可读性与数据类型的边界支持。针对原始类型(数值、字符串、布尔值等),ES6 引入的 Set 集合配合展开运算符是当前性能优异且代码简洁的标准做法;而针对业务中常见的对象数组或需要自定义唯一字段的场景,利用 Map 进行单次线性扫描则是兼顾 O(n) 时间复杂度与逻辑灵活性的首选解法。

一、基于 Set 集合的现代标准实现

在现代 JavaScript(ES6+)中,Set 是一种能够存储任意类型唯一值的数据结构。无论是原始值还是对象引用,同一个值在 Set 集合中只会出现一次。借助 Set 的天然去重特性与展开运算符(Spread Operator)或 Array.from(),可以写出极其精简且高效的去重逻辑。

1. 核心代码示例

// 方式 A:展开运算符实现
function uniqueBySet(arr) {
  return [...new Set(arr)];
}

// 方式 B:Array.from 实现
function uniqueByArrayFrom(arr) {
  return Array.from(new Set(arr));
}

// 执行测试
const rawList = [1, '1', 2, 2, 'hello', 'hello', NaN, NaN, true, true];
const result = uniqueBySet(rawList);
示例输出: console.log(result);
// 输出: [1, '1', 2, 'hello', NaN, true]

2. 运行机制与 SameValueZero 算法

为什么 Set 能够高效且准确地识别重复项?其内部基于 ECMAScript 规范定义的 SameValueZero 算法进行相等性检查。

在传统的 JavaScript 比较中,严格相等操作符 === 存在一个经典缺陷:NaN === NaN 的结果为 false。如果使用传统的遍历比对,往往需要额外编写特殊的条件来处理 NaN。而 Set 的 SameValueZero 规则明确将所有的 NaN 视为相等值,因此能够自动合并不重复存放。此外,+0 与 -0 在该规则下也被判定为同一数值。

JavaScript数组去重的几种高效实现方式
图 1:Set 集合底层 SameValueZero 判定机制与数据类型表现
注意事项:Set 仅依据引用指针来比较对象。两个字面量内容完全相同的对象(例如 { id: 1 } 与 { id: 1 })在内存堆中分别拥有独立的引用地址,因此 Set 不会将它们识别为重复项。若需要对对象数组进行去重,需要采用专门的哈希键映射方式。

二、基于 Map 的对象数组高效去重方案

在前端与后端交互时,最常见的数据结构往往是由对象构成的列表,例如用户列表、商品目录或表格数据。业务场景通常要求:依据对象的某个唯一键(如 id 或 code)进行去重,并保留首次出现的数据项。

针对这一需求,利用 ES6 的 Map 作为查找表,能够在单次遍历(O(n) 时间复杂度)内完成过滤,避免出现深层嵌套循环。

1. 核心代码示例

/**
 * 依据指定属性对对象数组去重(保留首次出现的项)
 * @param {Array<Object>} array 待去重数组
 * @param {string} key 唯一标识属性名
 * @returns {Array<Object>}
 */
function uniqueByKey(array, key) {
  const seen = new Map();
  return array.filter(item => {
    const val = item[key];
    if (seen.has(val)) {
      return false;
    }
    seen.set(val, true);
    return true;
  });
}

// 业务数据测试
const userList = [
  { id: 101, name: 'Alice', department: 'R&D' },
  { id: 102, name: 'Bob', department: 'Design' },
  { id: 101, name: 'Alice (Duplicate)', department: 'R&D' },
  { id: 103, name: 'Charlie', department: 'Product' }
];

const cleanUsers = uniqueByKey(userList, 'id');
示例输出: console.log(cleanUsers);
// 输出: [
// { id: 101, name: 'Alice', department: 'R&D' },
// { id: 102, name: 'Bob', department: 'Design' },
// { id: 103, name: 'Charlie', department: 'Product' }
// ]
JavaScript数组去重的几种高效实现方式
图 2:基于 Map 的对象数组指定键去重步骤与状态流转

2. 操作步骤分解

  1. 初始化记录容器:在内存中创建独立的 Map 实例 seen。相较于普通对象({}),Map 支持任意类型的键,且在频繁添加与查询时具备更为稳定的常数时间(O(1))寻址性能。
  2. 提取特征字段:遍历输入数组中的每一个元素,提取目标判定属性的值(如 item.id)。
  3. 哈希快速判定:通过 seen.has(val) 校验当前键是否已被记录:
    • 如果已存在,表明当前元素属于重复记录,回调返回 false 予以过滤;
    • 如果不存在,执行 seen.set(val, true) 将特征键写入哈希表,并返回 true 将当前元素保留到新数组中。
  4. 产出过滤结果:Array.prototype.filter 返回最终过滤好的新数组,原数组保持不变。

三、关键差异剖析:哈希单趟查找与传统嵌套遍历

在早期的前端开发中,经常能看到利用 Array.prototype.filter 搭配 Array.prototype.indexOf 进行去重的写法:

// 传统去重写法(不推荐用于大规模数据)
function uniqueTraditional(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

虽然这种写法代码较短,但其与现代基于 Set / Map 的方案存在根本性的性能差异。理解这种差异的起因,有助于在实际工程中做出合理的架构决策。

JavaScript数组去重的几种高效实现方式
图 3:一致基准下的执行轨迹对比:嵌套比对 vs 哈希单趟查找

为什么复杂度差异至关重要?

当我们在相同的软硬件环境下,输入一个包含大量项的数组时,两种方案的核心开销呈完全不同的数学级数演进:

  • filter + indexOf 的嵌套机制(O(n²)): filter 会对数组进行 n 次遍历;而在每次回调执行 arr.indexOf(item) 时,底层引擎必须从数组的索引 0 开始逐个向后比对元素,直到找到匹配项。对于包含 n 个唯一项的极端情况,总比对次数为 n × (n - 1) / 2。当数据量从 1,000 条增长到 50,000 条时,比对操作将从数十万次剧增至 12.5 亿次,直接造成主线程严重掉帧卡顿。
  • Set / Map 的哈希散列机制(O(n)): 哈希容器在底层通过键的哈希函数将值映射到特定的内存桶中,单次查找与插入的平摊时间复杂度接近 O(1)。因此,对含有 n 个元素的数组,Set 仅需进行 n 次查找和插入,整体计算耗时与数组长度呈现平稳的线性比例关系。
  • 特殊值容错能力: 由于 indexOf 底层采用严格相等 ===,无法匹配 NaN(arr.indexOf(NaN) 恒为 -1)。因此,传统写法在遇到 [NaN, NaN] 时,会将所有 NaN 元素全部丢弃,产生逻辑错误;而 Set 则能正确保留一份。

四、复杂全等与深度去重扩展

如果数组中包含嵌套对象,且没有统一的 id 主键,需要依据对象的“内部全部属性及值”进行深度全等去重,此时该如何处理?

在业务场景中,最直接实用的策略是借助 稳定键值序列化(Deterministic Serialization) 配合 Set 进行判定:

/**
 * 针对复杂对象的全字段去重(纯 JSON 结构)
 */
function uniqueDeepObjects(arr) {
  const seen = new Set();
  return arr.filter(item => {
    // 对键名排序后序列化,避免键顺序不一致导致误判
    const serialized = JSON.stringify(item, Object.keys(item).sort());
    if (seen.has(serialized)) {
      return false;
    }
    seen.add(serialized);
    return true;
  });
}

// 测试样例
const complexList = [
  { x: 1, y: 2 },
  { y: 2, x: 1 }, // 键顺序颠倒但内容实质全等
  { x: 1, y: 3 }
];

const uniqueList = uniqueDeepObjects(complexList);
示例输出: console.log(uniqueList);
// 输出: [ { x: 1, y: 2 }, { x: 1, y: 3 } ]
边界权衡:序列化方案适用于普通的纯数据对象(POJO)。如果对象中包含函数、正则表达式、循环引用或 Date 对象,JSON.stringify 会丢失原型或类型信息。在处理复杂引用图形时,应结合业务明确去重的依据维度,而非无节制地进行深度哈希运算。

五、去重方案技术选型与总结

在实际项目开发中,并不存在某种放之四海而皆准的“绝对最佳”方案,而是应当根据具体处理的数据类型、数据体量以及唯一性判定规则来选定合适的算法。

JavaScript数组去重的几种高效实现方式
图 4:JavaScript 数组去重场景决策树与方案对比全景
实现方案 适用数据类型 时间复杂度 空间复杂度 核心优势与推荐场景
[...new Set(arr)] 原始基础类型 O(n) O(n) 极度简洁、性能极高,支持 NaN,现代开发日常推荐
Map 键值映射 对象数组(指定主键) O(n) O(n) 灵活支持单字段或复合字段去重,保留首项语义清晰
JSON 序列化 + Set 无主键的内容全等对象 O(n × k) O(n) 解决纯数据对象的深度去重需求,代码成本低
filter + indexOf 仅极小规模基础数据 O(n²) O(1) 兼容旧环境但性能低下且不支持 NaN,现代工程应避免

综上所述,对于基础数据类型的去重,直接采用 [...new Set(arr)] 是兼顾规范、语义与性能的标准方案;针对业务系统中最频繁遇到的对象列表清洗,编写基于 Map 的单趟线性过滤器则是保障前端页面平稳运行与良好响应的最佳实践。

作者最新文章

相关标签:

相关文章
更多