12k
All articles

JavaScriptの落とし穴3選を解説

JavaScriptの3つの落とし穴を解説: 浮動小数点演算、NaN判定、ループ内awaitと、それぞれの正しい対処法。

OpenReplay Team
OpenReplay Team
JavaScriptの落とし穴3選を解説

0.1 + 0.20.3にならず、NaNは自分自身と等しくなく、ループ内のawaitは高速なページを遅くする可能性があります。これらはバグのように見えますが、実際にはJavaScriptが仕様通りに動作している結果です。本記事では、奇妙なコンソール出力だけでなく、それぞれの動作の仕組みを解説し、正しい修正方法を紹介します。いずれも実際のユーザー体験に影響を与える症状として現れます。たとえば、1セントずれた合計金額、不正な入力を通過させてしまうバリデーション、明確な理由のない遅いページ読み込みなどです。「なぜそうなるのか」を理解することで、本番環境でのトラブルを防ぎ、面接での質問にも的確に答えられるようになります。

重要なポイント

  • 0.1 + 0.20.30000000000000004を返すのは、IEEE 754倍精度浮動小数点数が数値を2進数で格納するためです。0.10.2も正確な有限の2進数表現を持たないため、加算前にそれぞれが丸められます。
  • 金額の計算では、浮動小数点のドル単位ではなく整数のセント単位で計算し、表示時にのみ変換してください。一般的な浮動小数点の比較では、Math.abs(a - b) < toleranceを使い、一律にNumber.EPSILONを使うのではなく、比較する数値の大きさに合わせた許容誤差を設定してください。
  • NaN === NaNfalseになるのは、IEEE 754仕様がNaNを自分自身を含むあらゆる値と等しくないと定義しているためです。型強制を行わないNumber.isNaN()を使用し、グローバルのisNaN()は避けてください。
  • ループ内のawaitはイテレーションごとにループ全体を一時停止させます。独立したリクエストを並行して実行するには、先にPromiseを開始してからawait Promise.all(...)を使用してください。

落とし穴その1:0.1 + 0.20.3にならない理由(浮動小数点演算)

0.1 + 0.20.30000000000000004を返すのは、JavaScriptの数値がIEEE 754倍精度浮動小数点数として2進数で格納されており、0.10.2も正確な有限の2進数表現を持たないためです。これらのリテラルはコード上に記述された時点で最も近い64ビット表現可能な値に丸められ、すでに丸められた2つの値の合計が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、その他あらゆる言語に共通する倍精度浮動小数点数の特性です。2進数における0.1は循環小数であり、10進数における1/30.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の精度問題を回避できるわけではなく、長い合計計算における誤差の蓄積を防ぐためのものである点に注意してください。

1セントずれた合計金額は、合算された値の正確なシーケンスでのみ再現されます。そのため、実際の入力順序を再現するセッションリプレイは、誤った数値のスクリーンショットよりも有用です。

落とし穴その2:NaNは自分自身と等しくない唯一の値

NaN === NaNfalseになるのは、IEEE 754標準がNaN(“Not-a-Number”)を自分自身を含むあらゆる値と等しくないと定義しており、JavaScriptがその規則を忠実に実装しているためです。これにより、NaNはJavaScriptにおいて自分自身と等しくない唯一の値となり、この性質をNaN検出に利用することもできます。

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

typeof NaN'number'です。NaNは独立した型ではなく、未定義または表現不可能な数値演算の結果を表す数値です。実際の落とし穴はグローバルのisNaN()にあります。この関数はテスト前に引数を数値に型強制するため、NaNでない値でも数値として評価された結果が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()を使用してください。Number.isFinite()は型強制なしでNaNInfinity、非数値を拒否します。また、払拭すべき関連する誤解として、parseInt("032")26ではなく32を返します。先頭にゼロが付く文字列を8進数として自動検出する動作はECMAScript 5で削除されており、広く引用されている26という結果は2011年以前の古い情報です。基数を指定することは引き続き推奨されますが、正しい理由で行ってください。

isNaN('')が空文字列を0に型強制したことでバリデーションが誤動作した場合、問題の値はバグレポート上では「空」に見えることがよくあります。実際のキーストロークとフィールドの状態を再現することで、何が通り抜けたかを正確に把握できます。

落とし穴その3:ループ内のawaitはリクエストを直列化してUIを遅くする

forループ内の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()でまとめてawaitすることです。これによりリクエストが並行して実行され、すべて完了した時点で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はいずれか1つのPromiseがrejectされた時点でrejectし、残りの結果を破棄します。1件の失敗でバッチ全体を中断させたくない場合は、代わりにPromise.allSettled()を使用してください。これはすべてのPromiseを待ち、それぞれをfulfilledまたはrejectedとして報告します。Promise.allSettledはES2020で導入され、2023年初頭からモダンブラウザ全体でbaselineとなっているため、現代の環境ではポリフィルは不要です。直列実行を選択するのは、各リクエストが前のリクエストの結果に依存している場合に限定してください。

一部のユーザーにとって「なんとなく遅い」画面はコードレビューで発見しにくいですが、セッションリプレイを使用すると、リクエストが並行ではなく順番に発火していることが確認でき、awaitされたループが原因であることをすぐに特定できます。

次のステップ

これら3つの動作はいずれも仕様通りであり、だからこそコードレビューをくぐり抜けてユーザーに影響を与えるのです。2進数の浮動小数点はIEEE 754の規定通りに丸められ、NaNはその標準の定義通りに自分自身を拒否し、awaitは実行を一時停止することの意味通りに直列化します。対策はシンプルで機械的なものです。浮動小数点には整数セント単位と大きさに合わせた許容誤差を、数値チェックにはNumber.isNaN()Number.isFinite()を、独立した非同期処理にはPromise.allまたはPromise.allSettledを使用してください。自分のコードベースにある通貨計算、バリデーション処理、データフェッチのループをこれら3つのパターンで監査すれば、それらが引き起こす「再現不可能な」バグのリリースを防ぐことができます。

よくある質問

グローバルの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も正確な有限の2進数表現を持たないため、格納時にそれぞれ丸められ、合計が0.30000000000000004になります。これはJavaScriptのバグではなく、2進数浮動小数点形式の特性であるため、整数セント単位での計算や許容誤差を使った比較という修正方法は他の言語にも同様に適用できます。

Promise.allではなくPromise.allSettledを使うべき場合はいつですか?

1件の失敗でバッチ全体を中断させたくない場合にPromise.allSettledを使用してください。Promise.allはいずれか1つのPromiseがrejectされた時点でrejectし、他の結果を破棄するため、全か無かの処理に適しています。Promise.allSettledは結果に関わらずすべてのPromiseを待ち、それぞれをfulfilledまたはrejectedとして報告する配列を返します。複数の独立したリソースを取得する際に部分的な成功が許容される場合に使用するのが適切です。ES2020で導入されており、現代の環境ではポリフィルは不要です。

ループ内で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.