JavaScript相等性详解:===、==、关系运算符与Object.is
了解在 JavaScript 中,严格相等与宽松相等、关系比较以及 Object.is 是如何真正判定元素是否相同的,还有每种方式背后隐藏的强制转换陷阱。
来源: Day 4 of Learning JavaScript: The Three Ways JavaScript Asks “Are These the Same Thing?”
想象这样一个表单:用户将年龄输入为文本 "18",系统会悄悄地通过数值 18 的验证,但后续对该值的运算却出错。又或者,由于两个不同的值恰好能转换为相同的结果,本应失败的访问验证却通过了。这类错误源于一个事实:JavaScript 有多种方法来判断两个值是否相同,而每种方法的“相同”定义都不同。读完这段内容后,你将明白 ===、==、关系运算符以及 Object.is() 的实际作用,也能知道在各种情况下该使用哪种方法。
为何这种语言存在多种相等概念
早期的 JavaScript 在浏览器中运行,那时几乎所有的输入都是以表单中的文本形式出现的。该语言并未在遇到以字符串形式出现的数字时抛出错误,而是采用了容错设计:它会推测用户的意图并自动进行类型转换。这种便利性反而成了该语言中最常见的错误来源之一,而更严格的运算符正是为了解决这一问题而存在的。
默认使用严格相等比较(===)
先进行类型检查,再进行检查值
严格相等比较会分两步进行测试,且不会存在任何隐式的类型转换。首先它会比较两个操作数的类型,如果类型不同,则直接返回 false,而不会进一步检查具体值。只有当类型匹配时,才会对实际值进行比较。
console.log(1 === 1);
// true (both are numbers, both are 1)
console.log(1 === "1");
// false (the types fail immediately: number versus string)
console.log(0 === false);
// false (number versus boolean)
console.log("" === false);
// false (string versus boolean)
console.log(null === null);
// true
console.log(null === undefined);
// false (different types)
该代码片段中的每一个 false 结果都源于类型比较:数字与字符串、数字与布尔值、字符串与布尔值,以及 null 与 undefined 的比较。
字符串比较是逐字符进行的,大小写很重要,不可见的空白字符也同样关键——当人们在输入邮箱地址后按下空格键时,就会出现这种非常常见的实际错误。
console.log("hello" === "Hello");
// false (different capitalization)
console.log("john@email.com" === "john@email.com ");
// false (the second string has a hidden space at the end)
在实际应用中,这意味着在比较之前需要先对用户输入进行规范化处理,通常使用 trim() 方法去除首尾空白,对于不区分大小写的字段还需使用 toLowerCase()。
IEEE 754标准中的两个刻意设计的例外情况
严格相等在几乎所有情况下都是可靠的,只有两个数值例外。这并非错误,而是遵循了JavaScript数字所基于的IEEE 754浮点数标准。第一个例外是“非数字”值NaN,它不等于任何事物,包括其自身。
const result = 0 / 0;
// result is NaN
console.log(result === NaN);
// false
console.log(NaN === NaN);
// false
由于相等性检查无法检测到这种情况,应使用专门的Number.isNaN()函数。
console.log(Number.isNaN(NaN));
// true
相比旧的全局函数isNaN(),更推荐使用此函数,因为后者会先将其参数转换为数字,因此对于像"abc"这样的字符串也会返回true。第二个例外与零有关:JavaScript会分别表示正零和负零,这在某些数值代码中很重要,但严格相等操作会将两者视为相等。
console.log(+0 === -0);
// true
对象是通过标识符进行比较的
数字和字符串等原始类型是按值进行比较的。对象则不同:存储对象的变量实际上只是保存了该对象在内存中的地址引用。可以想象两栋根据相同设计图建造、涂成相同颜色的房子,虽然看起来一模一样,但由于地址不同,并非同一栋房子。
当使用 === 比较对象时,JavaScript 会判断两侧是否引用同一个对象,而不会考虑对象内部的内容。数组也遵循相同规则,因为它们同样是对象。
const userA = { name: "Alex" };
const userB = { name: "Alex" };
console.log(userA === userB);
// false (two different objects sitting in different memory slots)
console.log([] === []);
// false (two different arrays sitting in different memory slots)
如果一个变量被赋值为另一个变量的值,那么这两个变量就会引用同一个对象,比较结果为真。
const original = { name: "Alex" };
const pointer = original;
console.log(original === pointer);
// true (both point to the exact same memory location)
要检查两个不同的对象是否包含相同的数据,需要自行比较它们的原始属性。
console.log(userA.name === userB.name);
// true (now we are comparing two strings, not two object addresses)
对于嵌套结构,比较操作会变成递归形式,因此团队通常会使用工具库或测试框架中经过验证的深度相等性检查函数,而非自行编写。
严格不等式 (!==)
检查值是否不同与检查它们是否相同一样常见。严格不等式是===的直接否定:当类型或值存在差异时,它会返回true。
console.log(1 !== 2);
// true (they are different numbers)
console.log(1 !== "1");
// true (one is a number, one is a string)
console.log("apple" !== "orange");
// true
每当代码只需在条件不满足时才运行时,比如进行权限检查或过滤掉不需要的项,这就是最合适的用法。
const currentRole = "guest";
if (currentRole !== "admin") {
console.log("Access denied. Admins only.");
}
// "Access denied. Admins only."
宽松相等性 (==) 及其转换
强制类型转换如何产生意外结果
在严格相等成为标准之前,大多数代码使用的是 ==。当操作数类型不同时,宽松相等会将其中一个或两个值转换为通用类型后再进行比较。其结果往往与人们的直觉不符。
console.log(1 == "1");
// true (the string "1" becomes the number 1)
console.log(0 == false);
// true (false becomes 0)
console.log("" == false);
// true (empty string and false both become 0)
console.log(0 == "");
// true (empty string becomes 0)
console.log([] == "");
// true (empty array becomes empty string)
console.log([1] == 1);
// true (array becomes "1", which becomes 1)
有一个著名的例子展示了其中可能涉及的诸多隐藏步骤。
console.log([] == 0);
// true
为了得到 true,引擎首先将空数组转换为原始类型,结果为空字符串 ""。由于字符串要与数字进行比较,而两者类型仍不同,因此字符串会被转换为数字,结果为 0。最终的比较是 0 == 0,结果为 true。
布尔值同样棘手,因为当它们与非布尔值比较时,会先被转换为数字。true 会变为 1。
console.log(true == "true");
// false
字符串 "true" 并不会变成 1;转换为数字后会变为 NaN,而 1 不等于 NaN。这就是为什么 value == true 几乎从来都不是你想要的结果。
null 和 undefined 有特殊的规则:在宽松相等比较中,它们只彼此相等,甚至不等于 0、false 或空字符串。
console.log(null == undefined);
// true
console.log(null == 0);
// false
console.log(null == false);
// false
console.log(null == "");
// false
console.log(undefined == 0);
// false
可以使用 == 进行 null 检查的情况
在现代代码中应尽量避免使用宽松相等比较,但有一个常见的例外情况。在处理来自 API 的数据时,你常常需要判断某个值是否为 null 或 undefined,而 == null 可以通过一次比较就给出答案。
function checkStatus(status) {
if (status == null) {
console.log("Status is missing entirely");
} else {
console.log("Status is:", status);
}
}
checkStatus(null);
// "Status is missing entirely"
checkStatus(undefined);
// "Status is missing entirely"
checkStatus(0);
// "Status is: 0"
这一行为正是由于上述规则所致:null仅与null和undefined存在宽松相等关系,因此像0或""这类虽为假值但有实际意义的数值能够原封不动地通过检查。它替代了更长的status === null || status === undefined表达式。需要注意的是,许多团队会将代码检查工具配置为完全禁止使用==,此时就需要手动编写严格的比较版本。例如,ESLint的eqeqeq规则就有一个选项,允许使用这种针对null的比较方式,同时禁止其他用法。在很多情况下,空值合并运算符??或可选链操作符?.能更直接地表达相同意图。
宽松不等号(!=)
!=会执行与==相同的转换操作,然后再对结果取反,因此它会带来所有类似的意外情况。
console.log(1 != "1");
// false (it converts "1" to a number, decides they are the same, so they are not different)
此处"1"会被转换为数字1,两个值被视为相等,因此“不相等”的判断结果为false。应对!=保持与==相同的谨慎态度,优先使用!==。
关系比较运算符:<、>、<= 和 >=
数字与混合类型
当操作数为两个数字时,关系运算符的行为与算术运算中的完全一致。
console.log(5 > 3);
// true
console.log(10 <= 10);
// true
当字符串遇到数字时,字符串会先被转换为数字。无法表示为数字的字符串会变成NaN,任何涉及NaN的关系比较都会返回false。
console.log("10" > 5);
// true
console.log("abc" > 3);
// false ("abc" becomes NaN, and NaN comparisons always return false)
null与undefined的区别所在
关系运算符会表现出与宽松相等性的不一致。==拒绝将null转换为数字,而<、>、<=和>=则会将其转换为0。
console.log(null == 0);
// false
console.log(null > 0);
// false (0 is not greater than 0)
console.log(null >= 0);
// true (0 is greater than or equal to 0)
因此null既不等于0也不大于0,但却大于或等于0。一旦明白a >= b的实际含义是“a < b不成立”,且0 < 0为假,这个结果就合乎逻辑了。
undefined的表现则又有所不同。将其转换为数字后会得到NaN,所以任何涉及undefined的关系比较都会返回false。
console.log(undefined > 0);
// false
console.log(undefined < 0);
// false
字符串的比较是基于代码单元,而非字典顺序
当两个操作数都是字符串时,不会进行数值转换。系统会从左到右逐个字符比较,依据的是每个字符的UTF-16数值代码单元。
console.log("apple" < "banana");
// true (a comes before b)
console.log("b" > "a");
// true (b comes after a)
大写字母的代码单元值小于小写字母,这就是为什么简单的排序算法会将“Zebra”排在“apple”之前的原因。
console.log("Z" < "a");
// true (Z=90, a=97 in Unicode)
对于面向用户的排序,应使用localeCompare()或Intl.Collator,这些方法遵循语言规则而非单纯的代码单元值。更危险的陷阱在于包含数字的字符串:作为字符串比较时,“10”的数值小于“9”。
console.log("10" < "9");
// true (the character "1" is smaller than "9")
JavaScript 并不知道这些字符串代表的是数值。它会查看每个字符串的第一个字符,即 "1" 与 "9",发现 "1" 在前,于是直接返回 true。当使用 sort() 方法对数字数组进行排序且未指定比较函数时,也会出现同样的情况,因为默认的排序机制会在比较之前将元素转换为字符串;若要按数值顺序排序,则需传入 (a, b) => a - b 作为比较函数。
解决方法是先明确将数值字符串转换为数字,然后再进行大小比较。
console.log(Number("10") < Number("9"));
// false (correct: 10 is not less than 9)
用于精确比对的 Object.is()
补全 === 的不足
Object.is() 是 JavaScript 提供的最精确的相等性检查方法。对于普通值,它的判断结果与严格相等运算符相同。
console.log(Object.is(1, 1));
// true
console.log(Object.is(1, "1"));
// false
两者的区别就在于 IEEE 754 规范中的两种情况。Object.is() 将 NaN 视为与自身相同:
console.log(Object.is(NaN, NaN));
// true
同时它还能区分正零和负零:
console.log(Object.is(0, -0));
// false
三种比较方式的对比
1和"1":==的结果为true;而===与Object.is()的结果为false。0和false:==的结果为true;而===与Object.is()的结果为false。null和undefined:==的结果为true;而===与Object.is()的结果为false。
NaN与NaN:使用==和===时结果为false;使用Object.is()时结果为true。+0与-0:使用==和===时结果为true;使用Object.is()时结果为false。[]与[],或{}与{}:这三种情况的结果均为false,因为它们是不同的对象。5与5:这三种情况的结果均为true。你很少会直接调用 Object.is(),但它在你日常使用的工具中起着重要作用。React 依赖 Object.is() 来判断状态或依赖项是否发生变化,从而决定是否需要重新渲染;这也是为什么直接修改对象内容不会触发更新的原因:引用地址依然相同。状态管理库也采用了同样的原理;如需深入了解,可参阅 React-Redux 如何通过相等性检查来决定是否重新渲染。
关键要点
在第一个场景中,本应被拒绝的访问却获得了许可,这其实只需使用 == 将字符串与数字进行比较即可:隐式的类型转换会让两个不同的值看起来相等。防止这种情况发生的规则很简单:
- 默认情况下,在所有地方都应使用
===和!==。 - 尽量避免使用
==和!=,除非你的代码检查规则允许使用value == null这一简写形式。 - 检测
NaN应使用Number.isNaN(),切勿使用相等运算符。 - 请记住,对象比较检测的是身份而非内容。
- 在使用
<或>之前,应先用Number()将数字字符串转换为数值,并为sort()提供合适的比较函数。 - 当需要精确处理
NaN或带符号的零时,应使用Object.is()。
JavaScript 的宽松转换机制不会消失,但坚持使用严格比较可以防止其影响实际程序行为。