原文:http://www.2ality.com/2012/01/object-plus-object.html
最近,Gary Bernhardt在一個簡短的演講視頻“Wat”中指出了一個有趣的JavaScript怪癖:在把對象和數組混合相加時,會得到一些你意想不到的結果.本篇文章會依次講解這些計算結果是如何得出的.
在JavaScript中,加法的規則其實很簡單,只有兩種情況:你只能把數字和數字相加,或者字串和字串相加,所有其他類型的值都會被自動轉換成這兩種類型的值. 為了能夠弄明白這種隱式轉換是如何進行的,我們首先需要搞懂一些基礎知識.注意:在下面的文章中提到某一章節的時候(比如§9.1),指的都是ECMA-262語言規範(ECMAScript 5.1)中的章節.
讓我們快速的複習一下.在JavaScript中,一共有兩種類型的值:原始值(primitives)和對象值(objects).原始值有:undefined, null, 布爾值(booleans), 數字(numbers),還有字串(strings).其他的所有值都是物件類型的值,包括數組(arrays)和函數(functions).
1.類型轉換
加法運算子會觸發三種類型轉換:將值轉換為原始值,轉換為數字,轉換為字串,這剛好對應了JavaScript引擎內部的三種抽象操作:ToPrimitive(),ToNumber(),ToString()
1.1 通過ToPrimitive()將值轉換為原始值
JavaScript引擎內部的抽象操作ToPrimitive()有著這樣的簽名:
ToPrimitive(input, PreferredType?)
選擇性參數PreferredType可以是Number或者String,它只代表了一個轉換的偏好,轉換結果不一定必須是這個參數所指的類型,但轉換結果一定是一個原始值.如果PreferredType被標誌為Number,則會進行下面的操作來轉換輸入的值 (§9.1):
- 如果輸入的值已經是個原始值,則直接返回它.
- 否則,如果輸入的值是一個對象.則調用該對象的valueOf()方法.如果valueOf()方法的傳回值是一個原始值,則返回這個原始值.
- 否則,調用這個對象的toString()方法.如果toString()方法的傳回值是一個原始值,則返回這個原始值.
- 否則,拋出TypeError異常.
如果PreferredType被標誌為String,則轉換操作的第二步和第三步的順序會調換.如果沒有PreferredType這個參數,則PreferredType的值會按照這樣的規則來自動化佈建:Date類型的對象會被設定為String,其它類型的值會被設定為Number.
1.2 通過ToNumber()將值轉換為數字
下面的表格解釋了ToNumber()是如何將原始值轉換成數位 (§9.3).
參數 |
結果 |
undefined |
NaN |
null |
+0 |
布爾值 |
true被轉換為1,false轉換為+0 |
數字 |
無需轉換 |
字串 |
由字串解析為數字.例如,"324"被轉換為324 |
如果輸入的值是一個對象,則會首先會調用ToPrimitive(obj, Number)將該對象轉換為原始值,然後在調用ToNumber()將這個原始值轉換為數字.
1.3 通過ToString()將值轉換為字串
下面的表格解釋了ToString()是如何將原始值轉換成字串的(§9.8).
參數 |
結果 |
undefined |
"undefined" |
null |
"null" |
布爾值 |
"true" 或者 "false" |
數字 |
數字作為字串,比如. "1.765" |
字串 |
無需轉換 |
如果輸入的值是一個對象,則會首先會調用ToPrimitive(obj, String)將該對象轉換為原始值,然後再調用ToString()將這個原始值轉換為字串.
1.4 實踐一下
下面的對象可以讓你看到引擎內部的轉換過程.
var obj = { valueOf: function () { console.log("valueOf"); return {}; // 沒有返回原始值 }, toString: function () { console.log("toString"); return {}; // 沒有返回原始值 }}
Number作為一個函數被調用(而不是作為建構函式調用)時,會在引擎內部調用ToNumber()操作:
> Number(obj)valueOftoStringTypeError: Cannot convert object to primitive value
2.加法
有下面這樣的一個加法操作.
value1 + value2
在計算這個運算式時,內部的操作步驟是這樣的 (§11.6.1):
- 將兩個運算元轉換為原始值 (下面是數學標記法,不是JavaScript代碼):
prim1 := ToPrimitive(value1)
prim2 := ToPrimitive(value2)
PreferredType被省略,因此Date類型的值採用String,其他類型的值採用Number.
- 如果prim1或者prim2中的任意一個為字串,則將另外一個也轉換成字串,然後返回兩個字串串連操作後的結果.
- 否則,將prim1和prim2都轉換為數字類型,返回他們的和.
2.1 預料到的結果
兩個空數組相加時,結果是我們所預料的:
> [] + []''
[]會被轉換成一個原始值,首先嘗試valueOf()方法,返回數組本身(this):
> var arr = [];> arr.valueOf() === arrtrue
這樣的結果不是原始值,所以再調用toString()方法,返回一個Null 字元串(是一個原始值).因此,[] + []的結果實際上是兩個Null 字元串的串連.
將一個空數組和一個Null 物件相加,結果也符合我們的預期:
> [] + {}'[object Object]'
類似的,Null 物件轉換成字串是這樣的.
> String({})'[object Object]'
所以最終的結果是 "" 和 "[object Object]" 兩個字串的串連.
下面是更多的對象轉換為原始值的例子,你能搞懂嗎:
> 5 + new Number(7)12> 6 + { valueOf: function () { return 2 } }8> "abc" + { toString: function () { return "def" } }'abcdef'
2.1 意想不到的結果
如果加號前面的第一個運算元是個Null 物件字面量,則結果會出乎我們的意料(下面的代碼在Firefox控制台中運行):
> {} + {}NaN
這是怎麼一回事?原因就是JavaScript引擎將第一個{}解釋成了一個空的代碼塊並忽略了它.NaN其實是後面的運算式+{}計算的結果 (加號以及後面的{}).這裡的加號並不是代表加法的二元運算子,而是一個一元運算子,作用是將它後面的運算元轉換成數字,和Number()函數完全一樣.例如:
> +"3.65"3.65
轉換的步驟是這樣的:
+{}Number({})Number({}.toString()) // 因為{}.valueOf()不是原始值Number("[object Object]")NaN
為什麼第一個{}會被解析成代碼塊呢?原因是,整個輸入被解析成了一個語句,如果一個語句是以左大括弧開始的,則這對大括弧會被解析成一個代碼塊.所以,你也可以通過強制把輸入解析成一個運算式來修複這樣的計算結果:
> ({} + {})'[object Object][object Object]'
另外,一個函數或方法的參數也會被解析成一個運算式:
> console.log({} + {})[object Object][object Object]
經過前面的這一番講解,對於下面這樣的計算結果,你也應該不會感到吃驚了:
> {} + []0
在解釋一次,上面的輸入被解析成了一個代碼塊後跟一個運算式+[].轉換的步驟是這樣的:
+[]Number([])Number([].toString()) // 因為[].valueOf()不是原始值Number("")0
有趣的是,Node.js的REPL在解析類似的輸入時,與Firefox和Chrome(和Node.js一樣使用V8引擎)的解析結果不同.下面的輸入會被解析成一個運算式,結果更符合我們的預料:
> {} + {}'[object Object][object Object]'> {} + []'[object Object]'
下面是SpiderMonkey 和 nodejs 中的結果對比.
3.其他
在大多數情況下,想要弄明白JavaScript中的+號是如何工作的並不難:你只能將數字和數字相加或者字串和字串相加.對象值會被轉換成原始值後再進行計算.如果你想串連多個數組,需要使用數組的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”(已牆).
4.參考
- JavaScript values: not everything is an object