下面我想要介紹的是一個謙卑的驚世之作。
在 1995 年布蘭登·艾克設(shè)計 JavaScript 的第一個版本時,他得到了許多錯誤,包括有些從那以后已經(jīng)成為語言的一部分的東西,比如如果你不慎把 Date object 和 objects 相乘,它會自動轉(zhuǎn)換成 NaN。然而,他所做對的事情在事后來看是極其重要的:對象、原型、有詞法作用域的一流功能、可變性默認(rèn)。語言具有良好的骨骼框架。這比以前實現(xiàn)的任何語言都要好。
不過,布蘭登做了一個特殊的決策,也是本章的重點內(nèi)容——一個我認(rèn)為可以被公開定性為錯誤的決定。這是一個小事情,一個微妙的事情。你可能已經(jīng)使用這個語言多年但從未注意到它。但是它很重要,因為這個錯誤存在于現(xiàn)在我們所認(rèn)為的“好的部分”里面。
它必須與變量一起工作。
這個規(guī)則聽起來很無辜。在一個 JS 函數(shù)內(nèi)聲明的一個 var 的作用域是那個函數(shù)的整個主體。但是這里有兩個方式可能讓它產(chǎn)生無病呻吟的效果。
一是在塊中聲明的變量的作用域不只是這個塊。它是整個函數(shù)。
你可能從未意識到這個。我恐怕這是你容易忽略的東西之一。我們來看一個場景,它導(dǎo)致了一個棘手的問題。
你現(xiàn)在的代碼中有一個變量,名為 t:
function runTowerExperiment(tower, startTime) {
var t = startTime;
tower.on("tick", function () {
... code that uses t ...
});
... more code ...
}
到目前為止一切都很好?,F(xiàn)在你想要添加保齡球速度測量,所以你在內(nèi)部回調(diào)函數(shù)中加入了一個if-語句。
function runTowerExperiment(tower, startTime) {
var t = startTime;
tower.on("tick", function () {
... code that uses t ...
if (bowlingBall.altitude() <= 0) {
var t = readTachymeter();
...
}
});
... more code ...
}
哦,親愛的。你在不知不覺中添加了第二個名為 t 的變量?,F(xiàn)在來看,之前工作地好好的“變量 t”指向新的內(nèi)部變量,而不再是現(xiàn)有的外部變量。
JavaScript 中 var 的作用域就像是 Photoshop 中的油漆桶工具。它同時向聲明的前后兩個方向延伸,直到到達(dá)一個函數(shù)邊界。因為這個變量 t 的作用域向后延伸的太長了,所以我們在輸入函數(shù)時必須盡快創(chuàng)建它。這就叫做變量提升(hoisting)。我喜歡把這想象成 JS 引擎利用一個微小的代碼起重機把每個 var 和函數(shù)提升到封閉函數(shù)頂。
現(xiàn)在,變量提升有了它的優(yōu)點。沒有了它,許多在全球作用域內(nèi)工作良好的 cromulent 技術(shù)就無法在IIFE內(nèi)工作。但在這種情況下,變量提升會帶來一個討厭的錯誤:你所有用了 t 的計算都會產(chǎn)生 NaN。這很難追查,特別是當(dāng)你的代碼比這個玩具例子更長的時候。
添加一個新的代碼塊會給該塊之前的代碼帶來一個難以理解的錯誤。只有我有這樣的問題,還是這真的很奇怪?我們不想影響到前面的原因。
但是與第二個 var 問題相比,這真的是小菜一碟。
你可以猜猜你運行這個代碼時會發(fā)生什么。這非常容易想。
var messages = ["Hi!", "I'm a web page!", "alert() is fun!"];
for (var i = 0; i < messages.length; i++) {
alert(messages[i]);
}
如果你一直關(guān)注 ES6,你會知道我喜歡用 alert() 作為示例代碼。也許你也知道 alert() 是一個糟糕的 API。它是同步的,所以當(dāng)一個 alert 可見時,輸入的事件不會傳遞。你的 JS 代碼——其實是你的整個界面——在用戶點擊 OK 之前基本暫停了。
你在 web 頁面上做的所有的操作,都可以使用 alert() 作為錯誤提示。我使用它是因為我認(rèn)為所有這些相同的事情會讓 alert() 成為一個有效的調(diào)試工具。
不過,我可能會被說服去放棄所有不良行為…如果這意味著我可以做一只會說話的貓。
var messages = ["Meow!", "I'm a talking cat!", "Callbacks are fun!"];
for (var i = 0; i < messages.length; i++) {
setTimeout(function () {
cat.say(messages[i]);
}, i * 1500);
}
但有些事情是錯誤的。貓會說三次“未定義”,而不是依次說出這三條信息。
你能找出下圖的一個 “bug” 嗎?
http://wiki.jikexueyuan.com/project/es-six-deeply/images/const.jpg" alt="" />圖片來源:內(nèi)維爾·賽沃瑞"
這里的問題是只有一個變量 i。它被循環(huán)本身和三個超時回調(diào)共享。當(dāng)循環(huán)完成運行,i 的值是 3(因為 messages.length 是 3)而且所有的回調(diào)函數(shù)都還沒有被回調(diào)。
所以當(dāng)?shù)谝粋€超時觸發(fā)并調(diào)回 cat.say(messages[i]) 是用了未被定義的 messages[3]。
解決這個問題的方法有許多(這里有一個),但這是一個 var 作用域規(guī)則帶來的二次問題。如果從一開始就沒有過這種問題,那便真是太好了。
在大多數(shù)情況下,JavaScript(還有其他編程語言,但尤其是 JavaScript)中的設(shè)計錯誤無法被修復(fù)。向后兼容性意味著永遠(yuǎn)不改變現(xiàn)有的 JS 代碼在網(wǎng)絡(luò)上的行為。即使是標(biāo)準(zhǔn)委員會也沒有辦法修復(fù) JavaScript 自動插入分號這件怪事。瀏覽器制造商根本無法實現(xiàn)突破性改革,因為那種改變根本就是虐待他們的用戶。
所以大約十年前 Brendan Eich 決定解決這個問題時,也就只有一個方法。
他添加了新的關(guān)鍵詞 let,let 就像 var 一樣可用來聲明變量,但它擁有更好的作用域規(guī)則。
它看起來是這樣的:
let t = readTachymeter();
let 和 var 是不同的,如果你只在你的代碼中做了一個全局搜索替換,可能就會由于 var 的原因破壞了你的部分代碼(可能是無意地)。但在大多數(shù)情況下,在新的 ES6 代碼中你應(yīng)該使用 let 代替 var。所以我們的口號是:“l(fā)et 是新型 var”。
let 和 var 之間的真正區(qū)別是什么?很高興你問了這個問題!
let 變量是有塊作用域的。用 let 定義的變量作用域只是封閉塊,而不是整個封閉函數(shù)?,F(xiàn)在仍有 let 變量提升,但是不再是隨意提升。在 TherunTowerExperiment 例子中,可以用把 var 替換成 let 的方法來進(jìn)行簡單修復(fù)。如果你在每個地方都是使用的 let,你就不會有這種錯誤。
全局 let 變量不是全局對象的屬性。也就是說,你不能通過寫 window.variableName 訪問它們。相反,它們存在于一個無形塊作用域內(nèi),我們可以想象成一個網(wǎng)頁運行的所有的 JS 代碼都被封閉在了一起。
這是一個非常微妙的差異。這意味著如果一個 for(let...)循環(huán)執(zhí)行多次并且該循環(huán)包含一個閉包,就像我們之前舉的會說話的貓的例子,每個閉包都會捕獲一個不同副本的循環(huán)變量而不是每個閉包都捕獲相同的循環(huán)變量。所以示例會說話的貓也可以通過用 let 替換 var 來完成修復(fù)。
這適用于所有三種 for 循環(huán):for–of,for–in 和加上分號的老式 C 類。
function update() {
console.log("current time:", t); // ReferenceError
...
let t = readTachymeter();
}
這個規(guī)則是幫助你定位錯誤。你會在有問題的代碼上獲得一個提醒而不是結(jié)果 NaN。
未初始化變量在作用域內(nèi)的時期稱為暫時性死區(qū)。我一直在等待這個激發(fā)性的術(shù)語可以躍位到科幻小說。但是什么都還沒有發(fā)生。
(脆脆的性能細(xì)節(jié):在大多數(shù)情況下,你可以說出是否聲明已經(jīng)運行了或只是在查看代碼,所以 JavaScript 引擎真的不需要在每次變量訪問時都進(jìn)行一次額外檢查來確保它已經(jīng)初始化了。然而有時在閉包內(nèi)這并不清楚。在這種情況下 JavaScript 引擎會進(jìn)行運行時檢查。這代表與 var 相比 let 需要更長的訪問時間。)
(交替全局作用域細(xì)節(jié):在某些程序設(shè)計語言中,變量的作用域在聲明點開始,而不是到達(dá)后面覆蓋整個封閉塊。標(biāo)準(zhǔn)委員考慮過為 let 使用這個作用域規(guī)則。但這樣使用 t 就會導(dǎo)致引用錯誤,隨后的 let t 就不在作用域中了,所以它根本不會再查閱那個變量。它可以在一個封閉作用域中查閱 t,但這個方法在有閉包或有函數(shù)變量提升的情況下不好用,所以它最終被拋棄了。)
這條規(guī)則也有助于你發(fā)現(xiàn)微小錯誤。但這是在你嘗試 let-to-var 轉(zhuǎn)換時容易導(dǎo)致問題的不同之處,因為它提供了甚至全局的 let 變量。
如果你有一些都是聲明相同全局變量的腳本,你最好使用 var。如果你用了 let,任何腳本在二次加載的時候都會出現(xiàn)錯誤無法運行。
或者運用 ES6 模塊。但這又是另一個故事了。
(語法細(xì)節(jié):let 是一個嚴(yán)格模式下的保留詞。在非嚴(yán)格模式的代碼中,為了向后兼容,你仍然可以聲明變量、函數(shù)和名為 let 的實參——你可以寫 var let = 'q';!,而不是你原本想寫的那樣。let let; 也是不允許的。)
除了這些差異,let 和 var 幾乎是一樣的。它們都支持聲明多個用逗號隔開的變量,例如他們都支持解構(gòu)。
注意,類聲明的行為和 let 很像而和 var 不像。如果你加載了一個包含多次類的腳本,在第二次時你會因為沒有重新聲明類而得到一個錯誤。
對了,還有一件事!
ES6 還引入了第三級關(guān)鍵字,你可以在 let 后使用:const。
用 const 聲明的變量就像 let,除非你不能指定它們或除非你在它們的聲明點進(jìn)行了聲明,這樣會產(chǎn)生語法錯誤。
const MAX_CAT_SIZE_KG = 3000; //