JavaScript 的相等比較:==、=== 和 Object.is()

JavaScript 的相等比較:==、=== 和 Object.is()

·

6 min read

在 JavaScript 中想判斷變數或對象是否相等有以下三種方法:

  • 一般相等(==)

  • 嚴格相等(===)

  • Object.is() 方法

前兩個比較運算子 ===== 都可以拿來判斷比較對象是否相等,不過兩者的差別究竟在哪裡?又為什麼要這樣設計?是我一開始學習 JavaScript 感到有點混淆的地方,也是滿常見的面試考題。

===== 的差別

「大部分情況下不建議使用 ==,應該使用 ===

初學 JavaScript 的我們通常會直接看到這樣的結論,但這是為什麼呢?簡單來說 == 不會進行型別檢查只會比較值,=== 則會同時檢查型別與值。

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

看到這邊可能會覺得 == 不用管型別簡單明瞭又方便!但其實會因為型別轉型導致出乎意料的結果,即使是 === 也有一些特殊的例外狀況需要使用 Object.is() 來解決。

== 等於(Equal) 與 != 不等於

== 等於(Equal)也稱作「鬆散/寬鬆相等(Loose Equal)」(相較於「嚴格相等」來說)。由於 JavaScript 是個動態型別語言,因此可以允許不同型別的值做比較。此外, == 也具有「對稱性(symmetric)」, A == B 的意思與 B == A 是相同的。

遇到不同型別的運算元時,會先自動轉型(type conversion)再比較內容

console.log("1" = 1); //true
console.log(true = 1); // true

不必管型別使用起來看起來方便許多,但⋯⋯事情真的不是憨人所想得這麼簡單!

來看看這幾個有點 tricky 的例子:

console.log(-0 == +0);
console.log(null == undefined);
console.log([1,2] == "1,2");
console.log([] == false);
console.log([0] == false);
console.log("\n" == false);
console.log("0XA19" == 2585);

以上的結果竟然都是 true!

到這邊你可能會跟我一樣頭痛。原來不同型別的值經過 JavaScript 的隱式轉型(Implicit Type Coercion) 反而會有預期之外的結果。

這也是為什麼通常來說更建議使用 ===。(是不是突然又覺得 === 比較簡單了…)

以下來做個簡單的分類:

同型別之間的比較

如果 Type(x)Type(y) 相同,執行的結果就跟使用嚴格相等 x === y 一樣。

  • String: 兩個 string 每一個字元跟順序都相同時才是 true

  • Number: 兩個 number 的值相同時才是 true

    • +0-0== 被視為相等、在 === 視為不相等

    • 只要任一運算元是 NaN 就是 false,NaN 不等於任何值(包含自己)(Ref: Javascript - NaN(Not a Number)

  • Boolean: 運算元都是 true 或都是 false 時才是 true

  • BigInt:兩個 BigInt 都有相同的值時才是 true

  • Symbol: 兩個 symbol 都引用相同的 symbol 值時才是 true

  • Object: 兩個 object 都指向相同的位置時才是 true

要注意,JavaScript 的比較也會因為是不是原始型別(primitive type) 而有所差異。非原始型別(像是 object、array 或 class)比較的基準會是看他們是否指向同一個參考(reference),而不是比較他們的值(value)

let obj1 = { name: 'John' }
let obj2 = { name: 'John' }

let array1 = [1,2,3]
let array2 = [1,2,3]

console.log(obj1 == obj2); // false
console.log(array1 == array2); // false

這部分在下方 Reference vs Value 會有進一步的說明。

string 或 boolean 會轉為 number

string 或 boolean 被拿來跟 number 比較時,會先透過 Number() 轉為 number,再跟另一個 number 比較。

string 與 number 比較

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

上面這幾個例子可以看到:

  1. "" 被自動轉型成 0,因此 "" == 0 可以看作是 0 == 0 ,兩值相等所以回傳 true

  2. " " 即使包含了空白字元,但經過 Number(" ")自動轉型仍是 0 ,因此也會回傳 true

  3. """ " 兩者型別相同(都是 string) 因此不需要轉型、直接比較內容,內容並不相等所以回傳 false。

boolean 與 number 比較

console.log(true == 1); // true
console.log(false == 0); // true
  • true 被 Number(true) 轉成 1

  • false 被 Number(false) 轉成 0

string 與 boolean 比較

string 與 boolean 都會先轉為 number 再作比較。

console.log('0' == false); // true
  1. '0' 字串透過 Number('0') 轉為數字 0

  2. false 布林值透過 Number(false) 轉為數字 0

  3. '0' == false 經過轉型後變成 0 == 0 ,兩值相同回傳 true

console.log('true' == true); // false
  1. 'true' 字串透過 Number('true') 因為無法轉型成數字因此結果是 NaN

  2. true 布林值透過 Number(true) 轉為數字 1

  3. 'true' == true 經過轉型後變成 NaN == 1 --> 只要出現 NaN 就回傳 false

null、undefined 的比較

運算元是 nullundefined 時的規則如下:

當其中一個運算元是 nullundefined,另一個運算元也必須是 nullundefined 才會回傳 true,否則一律回傳 false。

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

object 與 non-object 的比較

console.log([] == 0); // true
console.log([1] == 1); // true
console.log([] == true); // false ([] 物件透過 .toString() 被轉為 '',再透過 Boolean('') 轉為 false)

上述的例子們拆解後是這樣的:

  1. [] == 0 轉型後 => 0 == 0 => true

    • 左邊的物件 [] 透過 .toString() 被轉為空字串 '',再透過 Number('') 轉為數字 0
  2. [1] == 1 轉型後 => 1 == 1 => true

    • 左邊的物件 [1] 透過 .toString() 被轉為字串 '1',再透過 Number(1) 轉為數字 1
  3. [] == true 轉型後 => 0 == 1 => false

    • 左邊的物件 [] 透過 .toString() 被轉為空字串 '',再透過 Number('') 轉為數字 0

    • 右邊的布林值 true 透過 Number(true) 被轉為數字 1

{} 的比較會回傳 error

console.log({} == true) 
// Uncaught SyntaxError: Unexpected token '=='

{} 不能被拿來比對,因為 {} 不代表物件,而是代表一個區塊程式碼。區塊程式碼屬於陳述式,不能拿來做比對。

關於物件被轉型成基本型態,這邊節錄 MDN 裡的說明

Objects are converted to primitives by calling its @@toPrimitive (with “default” as hint), valueOf(), and toString() methods, in that order.

For valueOf() and toString(), if one returns an object, the return value is ignored and the other’s return value is used instead; if neither is present, or neither returns a primitive, a TypeError is thrown.

我們可以得知物件轉換成基本型態的步驟為:先 valueOf() → 返回 object → 再 toString()

== 的隱式轉型(Implicit Type Coercion)

關於 Javascript 的轉型(Coercion)一查下去又是另一個大坑,決定另起一篇來談。Javascript 的自動轉型(Coercion)

不過這邊找到這個滿有趣的網站,詳細展示了使用 == 時 JavaScript 是如何 step by step 針對 x == y 進行自動轉型與判斷:https://felix-kling.de/js-loose-comparison/

這邊嘗試當個翻譯蒟蒻並歸納網站的說明:

  1. 判斷型別:Type(x)Type(y) 是否相同 --> 若型別相同則回傳嚴格比較的結果: x === y

  2. 判斷 nullundefined

    • 如果 x 是 null、 y 是 undefined,回傳 true

    • 如果 x 是 undefined、 y 是 null,回傳 true

  3. 字串或布林值 vs 數字:如果是字串或布林值與數字相比較,會先透過 ToNumber() 將字串或布林值轉型成數字

  4. 物件 vs 與非物件(字串、數字、Symbol):如果是物件(object)與非物件相比較,會先將物件透過 ToPrimitive()將物件轉型成基本型態

  5. 回傳 false

=== 嚴格等於(Strict Equal)與 !== 嚴格不等於

顧名思義相較於寬鬆等於(==)會更嚴謹,===先比較型別、再判斷內容是否相等。上面提到幾個在 == 下會回傳 true 的例子,改用 === 後結果都是 false:

console.log("" === 0); // false
console.log(true === 1); // false
console.log(false === 0); // false
console.log(null === undefined); // false
console.log([1] === 1); // false

=== 的例外情況

BUT! 人生中最厲害就是這個 BUT! 其實 === 有兩種特殊的例外情況:

  • 比較 +0-0 時,會回傳 true

  • NaN 不等於任何值(包含自己),因此 NaN === NaN 會是 false(關於 NaN 的詳細說明:NaN (Not a Number)

+0 === -0; // true
NaN === NaN; // false

Object.is() 同值比較 (same-value equality)

上述的特殊狀況可以在 ES6 改用 Object.is() 方法來解決。

Object.is(value, value)

Object.is()=== 唯一的差別只在於處理帶符號的 0 (例如 +0-0)以及 NaN 值的時候。

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

Object.is() 在以下幾種情況成立時會返回 true:

  • 都是 undefined

  • 都是 null

  • 都是 true 或都是 false

  • 都是長度相同、字符相同、順序相同的字串

  • 都指向相同的位置

  • 都是 BigInt 且具有相同的數值

  • 都是 symbol 且引用相同的 symbol 值

  • 都是數字且

    • 都是 +0 或都是 -0

    • 都是 NaN

    • 都有相同的值,並且非 0 也非 NaN

零值相等

  1. null 和 undefined

     null == undefined // true
     null === undefined // false
     Object.is(null, undefined) // false
    
  2. NaN

     NaN == NaN // false
     NaN === NaN // false
     Object.is(NaN, NaN) // true
    
  3. 0 和帶符號的 0(+0 或 -0)

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

Reference vs Value

除了轉不轉型,這邊還想要特別補充 JavaScript 的比較基準也會因為型別不同而有所差異。

如果運算元是一般的原始型別(primitive type),例如字串或數字,就是直接比較它們的值是否相等;如果運算元是陣列(array)、物件(object)或 class 等非原始型別時 ,那麼比較的基準就會是看他們是否指向同一個參考(reference),而不是比較他們的值(value)

// 運算元是陣列
var a = [1,2,3,4,5]
var b = [1,2,3,4,5]
var c = a

console.log(a == b) // false
console.log(a === b) // false
console.log(Object.is(a, b)) // false
console.log(a === c) // true

// 運算元是物件
var obj1 = { name: 'John', age: 18 }
var obj2 = { name: 'John', age: 18 }
var obj3 = obj1

console.log(obj1 == obj2) // false
console.log(obj1 === obj2) // false
console.log(Object.is(obj1, obj2)) // false
console.log(obj1 === obj3) // true

內容一模一樣的 array 跟 object 不管用 ===== 還是 Object.is() 統統都是 false。

只有在兩者指向同一位置的時候(var c = aobj1 = obj3),才會讓兩者在進行相等比較的時候是 true。

為什麼不是根據內容來判斷是否相等呢?以下直接引用我覺得講得很清楚的這篇「我知道 == 與 === 不同,但為什麼要這樣設計?」的內容。

先定義所謂的「相等性」:

在程式語言當中,相等性通常可以分為三種:

  1. Reference:兩個值指向的指標相同

  2. Shallow Compare:兩個物件的 attributes 長度以及名稱相同,且屬性的 reference 也相同。

  3. Deep Equal:兩個物件的 attibutes 長度、名稱相同,值也相同,如果有巢狀結構,會遞迴持續比較。

如果不用指標、而是想要進一步比較物件裡面的內容,需要定義要比較到「多深」(物件可能有好幾層,也可能遇到 circular reference 的問題),是要 shallow compare 還是要 deep equal?Deep equal 又可能會遇到效能問題。

因此:

如果不用指標(reference)來判斷相等性,程式語言不知道要怎麼幫你判斷兩個物件是否相等

最簡單也最有效的方式就是直接用 reference 判斷

至於要如何做 Shallow Compare 或 Deep Equal 呢?一般來說會自己寫函式來定義到底要比較到多深,或使用 JSON.stringify() 來比較。(延伸閱讀:How to compare objects in JavaScript?

補充

Ref