我希望自己盡早知道的 7 個 JavaScript 怪癖
如果對你來說JavaScript還是一門全新的語言,或者你是在最近的開發中才剛剛對它有所了解,那么你可能會有些許挫敗 感。任何編程語言都有它自己的怪癖(quirks)——然而,當你從那些強類型的服務器端語言轉向JavaScript的時候 ,你會感到非常困惑。我就是這樣!當我在幾年前做全職JavaScript開發的時候,我多么希望關于這門語言的許多事情我能盡早地知道。我希望通過本文中分享的一些怪癖能讓你免于遭受我所經歷過的那些頭疼的日子。本文并非一個詳盡的列表,只是一些取樣,目的是拋磚引玉,并且讓你明白當你一旦逾越了這些障礙,你會發現JavaScript是多么強大。
我們會把焦點放在下面這些怪癖上:
1.) 相等
因為C#的緣故我習慣于用==運算符來做比較。具有相同值的值類型(以及字符串)是相等 的,反之不然。指向相同引用的引用類型是相等的,反之也不然。(當然這是建立在你沒有重載==運算符或者GetHashCode方法的前提下)當我知道 JavaScript有==和===兩種相等運算符時,令我驚詫不已。我所見過的大多數情況都是使用==,所以我如法炮制。然而,當我運行下面的代碼時 JavaScript并沒有給我想當然的結果:
var x = 1;if(x == "1") { console.log("YAY! They're equal!"); }</pre>
呃……這是什么黑魔法?整型數1怎么會和字符串”1”相等?
在JavaScript里有相等(equality ==)和恒等(strict equality ===)。相等運算符會先會先把運算符兩邊的運算元強制轉換為同種類型,然后再進行恒等比較。所以上面例子中的字符串”1”會先被轉換成整數1,然后再和 我們的變量x進行比較。
恒等不會進行強制類型轉換。如果運算元是不同類型的(就像整型數1和字符串”1”)那么他們就是不相等的:
var x = 1;// 對于恒等,首先類型必須一樣 if(x === "1") { console.log("Sadly, I'll never write this to the console"); }
if(x === 1) { console.log("YES! Strict Equality FTW.") }</pre>
你可能已經開始為各種不可預知的強制類型轉換擔憂了,它們可能會在你的應用中讓真假混亂,導致一些bug,而這些bug你很難從代碼中看出來。這并不奇怪,因此,那些有經驗的JavaScript開發者建議我們總是使用恒等運算符。
2.) 點號 vs 方括號
你可能會對JavaScript中用訪問數組元素的方式來訪問一個對象的屬性這種形式感到詫異,當然,這取決于你之前使用的其他語言:
// getting the "firstName" value from the person object: var name = person.firstName;// getting the 3rd element in an array: var theOneWeWant = myArray[2]; // remember, 0-based index</pre>
然而 ,你知道我們也能用方括號來引用對象的成員嗎?例如:
var name = person["firstName"];那這有什么用呢?可能大部分時間你還是使用點號,然而有些為數不多的情況下,方括號給我們提供了一些點號方式無法完成的捷徑。比如,我可能會經常把一些大的switch語句重構成一個調度表(dispatch table),像下面這樣:
var doSomething = function(doWhat) { switch(doWhat) { case "doThisThing": // more code... break; case "doThatThing": // more code... break; case "doThisOtherThing": // more code.... break; // additional cases here, etc. default: // default behavior break; } }它們能被轉換成下面這樣:
var thingsWeCanDo = { doThisThing : function() { / behavior / }, doThatThing : function() { / behavior / }, doThisOtherThing : function() { / behavior / }, default : function() { / behavior / } };var doSomething = function(doWhat) { var thingToDo = thingsWeCanDo.hasOwnProperty(doWhat) ? doWhat : "default" thingsWeCanDothingToDo; }</pre>
當然,使用switch本身并沒有什么錯(并且,在大多數情況下,如果你對迭代和性能很在意的話,switch可能比調度表要好)。然而,調度表提供了一種更好的組織和擴展方式,并且方括號允許你在運行時動態地引用屬性。
3.) 函數上下文
已經有很多不錯的博客里解釋過JavaScript中的this所代表的上下文(并且, 我在本文末尾也添加了這些博文的鏈接),然而,我還是明確地決定把它加到我“希望自己盡早知道的事”的清單里。在代碼的任意地方明確this所代表的東西 是什么并不困難——你只需要記住幾條規則。然而,我之前讀過的那些關于這點的解讀只能增添我的困惑,因此,我嘗試用一種簡單的方式來表述。
第一,開始時假設它是全局的
默認情況下,this引用的是全局對象(global object),直到有原因讓執行上下文發生了改變。在瀏覽器里它指向的就是window對象(或者在node.js里就是global)。
第二,方法內部的this
如果你有個對象中的某個成員是個function,那么當你從這個對象上調用這個方法的時候this就指向了這個父對象。例如:
var marty = { firstName: "Marty", lastName: "McFly", timeTravel: function(year) { console.log(this.firstName + " " + this.lastName + " is time traveling to " + year); } }marty.timeTravel(1955); // Marty McFly is time traveling to 1955</pre>
你可能已經知道你可以通過創建一個新的對象,來引用marty對象上的timeTravel方法。這確實是JavaScript一個非常強大的特性——能讓我們把函數應用到不止一個目標實例上:
var doc = { firstName: "Emmett", lastName: "Brown", }doc.timeTravel = marty.timeTravel;</pre>
那么,我們調用doc.timeTravel(1885)會發生什么事呢?
doc.timeTravel(1885); // Emmett Brown is time traveling to 1885呃……再一次被黑魔法深深地刺傷了。其實事實也并非如此,還記得我們前面提到過的當你調用一個方法,那么這個方法中的this將指向調用它的那個父對象。握緊你德羅寧(DeLoreans)跑車的方向盤吧,因為車子變重了。(譯注:作者示例代碼的參考背景是一部叫《回到未來》 的電影,Marty McFly 是電影里的主角,Emmett Brown 是把DeLoreans跑車改裝成時光旅行機的博士,所以marty對象和doc對象分別指代這兩人。而此時this指向了doc對象,博士比Marty 重,所以……我一定會看一下這部電影。 )
當我們保存了一個marty.TimeTravel方法的引用并且通過這個引用調用這個方法時到底發生了什么事呢?我們來看一下:
var getBackInTime = marty.timeTravel; getBackInTime(2014); // undefined undefined is time traveling to 2014為什么是“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的控制臺中得到的證明:
現在是討論關于this諸多重點之一——綁定事件處理函數——的最佳時機。
第三(其實只是第二點的一個擴展),異步調用的方法內部的this
我們假設在某個button被點擊的時候我們想調用marty.timeTravel方法:
var flux = document.getElementById("flux-capacitor"); flux.addEventListener("click", marty.timeTravel);當我們點擊button的時候,上面的代碼會輸出“undefined undefined is time traveling to [object MouseEvent]”。什么?!好吧,首先,最顯而易見的問題是我們沒有給timeTravel方法提供year參數。反而是把這個方法直接作為一個 事件處理函數,并且,MouseEvent被作為第一個參數傳進了事件處理函數中。這個很容易修復,然而真正的問題是我們又一次看到了 “undefined undefined”。別失望,你已經知道為什么會發生這種情況了(即使你沒有意識到這一點)。讓我們修改一下timeTravel函數,輸出this來 幫助我們獲得一些線索:
marty.timeTravel = function(year) { console.log(this.firstName + " " + this.lastName + " is time traveling to " + year); console.log(this); };現在我們再點擊button的時候,應該就能在瀏覽器控制臺中看到類似下面這樣的輸出:
在方法被調用時第二個console.log輸出了this,它實際上是我們綁定的 button元素。感到奇怪么?就像之前我們把marty.timeTravel賦值給一個getBakInTime的變量引用一樣,此時的 marty.timeTravel被保存為我們事件處理函數的引用,并且被調用了,但是并不是從“擁有者”marty對象那里調用的。在這種情況下,它是 被button元素實例中的事件觸發接口調用的。
那么,有沒有可能讓this是我們想要的東西呢?當然可以!這種情況下,解決方案非常簡 單。我們可以用一個匿名函數代替marty.timeTravel來做事件處理函數,然后在這個匿名函數里調用marty.timeTravel。同時這 樣也讓我們有機會修復之前丟失year參數的問題。
flux.addEventListener("click", function(e) { marty.timeTravel(someYearValue); });點擊button會看到像下面這樣的輸出:
成功了!但是為什么成功呢?思考一下我們是怎么調用timeTravel方法的。第一次 的時候我們是把這個方法的本身的引用作為事件處理函數,因此它并不是從父對象marty上調用的。第二次的時候,我們的匿名函數中的this是指向 button元素的,然而當我們調用marty.timeTravel時,我們是從父對象marty上調用的,所以此時這個方法里的this是 marty。
第四,構造函數里的this
當你用構造函數創建一個對象的實例時,那么構造函數里的this就是你新建的這個實例。例如:
var TimeTraveler = function(fName, lName) { this.firstName = fName; this.lastName = lName; // Constructor functions return the // newly created object for us unless // we specifically return something else };var marty = new TimeTraveler("Marty", "McFly"); console.log(marty.firstName + " " + marty.lastName); // Marty McFly</pre>
使用Call,Apply和Bind
從上面給出的例子你可能已經猜到了,通過一些語言級別的特性是允許我們在調用一個函數的時候指定它在運行時的this的。讓你給猜對了。call和apply方法存在于Function的prototype中,它們允許我們在調用一個方法的時候傳入一個this的值。
call方法的簽名中先是指定this參數,其后跟著的是方法調用時要用到的參數,這些參數是各自分開的。
someFn.call(this, arg1, arg2, arg3);apply的第一個參數同樣也是this的值,而其后跟著的是調用這個函數時的參數的數組。
someFn.apply(this, [arg1, arg2, arg3]);我們的doc和margy對象自己能進行時光旅行(譯注:即對象中有 timeTravel方法),然而愛因斯坦(譯注:Einstein,電影中博士的寵物,是一只狗)需要別人的幫助才能進行時光旅行,所以現在讓我們給之 前的doc對象(就是之前把marty.timeTravel賦值給doc.timeTravel的那個版本)添加一個方法,這樣doc對象就能幫助 einstein對象進行時光旅行了:
doc.timeTravelFor = function(instance, year) { this.timeTravel.call(instance, year); // alternate syntax if you used apply would be // this.timeTravel.apply(instance, [year]); };現在我們可以送愛因斯坦上路了:
var einstein = { firstName: "Einstein", lastName: "(the dog)" }; doc.timeTravelFor(einstein, 1985); // Einstein (the dog) is time traveling to 1985我知道這個例子讓你有些出乎意料,然而這已經足以讓你領略到把函數指派給其他對象調用的強大。
這里還有一種我們尚未探索的可能性。我們給marty對象加一個goHome的方法,這個方法是個讓marty回到未來的捷徑,因為它其實是調用了this.timeTravel(1985):
marty.goHome = function() { this.timeTravel(1985); }我們已經知道,如果把 marty.goHome 作為事件處理函數綁定到button的click事件上,那么this就是這個button。并且,button對象上也并沒有timeTravel這個 方法。我們可以用之前那種匿名函數的辦法來綁定事件處理函數,再在匿名函數里調用marty對象上的方法。不過,我們還有另外一個辦法,那就是bind函數:
flux.addEventListener("click", marty.goHome.bind(marty));bind函數其實是返回一個新函數,而這個新函數中的this值正是用bind的參數來指定的。如果你需要支持那些舊的瀏覽器(比如IE9以下的)你就需要用個bind方法的補丁(或者,如果你使用的是jQuery,那么你可以用$.proxy;另外underscore和lodash庫中也提供了_.bind)。
有一件事需要注意,如果你在一個原型方法上使用bind,那它會創建一個實例級別的方法,這樣就屏蔽了原型上的同名方法,你應該意識到這并不是個錯誤。關于這個問題的更多細節我在這篇文章里進行了描述。
4.) 函數聲明 vs 函數表達式
在JavaScript主要有兩種定義函數的方法(而ES6會在這里作介紹):函數聲明和函數表達式。
函數聲明不需要var關鍵字。事實上,正如 Angus Croll 所說:“把他當作變量聲明的兄弟是很有幫助的”。例如:
function timeTravel(year) { console.log(this.firstName + " " + this.lastName + " is time traveling to " + year); }上例中名叫timeTravel的函數不僅僅只在其被聲明的作用域內可見,而且對這個函數自身內部也是可見的(這一點對遞歸函數的調用尤為有用)。函數聲明其實就是命名函數,換句話說,上面的函數的name屬性就是timeTravel。
函數表達式是定義一個函數并把它賦值給一個變量。一般情況下,它們看起來會是這樣:
var someFn = function() { console.log("I like to express myself..."); };函數表達式也是可以被命名的,只不過不像函數聲明那樣,被命名的函數表達式的名字只能在 該函數內部的作用域中訪問(譯注:上例中的代碼,關鍵字function后面直接跟著圓括號,此時你可以用someFn.name來訪問函數名,但是輸出 將會是空字符串;而下例中的someFn.name會是”iHazName”,但是你卻不能在iHazName這個函數體之外的地方用這個名字來調用此函 數):
var someFn = function iHazName() { console.log("I like to express myself..."); if(needsMoreExpressing) { iHazName(); // the function's name can be used here } };// you can call someFn() here, but not iHazName() someFn();</pre>
函數表達式和函數聲明的討論遠不止這些,除此之外至少還有提升(hoisting)。提升是指函數和變量的聲明被解釋器移動到包含它們的作用域的頂部。雖然我們在這里沒有細說提升,但是務必讀一下Ben Cherry和Angus Croll對它的解讀。
5.) 具名和匿名函數
基于我們剛剛討論的,你肯定猜到所謂的匿名函數就是沒有名字的函數。大多數JavaScript開發者都能很快認出下例中第二個參數是一個匿名函數:
someElement.addEventListener("click", function(e) { // I'm anonymous! });而事實上我們的marty.timeTravel方法也是匿名的:
var marty = { firstName: "Marty", lastName: "McFly", timeTravel: function(year) { console.log(this.firstName + " " + this.lastName + " is time traveling to " + year); } }因為函數聲明必須有個名字,只有函數表達式才可能是匿名的。
6.) 自調用函數表達式
自從我們開始討論函數表達以來,有件事我就想立馬搞清楚,那就是自調用函數表達式( the Immediately Invoked Function Expression (IIFE))。我會在本文的結尾羅列幾篇對IIFE講解得不錯的文章。但簡而言之,它就是一個沒有賦值給任何變量的函數表達式,它并不等待稍后被調用, 而是在定義的時候就立即執行。下面這些瀏覽器控制臺的截圖能幫助我們理解:
首先讓我們輸入一個函數表達式,但是不把它賦值給任何變量,看看會發生什么:
無效的JavaScript語法——它其實是一個缺少名字的函數聲明。想讓它變成一個表達式,我們只需用一對圓括號把它包裹起來:
當把它變成一個表達式后控制臺立即返回給我們這個匿名函數(我們并沒有把這個函數賦值給 其他變量,但是,因為它是個表達式,我們只是獲取到了表達式的值)。然而,這只是實現了“自調用函數表達式”中的“函數表達式”部分。對于“自調用”這部 分,我們是通過給這個返回的表達式后面加上另外一對圓括號來實現的(就像我們調用任何其他函數一樣)。
“但是等等!Jim,我記得我以前在哪看到過把后面的那對圓括號放進表達式括號里面的情況。”你說得對,這種語法完全正確(因為Douglas Crockford 更喜歡這種語法,才讓它變得眾所周知):
這兩種語法都是可用的,然而我強烈建議你讀一下對這兩種用法有史以來最好的解釋。
OK,我們現在已經知道什么是IIFE了,那為什么說它很有用呢?
它可以幫助我們控制作用域,這是JavaScript中很重要的一部分!marty對象 一開始是被創建在一個全局作用域里。這意味著window對象(假定我們運行在瀏覽器里)里有個marty屬性。如果我們JavaScript代碼都照這 個寫法,那么很快全局作用域下就會被大量的變量聲明給填滿,污染了window對象。即使是在最理想的情況下,這都是不好的做法,因為把很多細節暴露給了 全局作用域,那么,當你在聲明一個對象時對它命名,而這個名字恰巧又和window對象上已經存在的一個屬性同名,那么會發生什么事呢?這個屬性會被覆蓋 掉!比如,你打算建個“阿梅莉亞·埃爾哈特(Amelia Earhart)”的粉絲網站,你在全局作用域下聲明了一個叫navigator的變量,那么我們來看一下這前后發生了些什么(譯注:阿梅莉亞·埃爾哈特 是一位傳奇的美國女性飛行員,不幸在1937年,當她嘗試全球首次環球飛行時,在飛越太平洋期間失蹤。當時和她一起在飛機上的導航員 (navigator)就是下面代碼中的這位佛萊得·努南(Fred Noonan)):
呃……
顯然,污染全局作用域是種不好的做法。JavaScript使用的是函數作用域(而不是 塊作用域,如果你是從C#或者Java轉過來的,這點一定要小心!)所以,阻止我們的代碼污染全局作用域的辦法就是創建一個新作用域,我們可以用IIFE 來達到這個目的,因為它里面的內容只會在它自己的函數作用域里。下面的例子里,我要先在控制臺查看一下window.navigator的值,再用一個 IIFE來包裹起具體的行為和數據,并把他賦值給amelia。這個IIFE返回一個對象作為我們的“應用程序作用域”。在這個IIFE里我聲明了一個 navigator變量,它不會覆蓋window.navigator的值。
作為一點額外的福利,我們上面創建的IIFE其實是JavaScript模塊模式(module pattern)的一個開端。在文章結尾有一些相關的鏈接,以便你可以繼續探索JavaScript的模塊模式。
7.) typeof運算符和Object.prototype.toString
終有一天你會遇到與此類似的情形,那就是你需要檢測一個函數傳進來的值是什么類型。typeof運算符似乎是不二之選,然而,它并不是那么可靠。例如,當我們對一個對象,一個數組,一個字符串,或者一個正則表達式使用typeof時,會發生什么呢?
好吧,至少它能把字符串從對象,數組和正則表達式中區分出來。幸虧我們還有其它辦法能從這些檢測的值里得到更多準確的信息。我們可以使用Object.prototype.toString函數并且應用上我們之前掌握的call方法的知識:
為什么我們要使用Object.prototype上的toString方法呢?因為它可能被第三方的庫或者我們自己的代碼中的實例方法給重載掉。而通過Object.prototype我們可以強制使用原始的toString。
如果你知道typeof會給你返回什么,并且你也不需要知道除此之外的其他信息(例如, 你只需要知道某個值是不是字符串),那么用typeof就再好不過了。然而,如果你想區分數組和對象或者正則表達式和對象等等的,那么就用 Object.prototype.toString吧。
接下來去哪里
我從其他的JavaScript開發者的真知灼見里受益匪淺,因此,請訪問下面的鏈接并且感謝一下他們吧。
Axel Rauschmayer’s great post on When is it OK to use == in JavaScript? (hint: never)
Fixing the typeof Operator by Angus Croll
Airbnb Github Issue comment that’s the single best explanation on IIFE parens placement
Function Declarations vs. Function Expressions – by Angus Croll
Getting Into Context Binds by yours truly
Immediately-Invoked Function Expression (IIFE) by Ben Alman
Learning JavaScript Design Patterns by Addy Osmani
Understanding the “this” keyword in JavaScript by Nicholas Bergson-Shilcock
Named function expressions demystified by Juriy “kangax” Zaytsev
Basic JavaScript for the impatient programmer by Axel Rauschmayer
JavaScript Scoping and Hoisting by Ben Cherry
JavaScript’s ‘this’ Keyword by David Shariff
What is the Execution Context & Stack in JavaScript? by David Shariff
原文出處: Jim Cowart 譯文出處: codingserf