初學 JavaScript 時,總會聽人說:「比較時記得用三等號(===),不要用雙等號(==)」。為什麼?稍有經驗的開發者馬上會想到——因為雙等號比較時 JavaScript 會做自動轉型。那麼自動轉型是怎麼轉的?背後機制是什麼?本章揭開弱型別 JavaScript 自動轉型的神秘面紗。

相等性#

在 JavaScript 中有數種比較運算子,比較特別的是這邊有兩種相等運算子:一般相等嚴格相等,也就是雙等號及三等號。

嚴格相等#

先從較嚴謹的嚴格相等開始看起。既然稱為嚴格相等,被比較的雙方除了值相等之外,兩者的型別也必須要相等,比較的結果才會是 true

"foo" === "foo"; // true
123 === 123; // true
123 === "123"; // false

另外補充,number 型別中的 0-0,在嚴格比較時會被認為是相等的。

一般相等#

也有人稱為寬鬆相等。與嚴格相等相比,會先將比較的雙方自動轉型成同型別的數值,隨後再開始比較,只要「值」一樣便會回傳 true 的結果:

"foo" == "foo"; // true
123 == 123; // true
123 == "123"; // true

false == ""; // true
false == "0"; // true
"" == "0"; // false

這裡提到的「自動轉型」,也就是 JavaScript 的判斷句讓許多開發者感到無比困惑,並建議大家避免使用一般相等的原因了。

物件的相等#

前述的比較範例中都沒有使用物件,是因為 JavaScript 對物件做相等性比較時,由於物件類型的變數儲存的其實是「記憶體位置」,彼此在比較相等性時,需要指向同一個物件才會得到 true 的比較結果:

let a = {};
let b = {};

a === b; // false

let c = {};
let d = c;

c === d; // true

如果是想要判斷兩物件的屬性是否全部相等,開發者們就必須自行另外撰寫判斷程式來處理。

例外的例外#

再補充個例外的例外;沒錯,又是 NaN,它是唯一一個不等於自己的特別數值,無論是嚴格相等或一般相等:

NaN == NaN; // false
NaN === NaN; // false

一般相等的自動轉型#

認識型別及相等性後,我們可以依照 ES6 的規範,仔細瞧瞧在做一般相等的比較時到底會發生什麼事情。

首先可以觀察到第一條規則,如果等號兩邊的型別相同,則回傳嚴格相等的比較結果,也就不會發生自動轉型了。接下來就是基本型別的轉換,最後是物件轉換,如果不符合任何一條規則,就回傳 false

基本型別轉型#

基本型別的轉型比較單純,首先是 nullundefined。在一般相等判斷句中會把 nullundefined 視為相等:

null == undefined; // true

symbol 的話,由於是唯一值,不會被轉型成別的 type。

剩下的 numberstringboolean,自動轉型會嘗試把比較的雙方都透過內部的 ToNumber 函式轉成 number 再做比較,例如剛剛奇怪的例子:

false == ""; // 轉型後為 0 == 0,true
false == "0"; // 轉型後為 0 == 0,true
"" == "0"; // 同型別,false

知道會先被轉成數字後,是不是就沒那麼難懂了。

物件轉型#

接著來看感覺最複雜的物件轉型。當物件與基本型別在做相等性判斷時,JavaScript 引擎會讓物件經過內部函式 ToPrimitive,嘗試將物件透過 valueOftoString 等方法轉成基本型別。

值得注意的是,ToPrimitive 函式除了接收物件參數外,還接收一個額外的「PreferredType」參數,而在進行物件轉型時,JavaScript 會依照前後文的語法,自動設定這個參數。

嘗試轉型的方法是什麼呢?開發者可以透過自訂物件中的 Symbol.toPrimitive 屬性,自訂這個轉型的方法。若沒有設定,則會依據 PreferredType 的設定,決定 valueOftoString 的使用順序,依序嘗試轉型,若三個方法都嘗試後仍得不到基本型別,則拋出型別錯誤。

直接看範例:

let obj = {
  name: "Gary",
  number: 96.3,
  [Symbol.toPrimitive]: function (hint) {
    // hint 為 'string', 'number', 'default' 三者之一
    return hint === "string" ? this.name : this.number;
  },
};
console.log(+obj); // 96.3
console.log(`${obj}`); // Gary

這邊設定了 Symbol.toPrimitive 屬性,藉由判斷參數 hint,修改了物件轉型的結果。

再看一個範例:

let tmp = {
  toString: () => "foo",
  valueOf: () => 123,
};

// default,優先使用 valueOf()
tmp == 123; // true
((((tmp ==
  "foo" + // false
    // number,優先使用 valueOf()
    tmp) ==
  123 + // true
    tmp) ==
  "foo" // string,優先使用 toString() // false
  `${tmp}`) ==
  123 // false
  `${tmp}`) ==
  "foo"; // true

藉由變數前後的程式碼,設定內部的 PreferredType 屬性,決定 valueOftoString 兩者的使用順序,進而改變了相等性判斷的結果。

牛刀小試#

最後來看一個玄妙的題目:

// 請問下列這四行的執行結果會得到什麼?
[] + []
[] + {}
{} + []
{} + {}

第一步先確認 []{}valueOftoString 分別是什麼:

[].valueOf() // [],回傳自己
[].toString() // ""
{}.valueOf() // {},回傳自己
{}.toString() // "[object Object]"

接著一個一個來看:

  • [] + []:由於沒有指定 PreferredType,預設會呼叫 valueOf 方法轉型,但回傳的是本身仍是物件;接著嘗試呼叫 toString 方法進行轉型,得到的是 "" 空字串,兩個空字串相加後仍是空字串。
  • [] + {}:同樣的先嘗試呼叫兩者的 valueOf,都仍然得到物件;接著呼叫 toString,得到 "" + [object Object],最後答案是 [object Object]
  • {} + []:這邊的陷阱題,由於 {} 會被優先解析成空區塊,沒有任何作用,剩下的 + [] 則會被解析成將 [] 強制轉型成數字;首先呼叫 []valueOf,仍為物件;接著呼叫 toString,得到基本型別 ""。這時式子變成 + "",因此得到了 0
  • {} + {}:把兩個物件 toString 後加起來,得到 [object Object][object Object]

最後的答案:

[] + [] // ""
[] + {} // "[object Object]"
{} + [] // 0
{} + {} // "[object Object][object Object]"

結語#

本章從相等性比較出發,一窺 JavaScript 型別轉換的底層機制。雖然切入點是一般相等運算子觸發的自動轉型,但底層處理方式都是相同的內部函式,這些觀念可套用到其他自動轉型運算。

在正式的工作開發中應該要盡可能的避免可能發生的錯誤,因此普遍來說團隊都會要求使用嚴格相等來取代一般相等。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10222508