12k
All articles

3个JavaScript陷阱详解

解析 3 个 JavaScript 陷阱:浮点运算、NaN 检查和循环中的 await,并给出对应的正确修复方法。

OpenReplay Team
OpenReplay Team
3个JavaScript陷阱详解

0.1 + 0.2 不等于 0.3NaN 不等于其自身,await 在循环内部会让快速页面变得迟缓——这三种JavaScript行为看似是bug,实则是语言完全按照其规范运行的结果。本文不仅解释每种行为背后的机制,而非仅仅展示奇怪的控制台输出,还给出了每种情况的正确修复方案。这三种问题都会以真实的用户可见症状呈现:差了一分钱的总金额、放过了错误输入的验证逻辑、毫无明显原因的页面加载缓慢。理解其”原因”才能让你在生产环境中避免这三种问题,并在面试中给出清晰的回答。

核心要点

  • 0.1 + 0.2 返回 0.30000000000000004,原因是IEEE 754双精度浮点数以二进制存储数值,而 0.10.2 都没有精确的有限二进制表示,因此在相加之前每个值都会被舍入。
  • 处理货币时,以整数分为单位进行计算而非使用浮点数表示的元;进行一般浮点数比较时,使用 Math.abs(a - b) < tolerance 进行测试,且容差应根据数值的数量级来设定,而非一律使用 Number.EPSILON
  • NaN === NaNfalse,因为IEEE 754规范将NaN定义为不等于任何值,包括其自身——应使用不进行类型强制转换的 Number.isNaN(),而非全局的 isNaN()
  • 循环中的 await 会在每次迭代时暂停整个循环;应先启动所有Promise,然后使用 await Promise.all(...) 并发执行相互独立的请求。

陷阱一:为什么 0.1 + 0.2 不等于 0.3(浮点数运算)

0.1 + 0.2 返回 0.30000000000000004,原因是JavaScript数值采用IEEE 754双精度浮点数以二进制存储,而 0.10.2 都没有精确的有限二进制表示。每个字面量在你写下的那一刻就被舍入到最接近的可表示64位值,而这两个已经经过舍入的值之和又被舍入到略大于 0.3 的数。

0.1 + 0.2;             // 0.30000000000000004
0.1 + 0.2 === 0.3;     // false
(0.1).toPrecision(20); // "0.10000000000000000555"

最后一行揭示了问题所在:存储的 0.1 从来就不是精确的 0.1。这并非JavaScript的特有问题——这是双精度浮点数的固有属性,Python、Java、C以及任何使用相同格式的语言都存在同样的问题。0.1 的二进制表示是一个无限循环小数,就如同 1/3 在十进制中是 0.333… 一样,因此必须进行截断处理。

修复方案取决于你的计算场景:

// 货币:以整数分为单位计算,仅在显示时进行格式化
const total = 1010 + 2030;   // 3040分
(total / 100).toFixed(2);    // "30.40"

// 一般比较:根据数量级设定容差
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true

对于货币,应以整数分为单位进行存储和计算,而非使用浮点数表示的元,然后在显示层进行除法运算并调用 toFixed(2)。对于近似相等的比较,应与一个容差值进行比对。Number.EPSILON 仅适用于数量级在1附近的数值——MDN明确警告它不是一个通用的安全阈值,因此应根据所比较值的大小来调整容差。如果需要对数组求和,Math.sumPrecise() 已于2026年4月成为Baseline新可用特性,因此可能无法在旧版设备上运行,且需要注意的是,它仍然无法避免单个字面量的 0.1 + 0.2 精度问题——它只能防止误差在长时间累加过程中不断积累。

差了一分钱的总金额只有在完全相同的数值求和序列下才能复现,这正是能够还原真实输入顺序的会话回放比一张错误数字的截图更有价值的原因。

陷阱二:NaN 是唯一不等于自身的值

NaN === NaNfalse,因为IEEE 754标准将NaN(“Not-a-Number”,非数字)定义为不等于任何值,包括其自身,而JavaScript严格遵循了这一规则。这使得NaN成为该语言中唯一不等于自身的值——你甚至可以利用这一特性来检测NaN。

NaN === NaN;   // false
NaN == NaN;    // false
typeof NaN;    // "number"

typeof NaN 返回 'number':NaN是一个表示未定义或不可表示的数值结果的数值,而非一个独立的类型。真正的陷阱在于全局 isNaN() 函数,它在测试之前会将参数强制转换为数值,因此那些本身并非NaN的值也可能被当作数值来检测:

isNaN('');   // false  — '' 被强制转换为 0
isNaN([]);   // false  — [] 被强制转换为 0
isNaN('45'); // false  — '45' 被强制转换为 45
isNaN({});   // true   — {} 被强制转换为 NaN

修复方案是使用ES2015引入的 Number.isNaN(),它不进行类型强制转换,仅对实际的 NaN 值返回 true

输入isNaN()(有强制转换)Number.isNaN()(无强制转换)
NaNtruetrue
'NaN'truefalse
''falsefalse
[]falsefalse
'45'falsefalse
undefinedtruefalse

当你需要判断”这个值是否就是NaN”时,使用 Number.isNaN();当你需要判断”这是否是一个真实的有限数值”时,使用 Number.isFinite()——它无需强制转换即可排除 NaNInfinity 和非数值类型。还有一个值得澄清的相关误解:parseInt("032") 返回 32,而非 26。将前导零字符串自动识别为八进制的特性已在ECMAScript 5中被移除,因此被广泛引用的 26 这一说法是2011年之前的历史遗留——仍然应该传入基数参数,但要出于正确的理由。

当验证逻辑因 isNaN('') 将空字符串强制转换为 0 而出现误判时,问题值在bug报告中往往看起来是”空的”;回放实际的键盘操作和字段状态,才能清楚地看到究竟是什么值通过了验证。

陷阱三:循环中的 await 会将请求串行化并拖慢UI

循环中的 await 会在每次迭代时暂停整个循环,因此相互之间没有依赖关系的请求会严格按照顺序逐一执行,而非并行执行。每次迭代都要等待其Promise完成后,下一个请求才会开始,将N个独立的网络往返变成了N个串行操作。

// 串行:每个await都会阻塞下一次迭代
async function getUsers(ids) {
  const users = [];
  for (const id of ids) {
    users.push(await fetchUser(id)); // 每次等待约1.5秒
  }
  return users;
}

修复方案是先启动所有Promise,然后使用 Promise.all() 统一等待,它会并发触发所有请求,并在全部完成后resolve:

async function getUsers(ids) {
  return Promise.all(ids.map(fetchUser));
}

以模拟每个请求固定1.5秒延迟为例(用于说明串行化问题,并非真实网络耗时),差异会随批量大小线性增长:

方案3个请求10个请求
循环中的 await(串行)约4.5秒约15秒
Promise.all(并发)约1.5秒约1.5秒

需要注意的是:Promise.all 会在任意一个Promise reject时立即reject,并丢弃其余Promise的结果。当单个失败不应中止整个批次时,应改用 Promise.allSettled(),它会等待所有Promise完成,并将每个结果报告为 fulfilledrejectedPromise.allSettled 于ES2020发布,自2023年初起已在各主流浏览器中成为基线特性,在现代环境中无需polyfill。只有当每个请求确实依赖上一个请求的结果时,才应有意使用串行执行。

页面对某些用户来说”就是慢”,这在代码审查中很难发现;而会话回放能够直观地呈现请求逐一触发而非同时触发的情况,从而直接指向问题所在的awaited循环。

后续建议

这三种行为都符合规范,这正是它们能通过代码审查并最终影响用户的原因:二进制浮点数的舍入是因为IEEE 754如此规定,NaN不等于自身是因为标准如此定义,await 的串行化是因为这就是暂停执行的含义。对应的防御措施简单而具体——浮点数使用整数分和按数量级调整的容差,数值检查使用 Number.isNaN()Number.isFinite(),独立的异步操作使用 Promise.allPromise.allSettled。对照这三种模式审查你自己的货币计算、验证逻辑和数据获取循环,由此引发的那类”不可能出现的”bug就不会再进入生产环境。

常见问题

全局isNaN()和Number.isNaN()有什么区别?

全局isNaN()在测试之前会将参数强制转换为数值,因此isNaN('')和isNaN([])都返回false,因为''和[]会被强制转换为0,而isNaN('NaN')返回true,因为'NaN'会被强制转换为NaN值。ES2015新增的Number.isNaN()不进行强制转换,仅对实际的NaN值返回true。当你需要专门检测NaN时,应使用Number.isNaN()。

0.1 + 0.2的浮点数问题只在JavaScript中存在吗?

不是。任何使用IEEE 754双精度浮点数的语言都存在这个问题,包括Python、Java、C、C++和Ruby。由于0.1和0.2都没有精确的有限二进制表示,在存储时各自会被舍入,其和因此舍入为0.30000000000000004。这是二进制浮点格式本身的特性,而非JavaScript的bug,因此同样的整数分方案和基于容差的修复方法适用于所有语言。

什么时候应该使用Promise.allSettled而不是Promise.all?

当单个失败不应中止整个批次时,应使用Promise.allSettled。Promise.all会在任意一个Promise reject时立即reject并丢弃其他结果,因此适用于全有或全无的操作。Promise.allSettled无论结果如何都会等待所有Promise完成,并返回一个数组,将每个结果报告为fulfilled或rejected,这正是你在获取多个独立资源且允许部分成功时所需要的行为。它于ES2020发布,在现代环境中无需polyfill。

在循环中使用await有时是正确的做法吗?

是的,当每次迭代确实依赖上一次迭代的结果时,例如通过API进行分页查询(下一个请求需要上一个响应返回的游标),或者为了避免服务器过载而有意进行限速时。在这些情况下,串行执行是预期的行为。这个陷阱只适用于相互独立的请求——对于这类请求,在循环中使用await会不必要地将本可由Promise.all并发执行的工作串行化。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.