> 原文:http://www.infoq.com/cn/articles/es6-in-depth-let-and-const
[TOC]
今天我想要談論的是一個集謙遜與驚人的野心于一身的新特性。
回溯到1995年,當Brendan Eich在設計第一版JavaScript時,他搞錯了許多東西,當然這也包括曾屬于語言本身的一部分,例如`Date`對象,對象相乘被自動轉換為NaN等。然而現在回過頭看,語言最重要的部分都是設計合理的:對象、原型、具有詞法作用域的一等函數、默認情況下的可變性等。語言的骨架非常優秀,甚至超越了人們對它的初步印象。
話說回來,正是Brendan當初的設計錯誤才誕生了今天這篇文章。我們這次關注的目標非常小,在你使用這門語言多年后可能根本不會注意到這個問題,但是它 又如此重要,因為我們可能會誤認為這個錯誤就是語言設計中的“the good parts”(譯者注:請參考《JavaScript語言精粹》一書中附錄A:毒瘤中有關作用域的描述)。
今天我們一定要把這些與變量有關的問題拿下。
## 問題 #1:JS沒有塊級作用域
請看這樣一條規則:在JS函數中的var聲明,其[作用域](http://robertnyman.com/2008/10/09/explaining-javascript-scope-and-closures/)是函數體的全部。乍一聽沒什么問題,但是如果碰到以下兩種情況就不會得到令人滿意的結果。
其一,在代碼塊內聲明的變量,其作用域是整個函數作用域而不是塊級作用域。
你之前可能沒有關注到這一點,但我擔心這個問題確實是你不能夠輕易忽視的。我們一起重現一下由這個問題引發的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](https://en.wikipedia.org/wiki/Immediately-invoked_function_expression))中通通失效。但在上面演示的這種情況下,提升會引發令人不愉快的bug:所有使用變量`t`進行的計算最終的結果都是`NaN`。這種問題極難定位,尤其是當你的代碼量遠超上面這個玩具一般的示例,你會發狂到崩潰。
在原有代碼塊之前添加新的代碼塊會導致詭異的錯誤,這時候我就會想,到底是誰的問題,我的還是系統的?我們可不希望自己搞砸了系統。
而這個問題與接下來這個問題相比就相形見絀了。
## 問題 #2:循環內變量過度共享
你可以猜一下當執行以下這段代碼時會發生什么,非常簡單:
~~~
var messages = ["嗨!", "我是一個web頁面!", "alert()方法非常有趣!"];
for (var i = 0; i < messages.length; i++) {
alert(messages[i]);
}
~~~
如果你一直跟隨這個[系列](http://www.infoq.com/cn/es6-in-depth/)的文章,你知道我喜歡在示例代碼中使用`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);
}
~~~
[點擊查看這段代碼錯誤的運行結果!](http://jsfiddle.net/8t2q8wfr/4/)
然而一定是哪里不對,這只會說話的貓并沒有按照預期連說三條消息,它說了三次“undefined”。
你知道問題出在哪里么?

你能看到樹上的毛毛蟲(bug)嗎?(圖片來源:[nevil saveri](https://www.flickr.com/photos/nevilzaveri/7994751456/))
事實上,這個問題的答案是,循環本身及三次timeout回調均共享唯一的變量i。當循環結束執行時,i的值為3(因為`messages.length`的值為3),此時回調尚未被觸發。
所以當第一個timeout執行時,調用`cat.say(messages[i])`,此時i的值為3,所以貓咪最終打印出來的是`messages[3]`的值亦即`undefined`。
解決這個問題有很多種方法([這里有一種](http://jsfiddle.net/sybn4h33/3/)),但是你想,`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`幾乎很相似了。舉個例子,它們都支持使用逗號分隔聲明多重變量,它們也都支持[解構](http://www.infoq.com/cn/articles/es6-in-depth-destructuring)特性。
注意,`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](https://zh.wikipedia.org/wiki/ALGOL),現在回過頭看當時的決定無比正確。
在ES3之前,JavaScript中只有全局作用域和函數作用域。(讓我們忽略`with`語句吧。)ES3中引入了`try-catch`語句,意味著語言中誕生一種新的作用域,只用于catch塊中的異常變量。ES5添加了用于嚴格的`eval()`方法的作用域。ES6添加了塊作用域,for循環作用域,新的全局`let`作用域,模塊作用域,以及求參數的默認值時使用的附加作用域。
所 有自ES3開始添加的其它作用域非常重要,它們的加入使得JavaScript面向過程與面向對象的特性運行得猶如閉包一樣平穩、精準,當然閉包也可以無 縫銜接這些作用域實現各種功能。或許你在閱讀這篇文章之前從未注意到這些作用域規則的存在,如果真的這樣,那這門語言就恰如其分地完成了它的本職工作。
## 我現在可以使用let和const了么?
是的。如果要在web上使用`let`和`const`特性,你需要使用一個諸如[Babel](http://babeljs.io/)、[Traceur](https://github.com/google/traceur-compiler#what-is-traceur)或[TypeScript](http://www.typescriptlang.org/)的ES6轉譯器。(Babel和Traceur暫不支持臨時死區特性。)
io.js支持`let`和`const`,但是只在嚴格模式下編碼可以使用。Node.js同樣支持,但是需要啟用`--harmony`選項。
[九年前](https://developer.mozilla.org/en-US/docs/Web/JavaScript/New_in_JavaScript/1.7),Brendan Eich在Firefox中實現了初版的`let`關鍵詞。這個特性在隨后的標準化進程中徹底地被重新設計了。Shu-yu Guo正在按照新標準對原有實現進行升級,該項目由Jeff Walden和其他人做代碼審查。
好的,我們正處于沖刺階段,漫長而艱難的ES6特性之旅的終點離我們不遠了,成功就在眼前。兩周后,我們將實現大家最期待的ES6特性(譯者注:作者7月31日發文,根據推算目前應該已經完成)。下一次,我們繼續拓展ES6中類的特性`super`,記得回來加入我們跟隨Eric Faust一起《深入淺出ES6:子類》。