回溯到1995年,當Brendan Eich在設計第一版JavaScript時,他搞錯了許多東西,當然這也包括曾屬於語言本身的一部分,例如Date
對象,對象相乘被自動轉換爲NaN等。然而現在回過頭看,語言最重要的部分都是設計合理的:對象、原型、具有詞法作用域的一等函數、默認情況下的可變性等。語言的骨架非常優秀,甚至超越了人們對它的初步印象。
話說回來,正是Brendan當初的設計錯誤才誕生了今天這篇文章。我們這次關注的目標非常小,在你使用這門語言多年後可能根本不會注意到這個問題,但是它 又如此重要,因爲我們可能會誤認爲這個錯誤就是語言設計中的“the good parts”(譯者注:請參考《JavaScript語言精粹》一書中附錄A:毒瘤中有關作用域的描述)。
今天我們一定要把這些與變量有關的問題拿下。
問題 #1:JS沒有塊級作用域
請看這樣一條規則:在JS函數中的var聲明,其作用域是函數體的全部。乍一聽沒什麼問題,但是如果碰到以下兩種情況就不會得到令人滿意的結果。
其一,在代碼塊內聲明的變量,其作用域是整個函數作用域而不是塊級作用域。
你之前可能沒有關注到這一點,但我擔心這個問題確實是你不能夠輕易忽視的。我們一起重現一下由這個問題引發的bug。
假如你現在的代碼使用了一個變量t
:
function runTowerExperiment(tower, startTime) {
var t = startTime;
tower.on("tick", function () {
... 使用了變量t的代碼 ...
});
... 更多代碼 ...
}
到目前爲止,一切都很順利。現在你想添加測量保齡球速度的功能,所以你在回調函數內部添加了一個簡單的if
語句。
function runTowerExperiment(tower, startTime) {
var t = startTime;
tower.on("tick", function () {
... 使用了變量t的代碼 ...
if (bowlingBall.altitude() <= 0) {
var t = readTachymeter();
...
}
});
... 更多代碼 ...
}
哦,親愛的,之前那段“使用了變量t的代碼”運行良好,現在你無意中添加了第二個變量t
,這裏的t
指向的是一個新的內部變量t
而不是原來的外部變量。
JavaScript中var
聲明的作用域像是Photoshop中的油漆桶工具,從聲明處開始向前後兩個方向擴散,直到觸及函數邊界才停止擴散。你想啊,這種變量t
的作用域甚廣,所以一進入函數就要馬上將它創建出來。這就是所謂的提升(hoisting)。變量提升就好比是,JS引擎用一個很小的代碼起重機將所有var
聲明和function
函數聲明都舉起到函數內的最高處。
現在看來,提升特性自有它的優點。如果沒有提升的動作,許多在全局作用域範圍內看似合理的完美技術在立即調用函數表達式(IIFE)中通通失效。但在上面演示的這種情況下,提升會引發令人不愉快的bug:所有使用變量t
進行的計算最終的結果都是NaN
。這種問題極難定位,尤其是當你的代碼量遠超上面這個玩具一般的示例,你會發狂到崩潰。
在原有代碼塊之前添加新的代碼塊會導致詭異的錯誤,這時候我就會想,到底是誰的問題,我的還是系統的?我們可不希望自己搞砸了系統。
而這個問題與接下來這個問題相比就相形見絀了。
問題 #2:循環內變量過度共享
你可以猜一下當執行以下這段代碼時會發生什麼,非常簡單:
var messages = ["嗨!", "我是一個web頁面!", "alert()方法非常有趣!"];
for (var i = 0; i < messages.length; i++) {
alert(messages[i]);
}
如果你一直跟隨這個系列的文章,你知道我喜歡在示例代碼中使用alert()
方法。可能你也知道alert()
不是一個好的API,它是一個同步方法,所以當彈出一個警告對話框時,輸入事件不會觸發,你的JS代碼,包括你的整個UI,直到用戶點擊OK確認之前完全處於暫停狀態。
請不要輕易使用alert()
來實現web頁面中的功能,我之所以在代碼中使用是因爲alert()
特性使它變成一個非常有教學意義的工具。
而且,如果放棄所有笨重的方法和糟糕的行爲就可以做出一隻會說話的貓,何樂而不爲呢?
var messages = ["喵!", "我是一隻會說話的貓!", "回調(callback)非常有趣!"];
for (var i = 0; i < messages.length; i++) {
setTimeout(function () {
cat.say(messages[i]);
}, i * 1500);
}
然而一定是哪裏不對,這隻會說話的貓並沒有按照預期連說三條消息,它說了三次“undefined”。
你知道問題出在哪裏麼?
你能看到樹上的毛毛蟲(bug)嗎?(圖片來源:nevil saveri)
事實上,這個問題的答案是,循環本身及三次timeout回調均共享唯一的變量i。當循環結束執行時,i的值爲3(因爲messages.length
的值爲3),此時回調尚未被觸發。
所以當第一個timeout執行時,調用cat.say(messages[i])
,此時i的值爲3,所以貓咪最終打印出來的是messages[3]
的值亦即undefined
。
解決這個問題有很多種方法(這裏有一種),但是你想,var
作用域規則接連給你添麻煩,如果能在第一時間徹底解決掉這個問題多好啊!
let是更完美的var
JavaScript的設計錯誤(其它語言也有,奈何JavaScript太突出)多半不能被修復。保持向後兼容性意味着永不改變JS代碼在Web平臺上的行爲,即使連標準委員會都無權要求修復JavaScript中自動插入分號這種怪異的特性;瀏覽器廠商也從來不會做出突破性的改變,因爲如此一來傷害的是他們的忠實用戶。
所以大約十年以前,Brendan Eich決定修復這個問題,但只有唯一的解決方案。
他添加了一個新的關鍵詞:let
。let
與var
一樣,也可以用來聲明變量,但它有着更好的作用域規則。
它看起來是這樣的:
let t = readTachymeter();
或者這樣的:
for (let i = 0; i < messages.length; i++) {
...
}
let
與var
還是有不同之處的,所以如果你只是在代碼中將var
全局搜索替換爲let
,一些依賴var
聲明的獨特特性(可能你不是故意這樣寫)的代碼可能無法正常運行。但對於絕大多數代碼來說,在ES6的新代碼模式下,你應該停止使用var
聲明變量,能使用let
就用吧!從現在起,請記住這句口號:“let
是更完美的var
”。
那到底let
和var
有什麼不同呢?非常高興你提出這個問題!
這一規則可以幫助你捕捉bug,除了NaN
錯誤以外,每一個異常都會在當前行拋出。
-
let
聲明的變量擁有塊級作用域。也就是說用let
聲明的變量的作用域只是外層塊,而不是整個外層函數。let
聲明仍然保留了提升的特性,但不會盲目提升。在runTowerExperiment
這個示例中,通過將var
替換爲let
可以快速修復問題,如果你處處使用let
進行聲明,就不會遇到類似的bug。 -
let
聲明的全局變量不是全局對象的屬性。這就意味着,你不可 以通過window.變量名
的方式訪問這些變量。它們只存在於一個不可見的塊的作用域中,這個塊理論上是Web頁面中運行的所有JS代碼的外層塊。 -
形如
for (let x...)
的循環在每次迭代時都爲x創建新的綁定。這是一個非常微妙的區別,拿我們的會說話的貓的例子來說,如果一個
for (let...)
循環執行多次並且循環保持了一個閉包,那麼每個閉包將捕捉一個循環變量的不同值作爲副本,而不是所有閉包都捕捉循環變量的同一個值。所以在會說話的貓示例中,也可以通過將
var
替換爲let
修復bug。這種情況適用於現有的三種循環方式:
for-of
、for-in
、以及傳統的用分號分隔的類C循環。 -
let
聲明的變量直到控制流到達該變量被定義的代碼行時纔會被裝載,所以在到達之前使用該變量會觸發錯誤。舉個例子:function update() { console.log("當前時間:", t); // 引用錯誤(ReferenceError) ... let t = readTachymeter(); }
不可訪問的這段時間變量一直處於作用域中,但是尚未裝載,它們位於臨時死區(Temporal Dead Zone,簡稱TDZ)中。我一直想用科幻小說來類比這個腦洞大開的行話,但是還沒想好怎麼搞。
(脆弱的性能細節:在大多數情況下,查看代碼就可以區分聲明是否已經執行,所以事實上,JavaScript引擎不需要在每次代碼運行時都額外執行 一次變量可訪問檢查來確保變量已經被初始化。然而在閉包內部有時不是透明的,這時JavaScript引擎將會做一個運行時檢查,也就意味着
let
相對var
而言比較慢。)(脆弱的平行宇宙作用域細節:在一些編程語言中,一個變量的作用域始於聲明之處,而非前後覆蓋整個封閉代碼塊。標準委員會曾考慮過將這種作用域準則賦予
let
關鍵詞,但是一旦使用這種準則,原本提前使用變量的語句會導致引用錯誤(ReferenceError),現在該語句不位於let t
的聲明作用域中,根本不會引用此處的變量t
,而是引用外層作用域的相應變量。但是這個方法無法與閉包和函數提升很好得結合,所以該提案最終被否決了。) -
用
let
重定義變量會拋出一個語法錯誤(SyntaxError)。這一條規則也可以幫助你檢測瑣碎的小問題。誠然,這亦是
var
與let
的不同之處,當你全局搜索var
替換爲let
時也會導致let
重定義語法錯誤,因爲這一規則對全局let
變量也有效。如果你的多個腳本中都聲明瞭相同的全局變量,你最好繼續用
var
聲明這些變量。如果你換用了let
,後加載的腳本都會執行失敗並拋出錯誤。或者你可以考慮使用ES6內建的模塊機制,後面的文章中會詳細講解。
(脆弱的語法細節:
let
是一個嚴格模式下的保留詞。在非嚴格模式下,出於向後兼容的目的,你仍可以用let
命名來聲明變量、函數和參數,雖然你不會犯傻,但是你確實可以編寫var let = 'q';
這樣的代碼!不過let let;
無論如何都是非法的。)
在那些不同之外,let
和var
幾乎很相似了。舉個例子,它們都支持使用逗號分隔聲明多重變量,它們也都支持解構特性。
注意,class
類聲明的行爲與var
不同而與let
一致。如果你加載一段包含同名類的腳本,後定義的類會拋出重定義錯誤。
const
是的,還有一個新的關鍵詞!
ES6引入的第三個聲明類關鍵詞與let
類似:const
。
const
聲明的變量與let
聲明的變量類似,它們的不同之處在於,const
聲明的變量只可以在聲明時賦值,不可隨意修改,否則會導致SyntaxError
(語法錯誤)。
const MAX_CAT_SIZE_KG = 3000; // 正確
MAX_CAT_SIZE_KG = 5000; // 語法錯誤(SyntaxError)
MAX_CAT_SIZE_KG++; // 雖然換了一種方式,但仍然會導致語法錯誤
當然,規範設計的足夠明智,用const
聲明變量後必須要賦值,否則也拋出語法錯誤。
const theFairest; // 依然是語法錯誤,你這個倒黴蛋
神祕的代理命名空間
“命名空間是一種絕妙的理念,我們應當多加利用!”——Tim Peters,“這是Python之禪”
嵌套作用域是編程語言背後的核心理念之一,這個理念始於大約57年前的ALGOL,現在回過頭看當時的決定無比正確。
在ES3之前,JavaScript中只有全局作用域和函數作用域。(讓我們忽略with
語句吧。)ES3中引入了try-catch
語句,意味着語言中誕生一種新的作用域,只用於catch塊中的異常變量。ES5添加了用於嚴格的eval()
方法的作用域。ES6添加了塊作用域,for循環作用域,新的全局let
作用域,模塊作用域,以及求參數的默認值時使用的附加作用域。
所 有自ES3開始添加的其它作用域非常重要,它們的加入使得JavaScript面向過程與面向對象的特性運行得猶如閉包一樣平穩、精準,當然閉包也可以無 縫銜接這些作用域實現各種功能。或許你在閱讀這篇文章之前從未注意到這些作用域規則的存在,如果真的這樣,那這門語言就恰如其分地完成了它的本職工作。
我現在可以使用let和const了麼?
是的。如果要在web上使用let
和const
特性,你需要使用一個諸如Babel、Traceur或TypeScript的ES6轉譯器。(Babel和Traceur暫不支持臨時死區特性。)
io.js支持let
和const
,但是隻在嚴格模式下編碼可以使用。Node.js同樣支持,但是需要啓用--harmony
選項。