原文:http://2ality.com/2012/01/object-plus-object.html
譯文:https://justjavac.com/javascript/2012/12/20/object-plus-object.html
最近,Gary Bernhardt 在一個簡短的演講視頻“Wat”中指出了一個有趣的 JavaScript 怪癖: 在把對象和數組混合相加時,會得到一些意想不到的結果。 本篇文章會依次講解這些計算結果是如何得出的。
在 JavaScript 中,加法的規則其實很簡單,只有兩種情況:
-
把數字和數字相加
-
把字符串和字符串相加
所有其他類型的值都會被自動轉換成這兩種類型的值。 爲了能夠弄明白這種隱式轉換是如何進行的,我們首先需要搞懂一些基礎知識。
注意:在下面的文章中提到某一章節的時候(比如§9.1),指的都是 ECMA-262 語言規範(ECMAScript 5.1)中的章節。
讓我們快速的複習一下。 在 JavaScript 中,一共有兩種類型的值:
-
原始值(primitives)
-
undefined
-
null
-
boolean
-
number
-
string
-
-
對象值(objects)。
除了原始值外,其他的所有值都是對象類型的值,包括數組(array)和函數(function)。
類型轉換
加法運算符會觸發三種類型轉換:
-
轉換爲原始值
-
轉換爲數字
-
轉換爲字符串
通過 ToPrimitive() 將值轉換爲原始值
JavaScript 引擎內部的抽象操作 ToPrimitive()
有着這樣的簽名:
ToPrimitive(input,PreferredType?)
可選參數 PreferredType
可以是 Number
或者 String
。 它只代表了一個轉換的偏好,轉換結果不一定必須是這個參數所指的類型(汗),但轉換結果一定是一個原始值。 如果 PreferredType
被標誌爲 Number
,則會進行下面的操作來轉換 input
(§9.1):
-
如果
input
是個原始值,則直接返回它。 -
否則,如果
input
是一個對象。則調用obj.valueOf()
方法。 如果返回值是一個原始值,則返回這個原始值。 -
否則,調用
obj.toString()
方法。 如果返回值是一個原始值,則返回這個原始值。 -
否則,拋出
TypeError
異常。
如果 PreferredType
被標誌爲 String
,則轉換操作的第二步和第三步的順序會調換。 如果沒有 PreferredType
這個參數,則 PreferredType
的值會按照這樣的規則來自動設置:
-
Date
類型的對象會被設置爲String
, -
其它類型的值會被設置爲
Number
。
通過 ToNumber() 將值轉換爲數字
下面的表格解釋了 ToNumber()
是如何將原始值轉換成數字的 (§9.3)。
參數 | 結果 |
---|---|
undefined | NaN |
null | +0 |
boolean | true被轉換爲1,false轉換爲+0 |
number | 無需轉換 |
string | 由字符串解析爲數字。例如,"324"被轉換爲324 |
如果輸入的值是一個對象,則會首先會調用 ToPrimitive(obj, Number)
將該對象轉換爲原始值, 然後在調用 ToNumber()
將這個原始值轉換爲數字。
通過ToString()將值轉換爲字符串
下面的表格解釋了 ToString()
是如何將原始值轉換成字符串的(§9.8)。
參數 | 結果 |
---|---|
undefined | "undefined" |
null | "null" |
boolean | "true" 或者 "false" |
number | 數字作爲字符串。比如,"1.765" |
string | 無需轉換 |
如果輸入的值是一個對象,則會首先會調用 ToPrimitive(obj, String)
將該對象轉換爲原始值, 然後再調用 ToString()
將這個原始值轉換爲字符串。
實踐一下
下面的對象可以讓你看到引擎內部的轉換過程。
var obj = {
valueOf: function () {
console.log("valueOf");
return {}; // not a primitive
},
toString: function () {
console.log("toString");
return {}; // not a primitive
}
}
Number
作爲一個函數被調用(而不是作爲構造函數調用)時,會在引擎內部調用 ToNumber()
操作:
> Number(obj)
valueOf
toString
TypeError: Cannot convert object to primitive value
加法
有下面這樣的一個加法操作。
value1 + value2
在計算這個表達式時,內部的操作步驟是這樣的 (§11.6.1):
-
將兩個操作數轉換爲原始值 (以下是數學表示法的僞代碼,不是可以運行的 JavaScript 代碼):
prim1 := ToPrimitive(value1) prim2 := ToPrimitive(value2)
PreferredType
被省略,因此Date
類型的值採用String
,其他類型的值採用Number
。 -
如果 prim1 或者 prim2 中的任意一個爲字符串,則將另外一個也轉換成字符串,然後返回兩個字符串連接操作後的結果。
-
否則,將 prim1 和 prim2 都轉換爲數字類型,返回他們的和。
預料到的結果
當你將兩個數組相加時,結果正是我們期望的:
> [] + []
''
[]
被轉換成一個原始值:首先嚐試 valueOf()
方法,該方法返回數組本身(this
):
> var arr = [];
> arr.valueOf() === arr
true
此時結果不是原始值,所以再調用 toString()
方法,返回一個空字符串(string
是原始值)。 因此,[] + []
的結果實際上是兩個空字符串的連接。
將一個數組和一個對象相加,結果依然符合我們的期望:
> [] + {}
'[object Object]'
解析:將空對象轉換成字符串時,產生如下結果。
> String({})
'[object Object]'
所以最終的結果其實是把 ""
和 "[object Object]"
兩個字符串連接起來。
更多的對象轉換爲原始值的例子:
> 5 + new Number(7)
12
> 6 + { valueOf: function () { return 2 } }
8
> "abc" + { toString: function () { return "def" } }
'abcdef'
意想不到的結果
如果 +
加法運算的第一個操作數是個空對象字面量,則會出現詭異的結果(Firefox console 中的運行結果):
> {} + {}
NaN
天哪!神馬情況?(譯註:原文沒有,是我第一次讀到這兒的時候感到太吃驚了,翻譯的時候加入的。@justjavac) 這個問題的原因是,JavaScript 把第一個 {}
解釋成了一個空的代碼塊(code block)並忽略了它。 NaN
其實是表達式 +{}
計算的結果 (+
加號以及第二個 {}
)。 你在這裏看到的 +
加號並不是二元運算符「加法」,而是一個一元運算符,作用是將它後面的操作數轉換成數字,和 Number()
函數完全一樣。例如:
> +"3.65"
3.65
以下的表達式是它的等價形式:
+{}
Number({})
Number({}.toString()) // {}.valueOf() isn’t primitive
Number("[object Object]")
NaN
爲什麼第一個 {}
會被解析成代碼塊(code block)呢? 因爲整個輸入被解析成了一個語句:如果左大括號出現在一條語句的開頭,則這個左大括號會被解析成一個代碼塊的開始。 所以,你也可以通過強制把輸入解析成一個表達式來修復這樣的計算結果: (譯註:我們期待它是個表達式,結果卻被解析成了語句,表達式和語句的區別可以查看我以前的『代碼之謎』系列的 語句與表達式。 @justjavac)
> ({} + {})
'[object Object][object Object]'
一個函數或方法的參數也會被解析成一個表達式:
> console.log({} + {})
[object Object][object Object]
經過前面的講解,對於下面這樣的計算結果,你也應該不會感到吃驚了:
> {} + []
0
在解釋一次,上面的輸入被解析成了一個代碼塊後跟一個表達式 +[]
。 轉換的步驟是這樣的:
+[]
Number([])
Number([].toString()) // [].valueOf() isn’t primitive
Number("")
0
有趣的是,Node.js 的 REPL 在解析類似的輸入時,與 Firefox 和 Chrome(和Node.js 一樣使用 V8 引擎) 的解析結果不同。 下面的輸入會被解析成一個表達式,結果更符合我們的預料:
> {} + {}
'[object Object][object Object]'
> {} + []
'[object Object]'
3. 這就是所有嗎?
在大多數情況下,想要弄明白 JavaScript 中的 +
號是如何工作的並不難:你只能將數字和數字相加或者字符串和字符串相加。 對象值會被轉換成原始值後再進行計算。如果將多個數組相加,可能會出現你意料之外的結果,相關文章請參考在 javascript 中,爲什麼 [1,2] + [3,4] 不等於 [1,2,3,4]? 和 爲什麼 ++[[]][+[]]+[+[]] = 10?。
如果你想連接多個數組,需要使用數組的 concat 方法:
> [1, 2].concat([3, 4])
[1, 2, 3, 4]
JavaScript 中沒有內置的方法來“連接” (合併)多個對象。 你可以使用一個 JavaScript 庫,比如 Underscore:
> var o1 = {eeny:1, meeny:2};
> var o2 = {miny:3, moe: 4};
> _.extend(o1, o2)
{eeny: 1, meeny: 2, miny: 3, moe: 4}
注意:和 Array.prototype.concat()
方法不同,extend()
方法會修改它的第一個參數,而不是返回合併後的對象:
> o1
{eeny: 1, meeny: 2, miny: 3, moe: 4}
> o2
{miny: 3, moe: 4}
如果你想了解更多有趣的關於運算符的知識,你可以閱讀一下 “Fake operator overloading in JavaScript”(中文正在翻譯中)。