フロントエンド開発の現場において、データ比較はバグの温床になりやすい領域の一つだ。特に状態管理ライブラリのセレクター、Reactの再レンダリング最適化(`React.memo`や`useMemo`)、あるいは複雑なドメインロジックにおける変更検知において、「等価性(Equality)」の解釈を誤ることは、致命的なパフォーマンス劣化や、再現性の低いUIの不整合を引き起こす。
コードレビューをしていて、未だに `===` 演算子だけで全てを解決しようとしているコードを見かける。しかし、IEEE 754倍精度浮動小数点数規格の仕様と、V8エンジン内部での数値表現の現実を知っていれば、`===` が万能ではないことは明白だ。
今回は、`Object.is` と `===` 演算子の決定的な違い、特に `NaN` と `-0`(負のゼロ) という、JavaScriptが抱える美しくも厄介なエッジケースに焦点を当て、堅牢なプロダクションコードの書き方を伝授する。
—
1. IEEE 754とJavaScriptの数値表現:なぜ比較でバグが起きるのか
JavaScriptのすべての数値(Number型およびBigIntを除く)は、IEEE 754規格に基づく64ビットの浮動小数点数としてV8のヒープ(あるいはインラインキャッシュ上のSMI)に格納される。この仕様上、避けて通れないのが以下の2つの例外的な存在だ。
1. `NaN`(Not-a-Number)の自己不一致性
- IEEE 754の仕様により、`NaN` は自分自身を含め、いかなる値とも等しくならない(`NaN === NaN` は `false`)。
2. 符号付きゼロ(`+0` と `-0`)の存在
- ビットレベルで見ると、正のゼロと負のゼロは符号ビットが異なるが、数学的には「等しい」とされるべきものである。
このハードウェアレベルの仕様の差異を、言語仕様としてどう抽象化するか。ここで `===`(厳密等価演算子)と `Object.is`(SameValueアルゴリズム)の挙動が分かれる。
`===` 演算子の限界
`===` は、基本的には直感的な同値性を返す。しかし、前述の2点において開発者の意図を裏切る。
console.log(NaN === NaN); // => false (バグの温床!)
console.log(+0 === -0); // => true (数学的には正しいが…)
`Object.is`(SameValue)の真価
ECMAScript 6 (ES2015) で導入された `Object.is` は、IEEE 754の厳密なビット表現の一致(SameValueアルゴリズム)を採用している。
console.log(Object.is(NaN, NaN)); // => true (同じNaNとして判定)
console.log(Object.is(+0, -0)); // => false (符号ビットの違いを検知)
—
2. 実務における脅威:NaNと-0が引き起こすサイレントバグ
「そんな特殊な値、実際のビジネスロジックで扱うことなんてあるのか?」と思うかもしれない。だが、モダンなWebアプリケーションにおいては日常茶飯事だ。
ケースA: フォーム入力とAPIレスポンスの `NaN`
ユーザーが数値入力フォームを空にして送信した際、あるいはAPIから不正なJSONが返ってきたとき、変数が `NaN` に落ち込むことはよくある。
ReduxやZustandなどの状態管理で、前回の状態と今回の状態を浅い比較(Shallow Equal)やメモ化で比較する際、`===` を使っていると、「`NaN` に変わったこと」が検知できず、無限ループや無駄な再レンダリング、あるいは不正なバリデーションスルーを引き起こす。
ケースB: 金融・グラフィックス演算における `-0`
CanvasやWebGLを用いたアニメーション、あるいは厳密な財務計算において、ベクトルや座標の計算結果が `-0` になることがある。
例えば、アニメーションのイージング関数や補間ロジックで、`-0` をそのままCSSプロパティ(`transform` など)に流し込んだり、キャッシュのキーとして使ったりすると、意図しない描画のちらつきや、オブジェクトのプロパティ順序・キャッシュヒット率の低下を招く。
—
3. プロダクションコード:堅牢なカスタムフックと等価性判定ユーティリティ
では、実務の現場でどのようにこれをハンドリングすべきか。
コンポーネントの再レンダリング制御や、非同期APIのデータ変更検知において、`NaN` や `-0` を安全に扱える堅牢なユーティリティ関数と、それを用いたカスタムフックの設計例を示す。
以下のコードは、実務のコードベースにそのまま組み込めるクオリティに仕上げている。
/
- @file equalityUtils.js
- @description IEEE 754のエッジケース(NaN, -0)を完全に考慮した厳密な同値判定モジュール
/
/
- 2つの値が厳密に等しいかを判定する(SameValueZeroベースの拡張)
- NaN同士を等価とみなし、-0と+0を区別しない(通常のUI状態管理で最も安全な仕様)
- @param {unknown} a
- @param {unknown} b
- @returns {boolean}
/
export function safeIs(a, b) {
// Object.isは NaN同士を true にし、-0 と +0 を区別する
if (Object.is(a, b)) {
return true;
}
// NaNの双方向チェック(Number.isNaNは型安全)
if (Number.isNaN(a) && Number.isNaN(b)) {
return true;
}
return false;
}
/
- プリミティブ値からなる配列やオブジェクトのシャロー比較(NaN対応版)
- ReactのuseMemoやカスタムフックの依存配列比較に応用可能
- @param {Record
| Array } a - @param {Record
| Array } b - @returns {boolean}
/
export function shallowEqualWithNaN(a, b) {
if (Object.is(a, b)) return true;
if (
typeof a !== ‘object’ || a === null ||
typeof b !== ‘object’ || b === null
) {
return false;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (let i = 0; i < keysA.length; i++) { const key = keysA[i]; if (!Object.prototype.hasOwnProperty.call(b, key)) { return false; } // 各プロパティの値に対してNaNセーフな比較を適用 if (!safeIs(a[key], b[key])) { return false; } } return true; }
実務での応用:変更検知カスタムフック(React)
この `safeIs` を用いることで、APIから返ってきたデータやフォームの状態に `NaN` が混入しても、確実に変更を検知できるカスタムフックを実装できる。
import { useRef, useEffect } from ‘react’;
import { safeIs } from ‘./equalityUtils’;
/
- 前回の値との差分を監視し、値が変更された時だけコールバックを実行する
- NaN や -0 の揺らぎによる誤検知を完全に防ぐ
- @param {unknown} value 監視対象の値
- @param {(curr: unknown, prev: unknown) => void} callback 変更時のコールバック
/
export function useDeepValueEffect(value, callback) {
const previousValueRef = useRef(value);
useEffect(() => {
const previousValue = previousValueRef.current;
// safeIs を用いることで NaN の変化も正確にキャッチする
if (!safeIs(value, previousValue)) {
callback(value, previousValue);
previousValueRef.current = value;
}
}, [value, callback]);
}
—
4. パフォーマンスとV8エンジンの最適化に関するアーキテクトからの助言
「毎回関数でラップしたり、`Object.is` を呼ぶことでパフォーマンスが落ちるのではないか?」という懸念を持つエンジニアもいるだろう。
現代のV8エンジン(TurboFanコンパイラ)は非常に優秀だ。`Object.is` や `Number.isNaN` はインライン展開(Inlining)され、多くの場合、通常の比較演算子と同等の速度まで最適化される。
ただし、以下の点には注意してアーキテクチャを組むべきである。
1. ホットパス(Hot Path)での過剰な深い比較(Deep Equality)の回避
- レンダリングのたびに巨大なオブジェクトツリーの深層比較を走らせるのは、V8のガベージコレクターやCPUキャッシュ効率の観点からアンチパターンである。
- 状態の正規化(Normalization)を行い、比較コストがO(1)〜O(Nの浅い階層)で済む設計を心がけよう。
2. 型の一貫性の維持(Hidden Classesの維持)
- V8はオブジェクトのプロパティ構造(Hidden Class / Shape)が変化すると、インラインキャッシュ(IC)のミスを引き起こし、最適化解除(Deoptimization)を誘発する。
- 比較関数に渡すオブジェクトの形状(プロパティの順序や存在)は常に一定に保つこと。
—
5. 総括
`Object.is` と `===` の違いは、単なる言語仕様のトリビアではない。それは、浮動小数点数というコンピュータの根本的な数値表現の限界を、フロントエンド開発者がいかにエレガントに調停し、バグのない堅牢なシステムを構築するかというエンジニアリングの美学そのものである。
コードレビューの際、`NaN` を扱う可能性のある計算結果の比較や、状態管理の同値性判定で `===` が使われていたら、迷わずこう問いかけてほしい。
「その比較、もし値が `NaN` になったらどうなるかテストしましたか?」
細部へのこだわりこそが、プロダクトの品質を神域へと引き上げる。今日のコードから、その甘さを排除しよう。