JS数组去重时NaN和undefined怎么处理

作者:异步漫步

时间:2026-11-03

浏览:415

深入讲解JavaScript数组去重中NaN与undefined的正确处理机制。剖析严格相等、SameValueZero以及对象哈希在处理特殊类型时的表现差异,提供现代化与向下兼容的稳妥方案。

文章目录
在 JavaScript 开发中,数组去重是一项高频基础操作。但当数组中混入 NaN、undefined 或稀疏数组空位(empty slots)时,不少传统写法都会出现“将所有 NaN 漏滤丢弃”或“让重复的 NaN 全部堆积”的异常情况。现代 JavaScript (ES6+) 中处理这两个特殊值的核心结论非常明确:直接优先使用 [...new Set(arr)] 或 Array.from(new Set(arr))。因为 Set 原生依赖 ECMAScript 规范的 SameValueZero 比较算法,能够把多个 NaN 视作相等并仅保留单个实例,同时也能对多个 undefined 稳定实现单值保留。本文将系统剖析常见去重方案对特殊值的处理差异,并提供兼顾现代标准与旧环境兼容的落地实现。
JS数组去重时NaN和undefined怎么处理
概览:JavaScript 数组中特殊值(NaN 与 undefined)去重机制全貌

一、为什么 NaN 与 undefined 容易在去重中出错

日常业务中数组包含的基本是数字、字符串或布尔值,这些类型在比较时行为符合直觉。但在处理 NaN 与 undefined 时,去重逻辑往往因底层判定算法的差异而失效:

  • NaN 的非自反性(Non-reflexive):在 IEEE 754 浮点数规范与 ECMAScript 严格全等(===)规范中,NaN === NaN 永远为 false。这是语言设计上的特性,却成了传统去重逻辑的软肋。任何依赖 === 或 indexOf 进行查重的方案,都无法通过自身与自身的比对判定相等。
  • indexOf 的底层限制:Array.prototype.indexOf 严格遵循全等比对。由于 NaN === NaN 恒为 false,执行 arr.indexOf(NaN) 时不论数组中是否存在 NaN,其返回值始终为 -1。
  • undefined 的全等与空位差异:undefined === undefined 恒为 true,因此单纯的比对并不会丢失 undefined。但如果数组是由 new Array(3) 等方式生成的稀疏数组,空位(empty slot)在不同高阶函数(如 filter、forEach、map)与展开运算符 [...arr] 中的遍历行为完全不同。
  • 对象键(Object Key)的类型退化:若使用普通 JavaScript 对象(如 obj[item] = true)作为哈希表去重,对象的键名会被隐式转换为字符串。数字 NaN 会退化为键名 "NaN",undefined 会退化为键名 "undefined"。如果原数组同时存在字符串 "NaN" 和数字 NaN,会导致跨类型合并覆盖。
JS数组去重时NaN和undefined怎么处理
图 1:四种底层值判定算法对特殊类型的行为对比

二、经典去重方案实测与问题剖析

为了直观观察不同去重方案的实际表现,我们定义一个包含了重复数字、字符串、多个 NaN 以及多个 undefined 的标准测试数组:

// 测试基准数组:包含重复项、NaN 与 undefined
const testArr = [1, NaN, undefined, 'NaN', NaN, 1, undefined];

1. 陷阱方案:filter + indexOf

这是在 ES5 时代极为流行的一种单行去重技巧,原理是利用 indexOf 返回元素在数组中首次出现的索引。如果当前项的索引等于首次出现索引,则说明是首次访问,予以保留:

function dedupeByFilterIndexOf(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

const resultFilter = dedupeByFilterIndexOf(testArr);
示例输出 (resultFilter)
[ 1, undefined, 'NaN' ]
严重缺陷:输出结果中所有的 NaN 均被彻底移除了,一个都没有保留!
原因分析:当遍历到数组第 1 项 NaN 时,执行 arr.indexOf(NaN) 返回 -1,判断条件 -1 === 1 为 false;同理遍历到第 4 项 NaN 时,-1 === 4 依然为 false。所有 NaN 均因找不到合法索引而被当成“无效项”过滤抛弃。

2. 异常方案:双重循环嵌套比对

初学者常用的另一种直觉写法是外层循环遍历元素,内层循环向前查找是否存在重复项:

function dedupeByNestedLoop(arr) {
  const result = [];
  for (let i = 0; i < arr.length; i++) {
    let isDuplicate = false;
    for (let j = 0; j < result.length; j++) {
      if (result[j] === arr[i]) {
        isDuplicate = true;
        break;
      }
    }
    if (!isDuplicate) {
      result.push(arr[i]);
    }
  }
  return result;
}

const resultNested = dedupeByNestedLoop(testArr);
示例输出 (resultNested)
[ 1, NaN, undefined, 'NaN', NaN ]
严重缺陷:与 filter + indexOf 恰好相反,由于 NaN === NaN 恒为 false,内层循环在比对 result[j] === NaN 时永远无法成立,isDuplicate 永远是 false。结果是每一个重复的 NaN 都被当作不同元素压入了结果数组,完全失去了去重能力。
JS数组去重时NaN和undefined怎么处理
图 2:常见去重方法执行过程单步追踪与结果比对

三、实用解决方案与针对性代码实现

针对 NaN 与 undefined 的特性,我们可以根据项目的运行环境采用不同层次的解决方案。

方案一:ES6+ 原生 Set 集合(推荐标准方案)

ECMAScript 2015 引入的 Set 数据结构,其成员的值都是唯一的。规范明确指定 Set 内部的判等依据是 SameValueZero 算法。在该算法下:

  • SameValueZero(NaN, NaN) 判定为 true,保证 NaN 仅会被记录一次;
  • SameValueZero(undefined, undefined) 判定为 true,保证 undefined 仅会被记录一次;
  • SameValueZero(NaN, 'NaN') 判定为 false,严格区分数值与字符串类型。
// 核心推荐写法:扩展运算符配合 Set
function dedupeBySet(arr) {
  return [...new Set(arr)];
}

// 亦可使用 Array.from
function dedupeByArrayFromSet(arr) {
  return Array.from(new Set(arr));
}

const resultSet = dedupeBySet(testArr);
示例输出 (resultSet)
[ 1, NaN, undefined, 'NaN' ]

可以看到,重复的 NaN 被合并为一个,重复的 undefined 也被合并为一个,且保留了字符串 'NaN' 的独立性。时间复杂度为线性的 $O(n)$,执行效率高且代码最为简洁。

方案二:基于 Array.prototype.includes 进行条件累加

如果在某些流式过滤场景下需要配合 reduce 或 forEach 构造结果数组,请务必使用 includes 替代 indexOf。ES2016 引入的 includes 方法同样采用 SameValueZero 比对:

function dedupeByIncludes(arr) {
  return arr.reduce((accumulator, currentItem) => {
    if (!accumulator.includes(currentItem)) {
      accumulator.push(currentItem);
    }
    return accumulator;
  }, []);
}

const resultIncludes = dedupeByIncludes(testArr);
示例输出 (resultIncludes)
[ 1, NaN, undefined, 'NaN' ]
提示:虽然 includes 能正确识别 NaN,但由于在 reduce 中每次调用 includes 都会线性扫描累加器,整体时间复杂度为 $O(n^2)$。当数组长度超过数千项时,性能会明显落后于 Set。

方案三:ES5 环境下安全去重函数(向下兼容方案)

如果需要在无法引入 ES6 Polyfill 的旧版浏览器或运行环境中执行去重,必须手动封装一个符合 SameValueZero 规则的比对辅助函数:

// 安全的 SameValueZero 比对器(兼容 ES5)
function isSameValueZero(x, y) {
  if (x === y) {
    return true;
  }
  // 处理两个比较值均为 NaN 的场景
  // 注意:必须使用严格的 NaN 校验,避免全局 isNaN 的隐式类型转换
  return (typeof x === 'number' && isNaN(x)) && (typeof y === 'number' && isNaN(y));
}

function dedupeSafeES5(arr) {
  var result = [];
  for (var i = 0; i < arr.length; i++) {
    var item = arr[i];
    var isDuplicate = false;
    for (var j = 0; j < result.length; j++) {
      if (isSameValueZero(result[j], item)) {
        isDuplicate = true;
        break;
      }
    }
    if (!isDuplicate) {
      result.push(item);
    }
  }
  return result;
}

var resultES5 = dedupeSafeES5(testArr);
示例输出 (resultES5)
[ 1, NaN, undefined, 'NaN' ]
JS数组去重时NaN和undefined怎么处理
图 3:包含特殊值的数组去重方案选型决策树

四、边界场景与常见陷阱排查

在工程实践中,除了基础的数据类型比较外,还需要注意以下三项容易导致生产故障的边界场景:

1. 警惕全局 isNaN 与 Number.isNaN 的差异

在编写自定义兼容逻辑时,切勿直接使用全局函数 isNaN():

// 错误用法演示:全局 isNaN 会触发隐式转换
console.log(isNaN('Hello'));       // 输出: true (字符串被强制转为 NaN)
console.log(isNaN(undefined));     // 输出: true (undefined 被转为 NaN)

// 正确用法:ES6 Number.isNaN 或类型前置判断
console.log(Number.isNaN('Hello'));   // 输出: false
console.log(Number.isNaN(undefined)); // 输出: false
console.log(Number.isNaN(NaN));       // 输出: true

如果直接使用 isNaN(item) 作为分支条件,那么传入字符串 'Hello' 或 undefined 时都会被错误归类为 NaN,造成严重的逻辑混乱。

2. 稀疏数组(空位 Holes)与显式 undefined 的去重差异

稀疏数组是指含有未初始化索引位置的数组。空位与显式赋值的 undefined 在去重时表现并不完全相同:

// 创建包含空位与显式 undefined 的数组
const sparseArr = [1, , undefined, 2];
console.log(sparseArr.length); // 4
console.log(0 in sparseArr);   // true (索引 0 存在值 1)
console.log(1 in sparseArr);   // false (索引 1 是空位,没有属性)
console.log(2 in sparseArr);   // true (索引 2 明确定义为 undefined)

// 行为 A:使用 Set 去重
const fromSet = [...new Set(sparseArr)];
// 结果: [ 1, undefined, 2 ] —— 展开运算符会将空位自动转为 undefined,合并为一个

// 行为 B:使用 filter 去重
const fromFilter = sparseArr.filter(() => true);
// 结果: [ 1, undefined, 2 ] —— filter 会直接跳过空位,但保留显式定义的 undefined

在绝大多数业务场景下,展开运算符配合 Set 将空位收拢为单个 undefined 是符合预期的安全处理方式。

3. 普通对象 Hash 键名冲突

部分开发者为了追求 $O(n)$ 复杂度,会使用 obj[item] = true 去重:

// 危险模式:对象属性名自动字符串化
const badHashDedupe = (arr) => {
  const seen = {};
  return arr.filter(item => seen.hasOwnProperty(item) ? false : (seen[item] = true));
};

console.log(badHashDedupe([NaN, 'NaN']));             // [ NaN ] (字符串 'NaN' 被吃掉)
console.log(badHashDedupe([undefined, 'undefined'])); // [ undefined ] (字符串 'undefined' 被吃掉)

由于普通对象的键名强制为字符串,导致数字 NaN 与字符串 'NaN' 发生了冲突。若需哈希映射,必须使用 ES6 的 Map(其键名比较遵循 SameValueZero,能准确区分不同的数据类型)。

JS数组去重时NaN和undefined怎么处理
图 4:常用去重方案指标总结与选型指南

五、常见问题速查

疑难问题 根本原因 解决建议
为什么我的数组去重后,所有的 NaN 都不见了? 代码中使用了 arr.filter((item, i) => arr.indexOf(item) === i)。indexOf(NaN) 始终返回 -1,条件判定永远失败。 将去重逻辑直接替换为 [...new Set(arr)]。
数组中有好几个 NaN,为什么去重后全部都在? 使用了基于 === 或 == 的循环遍历查重。由于 NaN === NaN 为 false,查重逻辑误判它们各不相同。 在循环内使用 SameValueZero 判定或改用 includes。
既有数字 NaN 又有字符串 "NaN",如何保证两者都保留? 使用 Set 或 Map,它们不会将元素转换为字符串键,能够严格保留原始类型与引用。 直接调用 [...new Set(arr)] 即可天然支持。
低版本打包工具不支持 ES6 怎么办? 旧项目构建链缺少 core-js 或 @babel/polyfill。 采用本文第三节方案三的 ES5 兼容函数,配合显式类型检查与 isNaN 逻辑。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章