前端开发··2 阅读·预计 9 分钟

JavaScript 对象判等与比较语义的工程陷阱:从 == 隐式转换到 Object.is 的边界治理

引言

相等性判断看似是 JavaScript 最基础的语法,却是线上事故的高发区。== 的隐式转换规则、NaN 的「不自等」、-0+0 的等价性,三者叠加让一次普通的比较成为定时炸弹。本文不重复罗列规范表格,而是用可复现的正反例,拆解这些陷阱背后的语义差异,并给出可在团队落地的判等策略。

一、== 的隐式转换并非「宽松」而是「规则爆炸」

宽松相等真正的危害不在「宽松」,而在它依赖一套 11 条抽象相等比较算法(Abstract Equality Comparison)。开发者无法在脑中可靠地模拟它的每一步。

// 反例:直觉会给出错误答案的比较
console.log('' == 0);          // true,空字符串被转为 0
console.log('0' == false);     // true,字符串与布尔值都被转成数字
console.log(null == undefined); // true,唯一的「同阵营」例外
console.log(null == 0);        // false,null 不参与数字转换
console.log([] == false);      // true,[] 走 ToPrimitive 再转数字
console.log('[object Object]' == {}); // true,对象转字符串后恰好相等

上面每一行的结果都「有理由」,但理由彼此矛盾。正例是用显式转换统一语义,让阅读者不需要背规范:

// 正例:显式归一化后再做严格比较
const toNumber = (v) => (v == null ? 0 : Number(v));

if (toNumber(inputValue) === toNumber(expectedValue)) {
  // 语义清晰:双方先统一为数字再比较
}

团队契约建议:默认禁用 ==,仅在 x == null(同时捕获 nullundefined)这一处豁免,其余走 ===

二、=== 的两个著名例外:NaN±0

严格相等在多数情况下「所见即所得」,但 NaN 和零值符号是两处反直觉点,几乎每个项目都踩过。

// 反例:NaN 永远不等于自身
const value = parseFloat(userInput);
if (value === NaN) {        // 恒为 false,永远不会进入
  // 这段防御形同虚设
}

// 惯用绕过方式依赖全局函数,但易被局部遮蔽
const NaN_shadow = 'not-a-number';
if (value !== value) {}     // 确实可靠,但可读性差

正例:用 Number.isNaN 精确判断,避免 isNaN 的隐式转数字副作用。

// 正例:Number.isNaN 不做转换,行为可预测
console.log(isNaN('abc'));      // true,「abc」被转成 NaN
console.log(Number.isNaN('abc')); // false,字符串并非 NaN

if (Number.isNaN(parseFloat(input))) {
  // 只有真正解析失败才进入
}

零值符号则是另一个隐蔽差异:加法运算中 -0 会丢失符号,但除法、1 / x 场景下 -00 会区分正负无穷。

const negZero = -1 / Infinity; // -0
console.log(negZero === 0);    // true,严格相等认为二者相同
console.log(1 / negZero);      // -Infinity,符号差异在此显形

三、Object.is:补上 === 漏掉的两块拼图

Object.is 的语义是 SameValue,它修正了 === 的两处「特殊处理」:NaN 视为相等,-0+0 视为不等。

console.log(Object.is(NaN, NaN));     // true
console.log(Object.is(0, -0));        // false
console.log(NaN === NaN);             // false
console.log(0 === -0);                // true

但要注意,Object.is 不是 === 的「升级替换」,三者在零值符号上形成三角关系,选错语义会引入比没用更隐蔽的 bug。

比较方式NaN 与 NaN+0 与 -0
==falsetrue
===falsetrue
Object.istruefalse

四、数组与集合成员判等:SameValueZero 的隐形登场

Array.prototype.includesMapSet 内部使用的不是 ===,而是 SameValueZero:它视 NaN 相等,但 ±0 相等。这意味着集合的成员判等语义与「手写遍历 ===」并不一致。

const set = new Set([NaN]);
console.log(set.has(NaN));     // true,SameValueZero 视为相等
console.log([NaN].includes(NaN)); // true,同样走 SameValueZero

// 反例:手写 indexOf 用的是严格相等,结果相反
console.log([NaN].indexOf(NaN));  // -1,找不到

差异的直接后果:同一份数据,用 includes 判断存在性是 true,用 indexOf 判断却返回 -1。这类不一致往往在重构「把 indexOf 换成 includes」时悄然埋雷。

五、给团队的四条落地契约

综合以上,抽象出一套可执行的判等规范,而非让每个成员临时查表:

// 1. 判等默认用 ===;仅 x == null 豁免
// 2. 判 NaN 用 Number.isNaN,而非 isNaN 或 x !== x
// 3. 需要区分 ±0 时用 Object.is,并注释说明原因
// 4. 集合成员/包含判断统一用 includes/has,避免与 indexOf 混用

// 可选的 lint 成本:开启 eqeqeq 并配置 smart 模式
// {
//   "eqeqeq": ["error", "smart"],
//   "no-self-compare": "error"
// }

结语

相等性比较的「坑」并不来自语法难懂,而来自它存在五套并行的语义:=====Object.is、SameValueZero,以及手写 !== 自比。它们对 NaN±0 的处理各不相同。治理的关键不是记下每一张表,而是收敛为团队公认的少数几条规则,配合 lint 栅栏把「安全的默认值」变成「唯一的默认值」。当比较语义不再依赖个人记忆时,这一类隐式转换事故才能真正消失。

0 评论

评论区

登录 后参与评论