我希望自己儘早知道的7個JavaScript怪癖

發表於2014-05-27

如果對你來說JavaScript還是一門全新的語言,或者你是在最近的開發中才剛剛對它有所瞭解,那麼你可能會有些許挫敗 感。任何程式語言都有它自己的怪癖(quirks)——然而,當你從那些強型別的伺服器端語言轉向JavaScript的時候 ,你會感到非常困惑。我就是這樣!當我在幾年前做全職JavaScript開發的時候,我多麼希望關於這門語言的許多事情我能儘早地知道。我希望通過本文中分享的一些怪癖能讓你免於遭受我所經歷過的那些頭疼的日子。本文並非一個詳盡的列表,只是一些取樣,目的是拋磚引玉,並且讓你明白當你一旦逾越了這些障礙,你會發現JavaScript是多麼強大。

我們會把焦點放在下面這些怪癖上:

1.相等

2.點號 vs 方括號

3.函式上下文

4.函式宣告和函式表示式

5.具名和匿名函式

6.自呼叫函式表示式

7.typeof vs object.ptototype.toString

 

1.) 相等

因為C#的緣故我習慣於用==運算子來做比較。具有相同值的值型別(以及字串)是相等 的,反之不然。指向相同引用的引用型別是相等的,反之也不然。(當然這是建立在你沒有過載==運算子或者GetHashCode方法的前提下)當我知道 JavaScript有==和===兩種相等運算子時,令我驚詫不已。我所見過的大多數情況都是使用==,所以我如法炮製。然而,當我執行下面的程式碼時 JavaScript並沒有給我想當然的結果:

呃……這是什麼黑魔法?整型數1怎麼會和字串”1”相等?

在JavaScript裡有相等(equality ==)和恆等(strict equality ===)。相等運算子會先會先把運算子兩邊的運算元強制轉換為同種型別,然後再進行恆等比較。所以上面例子中的字串”1”會先被轉換成整數1,然後再和 我們的變數x進行比較。

恆等不會進行強制型別轉換。如果運算元是不同型別的(就像整型數1和字串”1”)那麼他們就是不相等的:

你可能已經開始為各種不可預知的強制型別轉換擔憂了,它們可能會在你的應用中讓真假混亂,導致一些bug,而這些bug你很難從程式碼中看出來。這並不奇怪,因此,那些有經驗的JavaScript開發者建議我們總是使用恆等運算子。

2.) 點號 vs 方括號

你可能會對JavaScript中用訪問陣列元素的方式來訪問一個物件的屬性這種形式感到詫異,當然,這取決於你之前使用的其他語言:

然而 ,你知道我們也能用方括號來引用物件的成員嗎?例如:

那這有什麼用呢?可能大部分時間你還是使用點號,然而有些為數不多的情況下,方括號給我們提供了一些點號方式無法完成的捷徑。比如,我可能會經常把一些大的switch語句重構成一個排程表(dispatch table),像下面這樣:

它們能被轉換成下面這樣:

當然,使用switch本身並沒有什麼錯(並且,在大多數情況下,如果你對迭代和效能很在意的話,switch可能比排程表要好)。然而,排程表提供了一種更好的組織和擴充套件方式,並且方括號允許你在執行時動態地引用屬性。

3.) 函式上下文

已經有很多不錯的部落格裡解釋過JavaScript中的this所代表的上下文(並且, 我在本文末尾也新增了這些博文的連結),然而,我還是明確地決定把它加到我“希望自己儘早知道的事”的清單裡。在程式碼的任意地方明確this所代表的東西 是什麼並不困難——你只需要記住幾條規則。然而,我之前讀過的那些關於這點的解讀只能增添我的困惑,因此,我嘗試用一種簡單的方式來表述。

第一,開始時假設它是全域性的

預設情況下,this引用的是全域性物件(global object),直到有原因讓執行上下文發生了改變。在瀏覽器裡它指向的就是window物件(或者在node.js裡就是global)。

第二,方法內部的this

如果你有個物件中的某個成員是個function,那麼當你從這個物件上呼叫這個方法的時候this就指向了這個父物件。例如:

你可能已經知道你可以通過建立一個新的物件,來引用marty物件上的timeTravel方法。這確實是JavaScript一個非常強大的特性——能讓我們把函式應用到不止一個目標例項上:

那麼,我們呼叫doc.timeTravel(1885)會發生什麼事呢?

呃……再一次被黑魔法深深地刺傷了。其實事實也並非如此,還記得我們前面提到過的當你呼叫一個方法,那麼這個方法中的this將指向呼叫它的那個父物件。握緊你德羅寧(DeLoreans)跑車的方向盤吧,因為車子變重了。(譯註:作者示例程式碼的參考背景是一部叫《回到未來》 的電影,Marty McFly 是電影裡的主角,Emmett Brown 是把DeLoreans跑車改裝成時光旅行機的博士,所以marty物件和doc物件分別指代這兩人。而此時this指向了doc物件,博士比Marty 重,所以……我一定會看一下這部電影。 )

當我們儲存了一個marty.TimeTravel方法的引用並且通過這個引用呼叫這個方法時到底發生了什麼事呢?我們來看一下:

為什麼是“undefined undefined”?!為什麼不是“Marty McFly”?

讓我們問一個關鍵的問題:當我們呼叫getBackInTime函式時,它的父/擁有者 物件是誰呢?因為getBackInTime函式是存在於window上的,我們是把它當作函式(function)呼叫,而不是某個物件的方 (method)。當我們像上面這樣直接呼叫一個沒有擁有者物件的函式的時候,this將會指向全域性物件。David Shariff對此有個很妙的描述:

無論何時,當一個函式被呼叫,我們必須看方括號或者是圓括號左邊緊鄰的位置,如果我們看到一個引用(reference),那麼傳到function裡面的this值就是指向這個方法所屬於的那個物件,如若不然,那它就是指向全域性物件的。

因為getBackInTime的this是指向window的,而window物件裡並沒有firstName和lastName屬性,這就是解釋了為什麼我們看到的會是“undefined undefined”。

因此,我們就知道了直接呼叫一個沒有擁有者物件的函式時結果就是其內部的this將會是 全域性物件。但是,我也說過我們的getBackInTime函式是存在於window上的。我是怎麼知道的呢?除非我把getBackInTime包裹到 另一個不同的作用域中,否則我宣告的任何變數都會附加到window上。下面就是從Chrome的控制檯中得到的證明:

jsquirgwfwrks_afjq_1

現在是討論關於this諸多重點之一——繫結事件處理函式——的最佳時機。

第三(其實只是第二點的一個擴充套件),非同步呼叫的方法內部的this

我們假設在某個button被點選的時候我們想呼叫marty.timeTravel方法:

當我們點選button的時候,上面的程式碼會輸出“undefined undefined is time traveling to [object MouseEvent]”。什麼?!好吧,首先,最顯而易見的問題是我們沒有給timeTravel方法提供year引數。反而是把這個方法直接作為一個 事件處理函式,並且,MouseEvent被作為第一個引數傳進了事件處理函式中。這個很容易修復,然而真正的問題是我們又一次看到了 “undefined undefined”。別失望,你已經知道為什麼會發生這種情況了(即使你沒有意識到這一點)。讓我們修改一下timeTravel函式,輸出this來 幫助我們獲得一些線索:

現在我們再點選button的時候,應該就能在瀏覽器控制檯中看到類似下面這樣的輸出:

jsquigwerrks_afjq_2

在方法被呼叫時第二個console.log輸出了this,它實際上是我們繫結的 button元素。感到奇怪麼?就像之前我們把marty.timeTravel賦值給一個getBakInTime的變數引用一樣,此時的 marty.timeTravel被儲存為我們事件處理函式的引用,並且被呼叫了,但是並不是從“擁有者”marty物件那裡呼叫的。在這種情況下,它是 被button元素例項中的事件觸發介面呼叫的。

那麼,有沒有可能讓this是我們想要的東西呢?當然可以!這種情況下,解決方案非常簡 單。我們可以用一個匿名函式代替marty.timeTravel來做事件處理函式,然後在這個匿名函式裡呼叫marty.timeTravel。同時這 樣也讓我們有機會修復之前丟失year引數的問題。

點選button會看到像下面這樣的輸出:

jsquisgwegerks_afjq_3

成功了!但是為什麼成功呢?思考一下我們是怎麼呼叫timeTravel方法的。第一次 的時候我們是把這個方法的本身的引用作為事件處理函式,因此它並不是從父物件marty上呼叫的。第二次的時候,我們的匿名函式中的this是指向 button元素的,然而當我們呼叫marty.timeTravel時,我們是從父物件marty上呼叫的,所以此時這個方法裡的this是 marty。

第四,建構函式裡的this

當你用建構函式建立一個物件的例項時,那麼建構函式裡的this就是你新建的這個例項。例如:

使用Call,Apply和Bind

從上面給出的例子你可能已經猜到了,通過一些語言級別的特性是允許我們在呼叫一個函式的時候指定它在執行時的this的。讓你給猜對了。call和apply方法存在於Function的prototype中,它們允許我們在呼叫一個方法的時候傳入一個this的值。

call方法的簽名中先是指定this引數,其後跟著的是方法呼叫時要用到的引數,這些引數是各自分開的。

apply的第一個引數同樣也是this的值,而其後跟著的是呼叫這個函式時的引數的陣列。

我們的doc和margy物件自己能進行時光旅行(譯註:即物件中有 timeTravel方法),然而愛因斯坦(譯註:Einstein,電影中博士的寵物,是一隻狗)需要別人的幫助才能進行時光旅行,所以現在讓我們給之 前的doc物件(就是之前把marty.timeTravel賦值給doc.timeTravel的那個版本)新增一個方法,這樣doc物件就能幫助 einstein物件進行時光旅行了:

現在我們可以送愛因斯坦上路了:

我知道這個例子讓你有些出乎意料,然而這已經足以讓你領略到把函式指派給其他物件呼叫的強大。

這裡還有一種我們尚未探索的可能性。我們給marty物件加一個goHome的方法,這個方法是個讓marty回到未來的捷徑,因為它其實是呼叫了this.timeTravel(1985):

我們已經知道,如果把 marty.goHome 作為事件處理函式繫結到button的click事件上,那麼this就是這個button。並且,button物件上也並沒有timeTravel這個 方法。我們可以用之前那種匿名函式的辦法來繫結事件處理函式,再在匿名函式裡呼叫marty物件上的方法。不過,我們還有另外一個辦法,那就是bind函式:

bind函式其實是返回一個新函式,而這個新函式中的this值正是用bind的引數來指定的。如果你需要支援那些舊的瀏覽器(比如IE9以下的)你就需要用個bind方法的補丁(或者,如果你使用的是jQuery,那麼你可以用$.proxy;另外underscore和lodash庫中也提供了_.bind)。

有一件事需要注意,如果你在一個原型方法上使用bind,那它會建立一個例項級別的方法,這樣就遮蔽了原型上的同名方法,你應該意識到這並不是個錯誤。關於這個問題的更多細節我在這篇文章裡進行了描述。

4.) 函式宣告 vs 函式表示式

在JavaScript主要有兩種定義函式的方法(而ES6會在這裡作介紹):函式宣告和函式表示式。

函式宣告不需要var關鍵字。事實上,正如 Angus Croll 所說:“把他當作變數宣告的兄弟是很有幫助的”。例如:

上例中名叫timeTravel的函式不僅僅只在其被宣告的作用域內可見,而且對這個函式自身內部也是可見的(這一點對遞迴函式的呼叫尤為有用)。函式宣告其實就是命名函式,換句話說,上面的函式的name屬性就是timeTravel。

函式表示式是定義一個函式並把它賦值給一個變數。一般情況下,它們看起來會是這樣:

函式表示式也是可以被命名的,只不過不像函式宣告那樣,被命名的函式表示式的名字只能在 該函式內部的作用域中訪問(譯註:上例中的程式碼,關鍵字function後面直接跟著圓括號,此時你可以用someFn.name來訪問函式名,但是輸出 將會是空字串;而下例中的someFn.name會是”iHazName”,但是你卻不能在iHazName這個函式體之外的地方用這個名字來呼叫此函 數):

函式表示式和函式宣告的討論遠不止這些,除此之外至少還有提升(hoisting)。提升是指函式和變數的宣告被直譯器移動到包含它們的作用域的頂部。雖然我們在這裡沒有細說提升,但是務必讀一下Ben CherryAngus Croll對它的解讀。

5.) 具名和匿名函式

基於我們剛剛討論的,你肯定猜到所謂的匿名函式就是沒有名字的函式。大多數JavaScript開發者都能很快認出下例中第二個引數是一個匿名函式:

而事實上我們的marty.timeTravel方法也是匿名的:

因為函式宣告必須有個名字,只有函式表示式才可能是匿名的。

6.) 自呼叫函式表示式

自從我們開始討論函式表達以來,有件事我就想立馬搞清楚,那就是自呼叫函式表示式( the Immediately Invoked Function Expression (IIFE))。我會在本文的結尾羅列幾篇對IIFE講解得不錯的文章。但簡而言之,它就是一個沒有賦值給任何變數的函式表示式,它並不等待稍後被呼叫, 而是在定義的時候就立即執行。下面這些瀏覽器控制檯的截圖能幫助我們理解:

首先讓我們輸入一個函式表示式,但是不把它賦值給任何變數,看看會發生什麼

jsqduirks_afjq_4

無效的JavaScript語法——它其實是一個缺少名字的函式宣告。想讓它變成一個表示式,我們只需用一對圓括號把它包裹起來:

jsquirks_afjq_5

當把它變成一個表示式後控制檯立即返回給我們這個匿名函式(我們並沒有把這個函式賦值給 其他變數,但是,因為它是個表示式,我們只是獲取到了表示式的值)。然而,這只是實現了“自呼叫函式表示式”中的“函式表示式”部分。對於“自呼叫”這部 分,我們是通過給這個返回的表示式後面加上另外一對圓括號來實現的(就像我們呼叫任何其他函式一樣)。

jsquirks_afjq_6

“但是等等!Jim,我記得我以前在哪看到過把後面的那對圓括號放進表示式括號裡面的情況。”你說得對,這種語法完全正確(因為Douglas Crockford 更喜歡這種語法,才讓它變得眾所周知):

jsquirks_afjq_7

這兩種語法都是可用的,然而我強烈建議你讀一下對這兩種用法有史以來最好的解釋

OK,我們現在已經知道什麼是IIFE了,那為什麼說它很有用呢?

它可以幫助我們控制作用域,這是JavaScript中很重要的一部分!marty物件 一開始是被建立在一個全域性作用域裡。這意味著window物件(假定我們執行在瀏覽器裡)裡有個marty屬性。如果我們JavaScript程式碼都照這 個寫法,那麼很快全域性作用域下就會被大量的變數宣告給填滿,汙染了window物件。即使是在最理想的情況下,這都是不好的做法,因為把很多細節暴露給了 全域性作用域,那麼,當你在宣告一個物件時對它命名,而這個名字恰巧又和window物件上已經存在的一個屬性同名,那麼會發生什麼事呢?這個屬性會被覆蓋 掉!比如,你打算建個“阿梅莉亞·埃爾哈特(Amelia Earhart)”的粉絲網站,你在全域性作用域下宣告瞭一個叫navigator的變數,那麼我們來看一下這前後發生了些什麼(譯註:阿梅莉亞·埃爾哈特 是一位傳奇的美國女性飛行員,不幸在1937年,當她嘗試全球首次環球飛行時,在飛越太平洋期間失蹤。當時和她一起在飛機上的導航員 (navigator)就是下面程式碼中的這位佛萊得·努南(Fred Noonan)):

jsquirks_afjq_8

呃……

顯然,汙染全域性作用域是種不好的做法。JavaScript使用的是函式作用域(而不是 塊作用域,如果你是從C#或者Java轉過來的,這點一定要小心!)所以,阻止我們的程式碼汙染全域性作用域的辦法就是建立一個新作用域,我們可以用IIFE 來達到這個目的,因為它裡面的內容只會在它自己的函式作用域裡。下面的例子裡,我要先在控制檯檢視一下window.navigator的值,再用一個 IIFE來包裹起具體的行為和資料,並把他賦值給amelia。這個IIFE返回一個物件作為我們的“應用程式作用域”。在這個IIFE裡我宣告瞭一個 navigator變數,它不會覆蓋window.navigator的值。

jsquirks_afjq_9

作為一點額外的福利,我們上面建立的IIFE其實是JavaScript模組模式(module pattern)的一個開端。在文章結尾有一些相關的連結,以便你可以繼續探索JavaScript的模組模式。

7.) typeof運算子和Object.prototype.toString

終有一天你會遇到與此類似的情形,那就是你需要檢測一個函式傳進來的值是什麼型別。typeof運算子似乎是不二之選,然而,它並不是那麼可靠。例如,當我們對一個物件,一個陣列,一個字串,或者一個正規表示式使用typeof時,會發生什麼呢?

jsquirks_afjq_10

好吧,至少它能把字串從物件,陣列和正規表示式中區分出來。幸虧我們還有其它辦法能從這些檢測的值裡得到更多準確的資訊。我們可以使用Object.prototype.toString函式並且應用上我們之前掌握的call方法的知識:

jsquirks_afjq_11

為什麼我們要使用Object.prototype上的toString方法呢?因為它可能被第三方的庫或者我們自己的程式碼中的例項方法給過載掉。而通過Object.prototype我們可以強制使用原始的toString。

如果你知道typeof會給你返回什麼,並且你也不需要知道除此之外的其他資訊(例如, 你只需要知道某個值是不是字串),那麼用typeof就再好不過了。然而,如果你想區分陣列和物件或者正規表示式和物件等等的,那麼就用 Object.prototype.toString吧。

接下來去哪裡

我從其他的JavaScript開發者的真知灼見裡受益匪淺,因此,請訪問下面的連結並且感謝一下他們吧。

相關文章