原文: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) valueOf toString TypeError: Cannot convert object to primitive value
2.加法
有下面這樣的一個加法操做.
value1 + value2
在計算這個表達式時,內部的操做步驟是這樣的 (§11.6.1):
- 將兩個操做數轉換爲原始值 (下面是數學表示法,不是JavaScript代碼):
prim1 := ToPrimitive(value1)
PreferredType被省略,所以Date類型的值採用String,其餘類型的值採用Number.
prim2 := ToPrimitive(value2) - 若是prim1或者prim2中的任意一個爲字符串,則將另一個也轉換成字符串,而後返回兩個字符串鏈接操做後的結果.
- 不然,將prim1和prim2都轉換爲數字類型,返回他們的和.
2.1 預料到的結果
兩個空數組相加時,結果是咱們所預料的:
> [] + [] ''
[]會被轉換成一個原始值,首先嚐試valueOf()方法,返回數組自己(this):
> var arr = []; > arr.valueOf() === arr true
這樣的結果不是原始值,因此再調用toString()方法,返回一個空字符串(是一個原始值).所以,[] + []的結果其實是兩個空字符串的鏈接.
將一個空數組和一個空對象相加,結果也符合咱們的預期:
> [] + {} '[object Object]'
相似的,空對象轉換成字符串是這樣的.
> String({}) '[object Object]'
因此最終的結果是 "" 和 "[object Object]" 兩個字符串的鏈接.
下面是更多的對象轉換爲原始值的例子,你能搞懂嗎:
> 5 + new Number(7) 12 > 6 + { valueOf: function () { return 2 } } 8 > "abc" + { toString: function () { return "def" } } 'abcdef'
2.1 意想不到的結果
若是加號前面的第一個操做數是個空對象字面量,則結果會出乎咱們的意料(下面的代碼在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」(已牆).