JavaScript 的抽象相等比较(`==`)算法:类型转换与值的精确匹配逻辑

深入解析JavaScript抽象相等比较 (==) 算法:类型转换与值的精确匹配逻辑

在JavaScript的世界里,相等比较是一个核心且常被误解的概念。我们有两种主要的相等运算符:抽象相等比较(==,也称为宽松相等)和严格相等比较(===)。尽管严格相等因其直观性而被广泛推荐,但深入理解抽象相等比较 (==) 的工作原理,对于任何希望掌握JavaScript深层机制的开发者来说都是至关重要的。它揭示了JavaScript在进行类型转换时的哲学,以及它如何尝试在不同类型的值之间找到“相等”。

本讲座将带您深入ECMA-262规范,详细剖析==运算符背后复杂的抽象相等比较算法。我们将探讨类型转换的规则、值的精确匹配逻辑,并通过丰富的代码示例来演示其行为,包括那些看似反直觉的场景。


1. =====:核心差异概览

在开始深入研究==之前,我们先快速回顾一下它与===的核心区别。

  • === (严格相等):在比较两个值时,如果它们的类型不同,===会立即返回false,不进行任何类型转换。只有当类型相同且值也相同时,才返回true。这使得===的行为非常可预测和直观。

  • == (抽象相等):在比较两个值时,如果它们的类型不同,==会尝试进行类型转换(type coercion),将一个或两个操作数转换为相同的类型,然后再进行值的比较。这种隐式类型转换是==行为复杂且有时难以预测的根源。

理解==的关键在于理解其内部的“抽象相等比较算法”(Abstract Equality Comparison Algorithm),这是ECMA-262规范中定义的一系列步骤。


2. 抽象相等比较算法(Abstract Equality Comparison Algorithm)概述

当JavaScript引擎遇到x == y这样的表达式时,它会调用内部的Abstract Equality Comparison算法。这个算法是一个递归过程,根据两个操作数xy的类型来执行不同的比较逻辑。其基本流程可以概括如下:

  1. 如果类型相同:直接比较值。
  2. 如果类型不同:尝试进行类型转换,然后重新比较。

下面我们来详细分解这个算法的每一步。


3. 类型相同的比较逻辑 (Type(x) === Type(y))

xy的类型相同时,==的行为与===几乎完全一致。这是最直接的比较场景。

3.1. Undefined 类型

如果xy都是Undefined类型,则返回true

console.log(undefined == undefined); // true

3.2. Null 类型

如果xy都是Null类型,则返回true

console.log(null == null); // true

3.3. Number 类型

如果xy都是Number类型,则:

  • 如果xNaN,返回false。(NaN不等于任何值,包括它自己)
  • 如果yNaN,返回false
  • 如果xy是相同的数值,返回true
  • 如果x+0y-0,或者x-0y+0,返回true。这体现了JavaScript中零的特殊处理。
    console.log(10 == 10);     // true
    console.log(10 == 5);      // false
    console.log(NaN == NaN);   // false (NaN is the only value not equal to itself)
    console.log(0 == -0);      // true
    console.log(0 == +0);      // true
    console.log(-0 == +0);     // true

3.4. String 类型

如果xy都是String类型,当它们具有相同的字符序列、相同的长度且对应位置的字符都相同时,返回true

console.log("hello" == "hello"); // true
console.log("hello" == "world"); // false
console.log("" == "");           // true

3.5. Boolean 类型

如果xy都是Boolean类型,当它们都为true或都为false时,返回true

console.log(true == true);   // true
console.log(false == false); // true
console.log(true == false);  // false

3.6. Symbol 类型

如果xy都是Symbol类型,当它们引用相同的Symbol值时,返回true。这通常意味着它们是同一个Symbol变量或通过Symbol.for()创建的全局Symbol

let sym1 = Symbol('foo');
let sym2 = Symbol('foo');
let sym3 = sym1;

console.log(sym1 == sym1); // true (same reference)
console.log(sym1 == sym2); // false (different references, even if description is same)
console.log(sym1 == sym3); // true (same reference)

3.7. BigInt 类型

如果xy都是BigInt类型,当它们代表相同的数学整数值时,返回true

console.log(10n == 10n);   // true
console.log(5n == 10n);    // false
console.log(0n == -0n);    // true (BigInts don't have distinct positive/negative zero)

3.8. Object 类型

如果xy都是Object类型,当它们引用内存中的同一个对象时,返回true。这被称为“引用相等”。

let obj1 = {};
let obj2 = {};
let obj3 = obj1;
let arr1 = [];
let arr2 = [];

console.log(obj1 == obj1); // true (same reference)
console.log(obj1 == obj2); // false (different references)
console.log(obj1 == obj3); // true (same reference)
console.log(arr1 == arr2); // false (different references)

4. 类型不同的比较逻辑 (Type Coercion)

xy的类型不同时,==算法会启动类型转换机制。这是==最复杂且最容易出错的部分。算法会按照优先级和规则尝试将一个或两个操作数转换为可以比较的类型。

4.1. NullUndefined 的特殊关系

如果一个是Null,另一个是Undefined,则返回true。这是==的一个特例,它们被认为是“相等”的。

console.log(null == undefined); // true
console.log(undefined == null); // true

// 但它们不等于其他任何值
console.log(null == 0);         // false
console.log(null == '');        // false
console.log(null == false);     // false
console.log(undefined == 0);    // false
console.log(undefined == '');   // false
console.log(undefined == false);// false

4.2. Number 与 String 比较

如果一个是Number,另一个是String,算法会将String转换为Number类型,然后比较这两个Number
转换规则:ToNumber(String)

// 字符串 "10" 转换为数字 10,然后 10 == 10
console.log(10 == "10");     // true

// 字符串 "  5  " 转换为数字 5,然后 5 == 5
console.log(5 == "  5  ");   // true

// 字符串 "0xAF" 转换为数字 175,然后 175 == 175
console.log(175 == "0xAF");  // true

// 字符串 "" 转换为数字 0,然后 0 == 0
console.log(0 == "");        // true

// 字符串 "abc" 转换为数字 NaN,然后 10 == NaN
console.log(10 == "abc");    // false (NaN不等于任何值)
console.log(NaN == "abc");   // false (NaN不等于任何值)

4.3. Boolean 与其他类型比较

如果一个是Boolean类型,另一个是其他类型,算法会将Boolean操作数转换为Number类型。true转换为1false转换为0。然后重新执行比较。

// true 转换为 1,然后 1 == 1
console.log(true == 1);       // true

// false 转换为 0,然后 0 == 0
console.log(false == 0);      // true

// true 转换为 1,然后 1 == "1"。再将 "1" 转换为 1,所以 1 == 1
console.log(true == "1");     // true

// false 转换为 0,然后 0 == ""。再将 "" 转换为 0,所以 0 == 0
console.log(false == "");     // true

// true 转换为 1,然后 1 == "0"。再将 "0" 转换为 0,所以 1 == 0
console.log(true == "0");     // false

// false 转换为 0,然后 0 == "abc"。再将 "abc" 转换为 NaN,所以 0 == NaN
console.log(false == "abc");  // false

4.4. Object 与 Primitive 类型比较

如果一个是Object类型,另一个是Primitive类型(String, Number, Symbol, BigInt),算法会将Object操作数转换为原始类型(Primitive)。这个转换通过内部的ToPrimitive操作完成。

ToPrimitive操作的步骤:

  1. 检查Symbol.toPrimitive方法:如果对象有Symbol.toPrimitive方法,则调用它,并传递一个hint参数("number""string")。如果该方法返回一个原始值,则使用它。
  2. hint"number"(默认行为,或在数学运算上下文)
    • 调用对象的valueOf()方法。如果返回原始值,则使用它。
    • 否则,调用对象的toString()方法。如果返回原始值,则使用它。
    • 如果两者都未返回原始值,则抛出TypeError
  3. hint"string"(在字符串上下文,如String()转换)
    • 调用对象的toString()方法。如果返回原始值,则使用它。
    • 否则,调用对象的valueOf()方法。如果返回原始值,则使用它。
    • 如果两者都未返回原始值,则抛出TypeError

对于==操作,ToPrimitivehint通常默认为"number"(除非另一个操作数是字符串,这时可能会倾向于"string",但规范中通常是"number")。

示例:Object 转换为 Primitive

  • 数组 (Array)
    • [ ]valueOf() 返回 [ ] (非原始值)。toString() 返回 ""。所以 [ ] == 0 会变成 "" == 0,最终为 true
    • [1]valueOf() 返回 [1]toString() 返回 "1"。所以 [1] == "1" 会变成 "1" == "1",最终为 true
    • [1, 2]valueOf() 返回 [1, 2]toString() 返回 "1,2"。所以 [1,2] == "1,2" 会变成 "1,2" == "1,2",最终为 true
// [ ] 转换为 "" (ToPrimitive),然后 "" 转换为 0 (ToNumber),所以 0 == 0
console.log([] == 0);         // true

// [ ] 转换为 "" (ToPrimitive),然后 "" == ""
console.log([] == "");        // true

// [1] 转换为 "1" (ToPrimitive),然后 "1" == 1。再将 "1" 转换为 1,所以 1 == 1
console.log([1] == 1);        // true

// [1] 转换为 "1" (ToPrimitive),然后 "1" == "1"
console.log([1] == "1");      // true

// [1, 2] 转换为 "1,2" (ToPrimitive),然后 "1,2" == "1,2"
console.log([1, 2] == "1,2"); // true
  • 普通对象 (Object)
    • {}valueOf() 返回 {}toString() 返回 "[object Object]"
      
      // {} 转换为 "[object Object]" (ToPrimitive),然后 "[object Object]" == "[object Object]"
      console.log({} == "[object Object]"); // true

// {} 转换为 "[object Object]" (ToPrimitive),然后 "[object Object]" == 0。再将 "[object Object]" 转换为 NaN,所以 NaN == 0
console.log({} == 0); // false


*   **日期对象 (`Date`)**:`Date`对象的`ToPrimitive`行为会根据`hint`有所不同。在数字上下文中,它会尝试返回时间戳(数字)。
```javascript
let d = new Date(0); // 1970-01-01T00:00:00.000Z
console.log(d == 0);      // true (d.valueOf()返回0,然后 0 == 0)
console.log(d == "0");    // true (d.valueOf()返回0,然后 0 == "0",再 "0" 转 0,所以 0 == 0)

4.5. Symbol 与其他类型比较

如果一个是Symbol,另一个是其他类型(除了同为Symbol),==会直接返回falseSymbol不会进行隐式类型转换来与其他类型的值进行比较。

let sym = Symbol("test");
console.log(sym == "test");     // false
console.log(sym == true);       // false
console.log(sym == 123);        // false
console.log(sym == null);       // false
console.log(sym == undefined);  // false
console.log(sym == {});         // false

4.6. BigInt 与 Number 比较

如果一个是BigInt,另一个是Number,它们会进行数值上的比较。如果它们的数学值相同,则返回true

console.log(10n == 10);      // true
console.log(10 == 10n);      // true
console.log(0n == 0);        // true
console.log(1n == true);     // false (true先转为Number 1,1n == 1,所以true。但规范规定,当BigInt与Boolean比较时,BigInt会先转为Number,然后进行比较。此处的行为是:true转为1,1n转为1,所以1 == 1,true)
// 实际上,BigInt和Boolean比较时,BigInt会先转为Number,然后比较。
// true == 1n => ToNumber(1n) => 1 == 1 => true
// false == 0n => ToNumber(0n) => 0 == 0 => true
console.log(1n == true);     // true
console.log(0n == false);    // true
console.log(10n == 10.0);    // true
console.log(10n == 10.1);    // false

更正和详细说明:BigInt与Boolean的比较
根据ECMA-262规范,当BigIntBoolean进行抽象相等比较时,算法会先将Boolean转换为Number。然后,如果一个操作数是BigInt,另一个是Number,则按照BigIntNumber的比较规则进行:如果它们的数学值相同,则返回true

// 示例:BigInt 与 Boolean
console.log(1n == true);
// 1. `true` 是 Boolean,`1n` 是 BigInt。
// 2. 将 Boolean `true` 转换为 Number,得到 `1`。
// 3. 比较 `1n == 1`。
// 4. `1n` 和 `1` 的数学值相同,所以返回 `true`。

console.log(0n == false);
// 1. `false` 是 Boolean,`0n` 是 BigInt。
// 2. 将 Boolean `false` 转换为 Number,得到 `0`。
// 3. 比较 `0n == 0`。
// 4. `0n` 和 `0` 的数学值相同,所以返回 `true`。

console.log(2n == true);
// 1. `true` 转换为 `1`。
// 2. 比较 `2n == 1`。
// 3. 数学值不同,返回 `false`。

5. 抽象相等比较算法流程图(简化版)

为了更好地理解上述规则,我们可以用一个简化的流程图来表示算法的决策过程。

比较 x == y

1. Type(x) 和 Type(y) 是否相同?
   YES -> 按照同类型规则比较 (见 §3)
          返回结果

   NO  -> 继续下一步 (类型转换逻辑)

2. x 是 Null 且 y 是 Undefined, 或 x 是 Undefined 且 y 是 Null?
   YES -> 返回 true

3. x 是 Number 且 y 是 String?
   YES -> 将 y 转换为 Number (ToNumber(y)),然后比较 x == ToNumber(y)
          返回结果

4. x 是 String 且 y 是 Number?
   YES -> 将 x 转换为 Number (ToNumber(x)),然后比较 ToNumber(x) == y
          返回结果

5. x 是 Boolean?
   YES -> 将 x 转换为 Number (ToNumber(x),true -> 1, false -> 0),然后比较 ToNumber(x) == y
          返回结果

6. y 是 Boolean?
   YES -> 将 y 转换为 Number (ToNumber(y),true -> 1, false -> 0),然后比较 x == ToNumber(y)
          返回结果

7. x 是 Object 且 y 是 (String, Number, Symbol, BigInt) 之一?
   YES -> 将 x 转换为原始值 (ToPrimitive(x)),然后比较 ToPrimitive(x) == y
          返回结果

8. x 是 (String, Number, Symbol, BigInt) 之一 且 y 是 Object?
   YES -> 将 y 转换为原始值 (ToPrimitive(y)),然后比较 x == ToPrimitive(y)
          返回结果

9. x 是 BigInt 且 y 是 Number?
   YES -> 如果 x 和 y 的数学值相同,返回 true;否则返回 false
          返回结果

10. x 是 Number 且 y 是 BigInt?
    YES -> 如果 x 和 y 的数学值相同,返回 true;否则返回 false
           返回结果

11. 其他所有情况 (例如 Symbol 与 Number, BigInt 与 Symbol, Object 与 Object 但引用不同, Symbol 与 Object 等)
    NO  -> 返回 false

6. 常见陷阱与反直觉行为

==的类型转换特性导致了许多令人困惑的结果,尤其是当操作数涉及空值、空字符串、空数组或空对象时。

6.1. NaN 的特殊性

NaN不等于任何值,包括它自己。

console.log(NaN == NaN);      // false
console.log(NaN == 0);        // false
console.log(NaN == undefined); // false
console.log(NaN == null);     // false

6.2. 空字符串、空数组与零/布尔值

// 字符串转数字,"" -> 0
console.log("" == 0);         // true

// 布尔转数字,false -> 0
console.log(false == 0);      // true

// 组合:false == ""
// 1. false 转换为 0 (ToNumber(false))
// 2. 0 == ""
// 3. "" 转换为 0 (ToNumber("")), 所以 0 == 0
console.log(false == "");     // true

// 数组转原始值,[] -> ""
// 1. [] 转换为 "" (ToPrimitive([]))
// 2. "" == 0
// 3. "" 转换为 0 (ToNumber("")), 所以 0 == 0
console.log([] == 0);         // true

// 数组转原始值,[] -> ""
// 1. [] 转换为 "" (ToPrimitive([]))
// 2. "" == false
// 3. false 转换为 0 (ToNumber(false))
// 4. "" 转换为 0 (ToNumber("")), 所以 0 == 0
console.log([] == false);     // true

// ![] 的计算:
// 1. [] 是 truthy 值,所以 ![] 是 false
// 2. 然后比较 [] == false
// 3. 如上所述,结果为 true
console.log([] == ![]);      // true (一个经典的JS面试题)

6.3. nullundefined 与其他假值的区别

尽管nullundefined在布尔上下文中都是falsey值,但它们只在彼此之间==true。它们不等于0false或空字符串。

console.log(null == 0);         // false
console.log(undefined == 0);    // false
console.log(null == false);     // false
console.log(undefined == false); // false
console.log(null == "");        // false
console.log(undefined == "");   // false

6.4. 对象的ToPrimitive行为

// 普通对象 {} 转换为 "[object Object]"
console.log({} == "[object Object]"); // true

// 普通对象 {} 转换为 "[object Object]",然后与 0 比较,"[object Object]" 转换为 NaN,所以 NaN == 0
console.log({} == 0);                 // false

// 自定义 valueOf/toString
let myObj = {
  valueOf: function() { return 10; },
  toString: function() { return "hello"; }
};
console.log(myObj == 10);     // true (优先调用 valueOf)
console.log(myObj == "hello"); // false (因为 `myObj == "hello"` 相当于 `10 == "hello"`, "hello" 转 NaN, 10 == NaN)

let myObj2 = {
  // 不定义 valueOf, 只有 toString
  toString: function() { return "20"; }
};
console.log(myObj2 == 20);    // true (调用 toString 返回 "20",然后 "20" 转 20,所以 20 == 20)

7. 抽象相等比较总结表

表达式 (x == y) 结果 解释
undefined == undefined true 同类型比较
null == null true 同类型比较
null == undefined true 特殊规则:nullundefined 互相相等
1 == 1 true 同类型 (Number) 比较
0 == -0 true 同类型 (Number) 比较,+0-0 相等
NaN == NaN false 同类型 (Number) 比较,NaN 不等于自身
"hello" == "hello" true 同类型 (String) 比较
true == true true 同类型 (Boolean) 比较
sym1 == sym1 true 同类型 (Symbol) 比较,引用相同
sym1 == sym2 false 同类型 (Symbol) 比较,引用不同
10n == 10n true 同类型 (BigInt) 比较
{a:1} == {a:1} false 同类型 (Object) 比较,引用不同
10 == "10" true String 转换为 Number10 == ToNumber("10") -> 10 == 10
0 == "" true String 转换为 Number0 == ToNumber("") -> 0 == 0
true == 1 true Boolean 转换为 NumberToNumber(true) == 1 -> 1 == 1
false == 0 true Boolean 转换为 NumberToNumber(false) == 0 -> 0 == 0
true == "1" true Boolean 转换为 Number1 == "1" -> 1 == ToNumber("1") -> 1 == 1
false == "" true Boolean 转换为 Number0 == "" -> 0 == ToNumber("") -> 0 == 0
[] == 0 true Object 转换为 PrimitiveToPrimitive([]) -> ""。然后 "" == 0 -> ToNumber("") == 0 -> 0 == 0
[] == "" true Object 转换为 PrimitiveToPrimitive([]) -> ""。然后 "" == ""
[1] == "1" true Object 转换为 PrimitiveToPrimitive([1]) -> "1"。然后 "1" == "1"
{} == "[object Object]" true Object 转换为 PrimitiveToPrimitive({}) -> "[object Object]"。然后 "[object Object]" == "[object Object]"
null == 0 false null 只与 undefined 相等
undefined == "" false undefined 只与 null 相等
Symbol('a') == 'a' false Symbol 不会进行隐式类型转换
10n == 10 true BigIntNumber 比较,数学值相同
0n == false true false 转换为 Number 0。然后 0n == 0,数学值相同
2n == true false true 转换为 Number 1。然后 2n == 1,数学值不同

8. 何时使用 ==,何时使用 ===,以及 Object.is()

通过前面的详细分析,我们已经清楚地看到==操作符的复杂性和潜在的陷阱。因此,在绝大多数情况下,我们强烈推荐使用===(严格相等)

8.1. ===:最佳实践

  • 优点:行为可预测,不进行隐式类型转换,代码更清晰,更少出错。
  • 适用场景:几乎所有需要比较值的场合。
    
    let a = 10;
    let b = "10";

console.log(a == b); // true (类型转换)
console.log(a === b); // false (类型和值都不同)


#### 8.2. `==`:极少数场景

`==`唯一的“合理”使用场景是检查一个变量是否是`null`或`undefined`,因为`null == undefined`为`true`,而`null`和`undefined`不等于其他任何“假值”(如`0`, `''`, `false`)。
```javascript
function checkValue(value) {
  // 使用 == 可以同时检查 null 和 undefined
  if (value == null) {
    console.log("Value is null or undefined");
  } else {
    console.log("Value is defined:", value);
  }
}

checkValue(null);      // Value is null or undefined
checkValue(undefined); // Value is null or undefined
checkValue(0);         // Value is defined: 0
checkValue('');        // Value is defined:
checkValue(false);     // Value is defined: false

即使在这种情况下,很多人也倾向于使用value === null || value === undefined以保持一致的严格性,或者使用可选链操作符等现代语法糖。

8.3. Object.is():更严格的比较

Object.is()提供了比===更严格的相等判断,它在两个方面与===不同:

  1. NaNObject.is(NaN, NaN)返回true,而NaN === NaN返回false
  2. +0-0Object.is(+0, -0)返回false,而+0 === -0返回true

在其他所有情况下,Object.is()的行为与===相同。

console.log(1 === 1);         // true
console.log(Object.is(1, 1)); // true

console.log("hello" === "hello");         // true
console.log(Object.is("hello", "hello")); // true

console.log(null === undefined);         // false
console.log(Object.is(null, undefined)); // false

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

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

Object.is()适用于需要精确区分NaN和正负零的场景,例如在某些数据结构中对键进行比较时。

8.4. 三种相等比较的对比表

特性 / 运算符 == (抽象相等) === (严格相等) Object.is()
类型转换
NaN 行为 NaN == NaN -> false NaN === NaN -> false Object.is(NaN, NaN) -> true
+0 / -0 行为 +0 == -0 -> true +0 === -0 -> true Object.is(+0, -0) -> false
null / undefined null == undefined -> true null === undefined -> false Object.is(null, undefined) -> false
推荐使用 极少(仅value == null 绝大多数情况 需要区分NaN+0/-0的特殊情况

9. 结论

通过本讲座,我们深入剖析了JavaScript中抽象相等比较(==)算法的内部机制。我们了解了当操作数类型相同时的直接比较规则,以及当类型不同时,JavaScript如何根据一系列复杂的规则进行隐式类型转换(Type Coercion)。从NullUndefined的特殊关系,到NumberString的相互转换,再到Boolean的数值化,以及Object通过ToPrimitive操作转换为原始值,每一个环节都充满了细致的逻辑。

理解==的复杂性不仅能帮助我们避免潜在的错误,还能更深刻地体会JavaScript在设计之初为“宽容”和“灵活性”所做的权衡。尽管在现代JavaScript开发中,严格相等===因其可预测性而成为首选,但作为一名专业的开发者,掌握==的内部工作原理,无疑是构建对JavaScript语言全面而精准认知的基石。它提醒我们,在编写代码时,始终要警惕类型转换可能带来的意想不到的行为,并选择最适合当前语境的比较策略。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注