最近烤蜕,Gary Bernhardt 在一個(gè)簡短的演講視頻“Wat”中指出了一個(gè)有趣的 JavaScript 怪癖: 在把對象和數(shù)組混合相加時(shí)问畅,會(huì)得到一些意想不到的結(jié)果。 本篇文章會(huì)依次講解這些計(jì)算結(jié)果是如何得出的。
在 JavaScript 中,加法的規(guī)則其實(shí)很簡單毅臊,只有兩種情況:
把數(shù)字和數(shù)字相加
把字符串和字符串相加
所有其他類型的值都會(huì)被自動(dòng)轉(zhuǎn)換成這兩種類型的值。 為了能夠弄明白這種隱式轉(zhuǎn)換是如何進(jìn)行的黑界,我們首先需要搞懂一些基礎(chǔ)知識(shí)管嬉。
注意:在下面的文章中提到某一章節(jié)的時(shí)候(比如§9.1),指的都是 ECMA-262 語言規(guī)范(ECMAScript 5.1)中的章節(jié)朗鸠。
讓我們快速的復(fù)習(xí)一下蚯撩。 在 JavaScript 中,一共有兩種類型的值:
原始值(primitives)
undefined
null
boolean
number
string
對象值(objects)烛占。
除了原始值外胎挎,其他的所有值都是對象類型的值,包括數(shù)組(array)和函數(shù)(function)扰楼。
類型轉(zhuǎn)換
加法運(yùn)算符會(huì)觸發(fā)三種類型轉(zhuǎn)換:
轉(zhuǎn)換為原始值
轉(zhuǎn)換為數(shù)字
轉(zhuǎn)換為字符串
通過 ToPrimitive() 將值轉(zhuǎn)換為原始值
JavaScript 引擎內(nèi)部的抽象操作ToPrimitive()有著這樣的簽名:
ToPrimitive(input呀癣,PreferredType?)
可選參數(shù)PreferredType可以是Number或者String。 它只代表了一個(gè)轉(zhuǎn)換的偏好弦赖,轉(zhuǎn)換結(jié)果不一定必須是這個(gè)參數(shù)所指的類型(汗),但轉(zhuǎn)換結(jié)果一定是一個(gè)原始值浦辨。 如果PreferredType被標(biāo)志為Number蹬竖,則會(huì)進(jìn)行下面的操作來轉(zhuǎn)換input(§9.1):
如果input是個(gè)原始值,則直接返回它流酬。
否則币厕,如果input是一個(gè)對象。則調(diào)用obj.valueOf()方法芽腾。 如果返回值是一個(gè)原始值旦装,則返回這個(gè)原始值。
否則摊滔,調(diào)用obj.toString()方法阴绢。 如果返回值是一個(gè)原始值店乐,則返回這個(gè)原始值。
否則呻袭,拋出TypeError異常眨八。
如果PreferredType被標(biāo)志為String,則轉(zhuǎn)換操作的第二步和第三步的順序會(huì)調(diào)換左电。 如果沒有PreferredType這個(gè)參數(shù)廉侧,則PreferredType的值會(huì)按照這樣的規(guī)則來自動(dòng)設(shè)置:
Date類型的對象會(huì)被設(shè)置為String,
其它類型的值會(huì)被設(shè)置為Number篓足。
通過 ToNumber() 將值轉(zhuǎn)換為數(shù)字
下面的表格解釋了ToNumber()是如何將原始值轉(zhuǎn)換成數(shù)字的 (§9.3)段誊。
參數(shù)結(jié)果
undefinedNaN
null+0
booleantrue被轉(zhuǎn)換為1,false轉(zhuǎn)換為+0
number無需轉(zhuǎn)換
string由字符串解析為數(shù)字。例如栈拖,"324"被轉(zhuǎn)換為324
如果輸入的值是一個(gè)對象连舍,則會(huì)首先會(huì)調(diào)用ToPrimitive(obj, Number)將該對象轉(zhuǎn)換為原始值, 然后在調(diào)用ToNumber()將這個(gè)原始值轉(zhuǎn)換為數(shù)字辱魁。
通過ToString()將值轉(zhuǎn)換為字符串
下面的表格解釋了ToString()是如何將原始值轉(zhuǎn)換成字符串的(§9.8)烟瞧。
參數(shù)結(jié)果
undefined"undefined"
null"null"
boolean"true" 或者 "false"
number數(shù)字作為字符串。比如染簇,"1.765"
string無需轉(zhuǎn)換
如果輸入的值是一個(gè)對象参滴,則會(huì)首先會(huì)調(diào)用ToPrimitive(obj, String)將該對象轉(zhuǎn)換為原始值, 然后再調(diào)用ToString()將這個(gè)原始值轉(zhuǎn)換為字符串锻弓。
實(shí)踐一下
下面的對象可以讓你看到引擎內(nèi)部的轉(zhuǎn)換過程砾赔。
varobj={valueOf:function(){console.log("valueOf");return{};// not a primitive},toString:function(){console.log("toString");return{};// not a primitive}}
Number作為一個(gè)函數(shù)被調(diào)用(而不是作為構(gòu)造函數(shù)調(diào)用)時(shí)青灼,會(huì)在引擎內(nèi)部調(diào)用ToNumber()操作:
> Number(obj)
valueOf
toString
TypeError: Cannot convert object to primitive value
加法
有下面這樣的一個(gè)加法操作暴心。
value1+value2
在計(jì)算這個(gè)表達(dá)式時(shí),內(nèi)部的操作步驟是這樣的 (§11.6.1):
將兩個(gè)操作數(shù)轉(zhuǎn)換為原始值 (以下是數(shù)學(xué)表示法的偽代碼杂拨,不是可以運(yùn)行的 JavaScript 代碼):
prim1:=ToPrimitive(value1)prim2:=ToPrimitive(value2)
PreferredType被省略专普,因此Date類型的值采用String,其他類型的值采用Number弹沽。
如果 prim1 或者 prim2 中的任意一個(gè)為字符串檀夹,則將另外一個(gè)也轉(zhuǎn)換成字符串,然后返回兩個(gè)字符串連接操作后的結(jié)果策橘。
否則炸渡,將 prim1 和 prim2 都轉(zhuǎn)換為數(shù)字類型,返回他們的和丽已。
預(yù)料到的結(jié)果
當(dāng)你將兩個(gè)數(shù)組相加時(shí)蚌堵,結(jié)果正是我們期望的:
> [] + []
''
[]被轉(zhuǎn)換成一個(gè)原始值:首先嘗試valueOf()方法,該方法返回?cái)?shù)組本身(this):
> var arr = [];
> arr.valueOf() === arr
true
此時(shí)結(jié)果不是原始值,所以再調(diào)用toString()方法吼畏,返回一個(gè)空字符串(string是原始值)督赤。 因此,[] + []的結(jié)果實(shí)際上是兩個(gè)空字符串的連接宫仗。
將一個(gè)數(shù)組和一個(gè)對象相加够挂,結(jié)果依然符合我們的期望:
> [] + {}
'[object Object]'
解析:將空對象轉(zhuǎn)換成字符串時(shí),產(chǎn)生如下結(jié)果藕夫。
> String({})
'[object Object]'
所以最終的結(jié)果其實(shí)是把""和"[object Object]"兩個(gè)字符串連接起來孽糖。
更多的對象轉(zhuǎn)換為原始值的例子:
> 5 + new Number(7)
12
> 6 + { valueOf: function () { return 2 } }
8
> "abc" + { toString: function () { return "def" } }
'abcdef'
意想不到的結(jié)果
如果+加法運(yùn)算的第一個(gè)操作數(shù)是個(gè)空對象字面量,則會(huì)出現(xiàn)詭異的結(jié)果(Firefox console 中的運(yùn)行結(jié)果):
> {} + {}
NaN
天哪毅贮!神馬情況办悟?(譯注:原文沒有,是我第一次讀到這兒的時(shí)候感到太吃驚了滩褥,翻譯的時(shí)候加入的病蛉。@justjavac) 這個(gè)問題的原因是,JavaScript 把第一個(gè){}解釋成了一個(gè)空的代碼塊(code block)并忽略了它瑰煎。NaN其實(shí)是表達(dá)式+{}計(jì)算的結(jié)果 (+加號(hào)以及第二個(gè){})铺然。 你在?這里看到的+加號(hào)并不是二元運(yùn)算符「加法」,而是一個(gè)一元運(yùn)算符酒甸,作用是將它后面的操作數(shù)轉(zhuǎn)換成數(shù)字魄健,和Number()函數(shù)完全一樣?。例如:
> +"3.65"
3.65
以下的表達(dá)式是它的等價(jià)形式:
+{}
Number({})
Number({}.toString())? // {}.valueOf() isn’t primitive
Number("[object Object]")
NaN
為什么第一個(gè){}會(huì)被解析成代碼塊(code block)呢插勤? 因?yàn)檎麄€(gè)輸入被解析成了一個(gè)語句:如果左大括號(hào)出現(xiàn)在一條語句的開頭沽瘦,則這個(gè)左大括號(hào)會(huì)被解析成一個(gè)代碼塊的開始。 所以农尖,你也可以通過強(qiáng)制把輸入解析成一個(gè)表達(dá)式來修復(fù)這樣的計(jì)算結(jié)果: (譯注:我們期待它是個(gè)表達(dá)式析恋,結(jié)果卻被解析成了語句,表達(dá)式和語句的區(qū)別可以查看我以前的『代碼之謎』系列的語句與表達(dá)式盛卡。@justjavac)
> ({} + {})
'[object Object][object Object]'
一個(gè)函數(shù)或方法的參數(shù)也會(huì)被解析成一個(gè)表達(dá)式:
> console.log({} + {})
[object Object][object Object]
經(jīng)過前面的講解助隧,對于下面這樣的計(jì)算結(jié)果,你也應(yīng)該不會(huì)感到吃驚了:
> {} + []
0
在解釋一次滑沧,上面的輸入被解析成了一個(gè)代碼塊后跟一個(gè)表達(dá)式+[]喇颁。 轉(zhuǎn)換的步驟是這樣的:
+[]
Number([])
Number([].toString())? // [].valueOf() isn’t primitive
Number("")
0
有趣的是,Node.js 的 REPL 在解析類似的輸入時(shí)嚎货,與 Firefox 和 Chrome(和Node.js 一樣使用 V8 引擎) 的解析結(jié)果不同。 下面的輸入會(huì)被解析成一個(gè)表達(dá)式蔫浆,結(jié)果更符合我們的預(yù)料:
> {} + {}
'[object Object][object Object]'
> {} + []
'[object Object]'
3. 這就是所有嗎殖属?
在大多數(shù)情況下,想要弄明白 JavaScript 中的+號(hào)是如何工作的并不難:你只能將數(shù)字和數(shù)字相加或者字符串和字符串相加瓦盛。 對象值會(huì)被轉(zhuǎn)換成原始值后再進(jìn)行計(jì)算洗显。如果將多個(gè)數(shù)組相加外潜,可能會(huì)出現(xiàn)你意料之外的結(jié)果,相關(guān)文章請參考在 javascript 中挠唆,為什么 [1,2] + [3,4] 不等于 [1,2,3,4]处窥?和為什么 ++[[]][+[]]+[+[]] = 10?玄组。
如果你想連接多個(gè)數(shù)組滔驾,需要使用數(shù)組的 concat 方法:
> [1, 2].concat([3, 4])
[1, 2, 3, 4]
JavaScript 中沒有內(nèi)置的方法來“連接” (合并)多個(gè)對象。 你可以使用一個(gè) 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()方法會(huì)修改它的第一個(gè)參數(shù),而不是返回合并后的對象:
> o1
{eeny: 1, meeny: 2, miny: 3, moe: 4}
> o2
{miny: 3, moe: 4}
如果你想了解更多有趣的關(guān)于運(yùn)算符的知識(shí)患膛,你可以閱讀一下 “Fake operator overloading in JavaScript”(中文正在翻譯中)