相等性比較和同值

JavaScript 提供了三種不同的值比較操作

  • === — 嚴格相等(三等號)
  • == — 寬鬆相等(雙等號)
  • Object.is()

選擇哪種操作取決於你希望執行的比較型別。簡而言之

  • 雙等號(==)在比較兩個值時會執行型別轉換,並特殊處理 NaN-0+0 以符合 IEEE 754 規範(因此 NaN != NaN,且 -0 == +0);
  • 三等號(===)會執行與雙等號相同的比較(包括對 NaN-0+0 的特殊處理),但**不**進行型別轉換;如果型別不同,則返回 false
  • Object.is() 不進行型別轉換,也不特殊處理 NaN-0+0(這使得它的行為與 === 相同,除了在這些特殊的數值上)。

它們對應於 JavaScript 中的四種相等演算法中的三種

請注意,它們之間的區別都與它們對原始值的處理有關;它們都沒有比較引數在結構上是否概念相似。對於任何具有相同結構但本身是不同物件的非原始物件 xy,上述所有形式都將評估為 false

使用 === 的嚴格相等

嚴格相等比較兩個值的相等性。在比較之前,任何值都不會隱式轉換為其他值。如果值具有不同的型別,則認為這些值不相等。如果值具有相同的型別,不是數字,並且具有相同的值,則認為它們相等。最後,如果兩個值都是數字,則如果它們都非 NaN 且值相同,或者其中一個是 +0 而另一個是 -0,則認為它們相等。

js
const num = 0;
const obj = new String("0");
const str = "0";

console.log(num === num); // true
console.log(obj === obj); // true
console.log(str === str); // true

console.log(num === obj); // false
console.log(num === str); // false
console.log(obj === str); // false
console.log(null === undefined); // false
console.log(obj === null); // false
console.log(obj === undefined); // false

嚴格相等幾乎總是正確的比較操作。對於除數字以外的所有值,它使用顯而易見的語義:一個值只等於它本身。對於數字,它使用略微不同的語義來掩蓋兩個不同的邊緣情況。第一個是浮點零可以是正號或負號。這在表示某些數學解決方案時很有用,但由於大多數情況不關心 +0-0 之間的差異,因此嚴格相等將它們視為相同的值。第二個是浮點包含一個非數字值 NaN 的概念,用於表示某些不明確的數學問題的解決方案:例如,負無窮大加上正無窮大。嚴格相等將 NaN 視為不等於任何其他值——包括它本身。(唯一 (x !== x)true 的情況是 xNaN。)

除了 ===,嚴格相等還被陣列索引查詢方法使用,包括 Array.prototype.indexOf()Array.prototype.lastIndexOf()TypedArray.prototype.indexOf()TypedArray.prototype.lastIndexOf(),以及 case 匹配。這意味著你不能使用 indexOf(NaN) 來查詢陣列中 NaN 值的索引,也不能在 switch 語句中使用 NaN 作為 case 值並使其匹配任何內容。

js
console.log([NaN].indexOf(NaN)); // -1
switch (NaN) {
  case NaN:
    console.log("Surprise"); // Nothing is logged
}

使用 == 的寬鬆相等

寬鬆相等是_對稱的_:對於任何值 ABA == B 總是與 B == A 具有相同的語義(除了應用轉換的順序)。使用 == 執行寬鬆相等的行為如下

  1. 如果運算元具有相同的型別,則按如下方式進行比較
    • 物件:僅當兩個運算元引用同一物件時返回 true
    • 字串:僅當兩個運算元具有相同字元且順序相同時返回 true
    • 數字:僅當兩個運算元具有相同的值時返回 true+0-0 被視為相同的值。如果任一運算元為 NaN,則返回 false;因此 NaN 永遠不等於 NaN
    • 布林值:僅當兩個運算元都為 true 或都為 false 時返回 true
    • BigInt:僅當兩個運算元具有相同的值時返回 true
    • Symbol:僅當兩個運算元引用相同的 Symbol 時返回 true
  2. 如果其中一個運算元為 nullundefined,則另一個也必須為 nullundefined 才能返回 true。否則返回 false
  3. 如果其中一個運算元是物件而另一個是原始值,則將物件轉換為原始值
  4. 在此步驟中,兩個運算元都轉換為原始值(字串、數字、布林值、Symbol 和 BigInt 之一)。其餘的轉換逐個進行。
    • 如果它們是相同的型別,則使用步驟 1 進行比較。
    • 如果其中一個運算元是 Symbol 而另一個不是,則返回 false
    • 如果其中一個運算元是布林值而另一個不是,則將布林值轉換為數字true 轉換為 1,false 轉換為 0。然後再次寬鬆比較這兩個運算元。
    • 數字到字串:將字串轉換為數字。轉換失敗會導致 NaN,這將保證相等性為 false
    • 數字到 BigInt:根據其數學值進行比較。如果數字是 ±Infinity 或 NaN,則返回 false
    • 字串到 BigInt:使用與 BigInt() 建構函式相同的演算法將字串轉換為 BigInt。如果轉換失敗,則返回 false

傳統上,根據 ECMAScript,所有原始值和物件都與 undefinednull 寬鬆不相等。但是大多數瀏覽器允許在某些上下文中,非常狹窄的一類物件(特別是任何頁面的 document.all 物件)表現得彷彿它們_模擬_值 undefined。寬鬆相等就是這樣一種上下文:如果且僅當 A 是一個_模擬_ undefined 的物件時,null == Aundefined == A 才評估為 true。在所有其他情況下,物件永遠不與 undefinednull 寬鬆相等。

在大多數情況下,不建議使用寬鬆相等。使用嚴格相等進行比較的結果更容易預測,並且由於沒有型別強制轉換,因此計算速度可能更快。

以下示例演示了涉及數字原始值 0、BigInt 原始值 0n、字串原始值 '0' 以及其 toString() 值為 '0' 的物件的寬鬆相等比較。

js
const num = 0;
const big = 0n;
const str = "0";
const obj = new String("0");

console.log(num == str); // true
console.log(big == num); // true
console.log(str == big); // true

console.log(num == obj); // true
console.log(big == obj); // true
console.log(str == obj); // true

寬鬆相等僅由 == 運算子使用。

使用 Object.is() 的同值相等

同值相等確定兩個值在所有上下文中是否_功能上相同_。(此用例演示了Liskov 替換原則的一個例項。)一個例子發生在嘗試修改不可變屬性時

js
// Add an immutable NEGATIVE_ZERO property to the Number constructor.
Object.defineProperty(Number, "NEGATIVE_ZERO", {
  value: -0,
  writable: false,
  configurable: false,
  enumerable: false,
});

function attemptMutation(v) {
  Object.defineProperty(Number, "NEGATIVE_ZERO", { value: v });
}

Object.defineProperty 在嘗試更改不可變屬性時會丟擲異常,但如果未請求實際更改,它不會執行任何操作。如果 v-0,則未請求更改,也不會丟擲錯誤。在內部,當重新定義不可變屬性時,新指定的值會使用同值相等與當前值進行比較。

同值相等由 Object.is 方法提供。它在語言中幾乎所有期望等效標識值的地方都使用。

同值零相等

類似於同值相等,但 +0 和 -0 被認為是相等的。

同值零相等不作為 JavaScript API 公開,但可以透過自定義程式碼實現

js
function sameValueZero(x, y) {
  if (typeof x === "number" && typeof y === "number") {
    // x and y are equal (may be -0 and 0) or they are both NaN
    return x === y || (x !== x && y !== y);
  }
  return x === y;
}

同值零僅在將 NaN 視為等效時與嚴格相等不同,並且僅在將 -0 視為等效於 0 時與同值相等不同。這使得它在搜尋時通常具有最合理的行為,尤其是在處理 NaN 時。它被 Array.prototype.includes()TypedArray.prototype.includes() 以及 MapSet 方法用於比較鍵的相等性。

比較相等方法

人們經常透過說其中一個是另一個的“增強”版本來比較雙等號和三等號。例如,雙等號可以被認為是三等號的擴充套件版本,因為前者執行後者所做的一切,但對其運算元進行型別轉換——例如,6 == "6"。或者,可以聲稱雙等號是基準,而三等號是增強版本,因為它要求兩個運算元是相同的型別,因此它增加了一個額外的約束。

然而,這種思考方式意味著相等比較構成了一個一維的“光譜”,其中“完全嚴格”位於一端,“完全寬鬆”位於另一端。這個模型在 Object.is 上失效了,因為它既不比雙等號“寬鬆”,也不比三等號“嚴格”,也不適合介於兩者之間(即,既比雙等號嚴格,又比三等號寬鬆)。我們可以從下面的同值比較表中看出,這是由於 Object.is 處理 NaN 的方式。請注意,如果 Object.is(NaN, NaN) 評估為 false,我們_可以_說它符合寬鬆/嚴格光譜,作為三等號的一種更嚴格的形式,它區分 -0+0。然而,對 NaN 的處理意味著這不正確。不幸的是,Object.is 必須根據其特定特徵來思考,而不是其相對於相等運算子的寬鬆或嚴格程度。

x y == === Object.is SameValueZero
undefined undefined ✅ true ✅ true ✅ true ✅ true
null null ✅ true ✅ true ✅ true ✅ true
true true ✅ true ✅ true ✅ true ✅ true
false false ✅ true ✅ true ✅ true ✅ true
'foo' 'foo' ✅ true ✅ true ✅ true ✅ true
0 0 ✅ true ✅ true ✅ true ✅ true
+0 -0 ✅ true ✅ true ❌ false ✅ true
+0 0 ✅ true ✅ true ✅ true ✅ true
-0 0 ✅ true ✅ true ❌ false ✅ true
0n -0n ✅ true ✅ true ✅ true ✅ true
0 false ✅ true ❌ false ❌ false ❌ false
"" false ✅ true ❌ false ❌ false ❌ false
"" 0 ✅ true ❌ false ❌ false ❌ false
'0' 0 ✅ true ❌ false ❌ false ❌ false
'17' 17 ✅ true ❌ false ❌ false ❌ false
[1, 2] '1,2' ✅ true ❌ false ❌ false ❌ false
new String('foo') 'foo' ✅ true ❌ false ❌ false ❌ false
null undefined ✅ true ❌ false ❌ false ❌ false
null false ❌ false ❌ false ❌ false ❌ false
undefined false ❌ false ❌ false ❌ false ❌ false
{ foo: 'bar' } { foo: 'bar' } ❌ false ❌ false ❌ false ❌ false
new String('foo') new String('foo') ❌ false ❌ false ❌ false ❌ false
0 null ❌ false ❌ false ❌ false ❌ false
0 NaN ❌ false ❌ false ❌ false ❌ false
'foo' NaN ❌ false ❌ false ❌ false ❌ false
NaN NaN ❌ false ❌ false ✅ true ✅ true

何時使用 Object.is() 而不是三等號

通常,Object.is 對零的特殊行為可能引起興趣的唯一情況是,在某些超程式設計方案中,特別是在屬性描述符方面,當你的工作希望反映 Object.defineProperty 的某些特徵時。如果你的用例不需要這個,建議避免使用 Object.is,而改用 ===。即使你的要求涉及將兩個 NaN 值之間的比較評估為 true,通常也更容易對 NaN 檢查進行特殊處理(使用早期 ECMAScript 版本中可用的 isNaN 方法),而不是弄清楚周圍的計算可能如何影響你在比較中遇到的任何零的符號。

以下是內建方法和運算子的非詳盡列表,它們可能導致 -0+0 之間的區別在你的程式碼中體現出來

- (一元取反)

考慮以下示例

js
const stoppingForce = obj.mass * -obj.velocity;

如果 obj.velocity0(或計算為 0),則會在該位置引入一個 -0 並傳播到 stoppingForce

Math.atan2Math.ceilMath.powMath.round

在某些情況下,即使引數中不存在 -0,這些方法也可能返回 -0。例如,使用 Math.pow-Infinity 提升到任何負奇數指數會評估為 -0。請參閱各個方法的文件。

Math.floorMath.maxMath.minMath.sinMath.sqrtMath.tan

在某些情況下,如果引數中存在 -0,這些方法可能會返回 -0。例如,Math.min(-0, +0) 評估為 -0。請參閱各個方法的文件。

~, <<, >>

這些運算子中的每一個都在內部使用 ToInt32 演算法。由於內部 32 位整數型別中零隻有一個表示,因此 -0 在反向操作後無法存活往返。例如,Object.is(~~(-0), -0)Object.is(-0 << 2 >> 2, -0) 都評估為 false

當不考慮零的符號時,依賴 Object.is 可能很危險。當然,當目的是區分 -0+0 時,它確實能實現所需的功能。

注意事項:Object.is() 和 NaN

Object.is 規範將 NaN 的所有例項視為同一物件。然而,由於 型別化陣列 可用,我們可以擁有 NaN 的不同浮點表示,它們在所有上下文中不表現相同。例如

js
const f2b = (x) => new Uint8Array(new Float64Array([x]).buffer);
const b2f = (x) => new Float64Array(x.buffer)[0];
// Get a byte representation of NaN
const n = f2b(NaN);
// Change the first bit, which is the sign bit and doesn't matter for NaN
n[0] = 1;
const nan2 = b2f(n);
console.log(nan2); // NaN
console.log(Object.is(nan2, NaN)); // true
console.log(f2b(NaN)); // Uint8Array(8) [0, 0, 0, 0, 0, 0, 248, 127]
console.log(f2b(nan2)); // Uint8Array(8) [1, 0, 0, 0, 0, 0, 248, 127]

另見