當Brendan Eich在1995年設計了JavaScript的第一個版本時,他犯了很多錯誤,包括從那時起就成為該語言一部分的一些錯誤,比如Date物件和當你不小心將它們相乘時物件會自動轉換為NaN。然而,事後看來,他做對的事情都是非常重要的事情:物件
;原型
;具有詞法作用域的一級函式
;預設可變性
。這種語言很好。比大家一開始意識到的要好。
儘管如此,Brendan還是做出了一個與今天的文章相關的特殊設計決定——我認為這個決定可以被定性為一個錯誤。這是一件小事。一種微妙的東西。你可能用了好幾年,甚至都沒注意到它。但這很重要,因為這個錯誤出現在我們現在認為是“好的部分”的語言方面。
它和變數有關。
問題1:塊{}不是作用域
這條規則聽起來很無害:在JS函式中宣告的var的作用域就是該函式的整個函式體。但這有兩種讓人抱怨的後果。
一、在塊中宣告的變數的作用域不僅僅是塊本身。它是整個函式。
你可能從來沒有注意到這一點。恐怕這是你無法忘記的事情之一。讓我們來看看一個場景,它會導致一個棘手的錯誤。假設你有一些使用名為t
的變數的現有程式碼:
function runTowerExperiment(tower, startTime) {
var t = startTime;
tower.on("tick", function () {
... code that uses t ...
});
... more code ...
}
到目前為止,一切都很好。現在你想要新增保齡球速度測量值,因此你向內部回撥函式新增了一個小小的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
的變數。現在,在“使用t的程式碼”中(之前執行良好),t指向新的內部變數t,而不是現有的外部變數。
JavaScript中的var
的作用域就像Photoshop中的油漆桶工具。它從宣告開始,在兩個方向上擴充套件,向前和向後,一直擴充套件到函式邊界({
或}
)。由於變數t的作用域向後擴充套件了這麼多,所以必須在我們一進入函式時就建立它。這叫做變數提升
(hoisting)。我喜歡想象JS引擎用一個小小的程式碼起重機將每個var
和function
提升到外圍函式的頂部。
變數提升
有它的優點。如果沒有它,許多在全域性作用域中工作良好的完美的cromulent技術將無法在IIFE
(立即執行函式)中工作。但是在上面的程式碼中,變數提升
會導致一個嚴重的錯誤:使用t的所有計算將開始產生NaN。它也很難跟蹤,特別是如果你的程式碼比這個demo更大。
但與第二個var
問題相比,這是小菜一碟。
問題2:迴圈中的變數過度共享
你可以猜到執行這段程式碼時會發生什麼。很簡單:
var messages = ["Hi!", "I'm a web page!", "alert() is fun!"];
for (var i = 0; i < messages.length; i++) {
alert(messages[i]);
}
執行這段程式碼,瀏覽器會順序彈出3次alert框,訊息內容分別為"Hi!", "I'm a web page!", "alert() is fun!"。現在我們把程式碼稍微改動一下:
var messages = ["Meow!", "I'm a talking cat!", "Callbacks are fun!"];
for (var i = 0; i < messages.length; i++) {
setTimeout(function () {
console.log(messages[i]);
}, i * 1500);
}
再次執行發現,結果出乎預料。瀏覽器沒有按順序說出列印三條資訊,而是列印了三次undefined
。你能發現漏洞嗎?
這裡的問題是隻有一個變數i
。它由迴圈本身和所有三個setTimeout
回撥函式共享。當迴圈執行結束時,i的值為3(因為messages.length
為3),並且此時還沒有呼叫任何回撥函式。(非同步,事件迴圈)
因此,當第一個setTimeout
回撥函式觸發並呼叫console.log(messages[i])
時,它使用的是messages[3]
(messages[3]肯定是undefined)
有很多種解決的方法,下面是一種:
var messages = ["Meow!", "I'm a talking cat!", "Callbacks are fun!"];
for (var i = 0; i < messages.length; i++) {
setTimeout((function (index) {
return function() {console.log(messages[index])};
})(i), i * 1500);
}
如果一開始就沒有這種問題,那就太好了。
let, const是新的var
在大多數情況下,JavaScript(也包括其他程式語言,尤其是JavaScript)中的設計錯誤是無法修復的。向後相容性意味著永遠不會改變Web上現有JS程式碼的行為。即使是標準委員會也沒有能力,比如說,解決JavaScript自動分號插入的奇怪問題。瀏覽器製造商不會實現破壞性的更改,因為這種更改會懲罰使用者。大約十年前,當Brendan Eich決定解決這個問題時,只有一種方法。
他新增了一個新的關鍵字let
,可以用來宣告變數,就像var
一樣,但是有更好的作用域規則。
let t = readTachymeter();
for (let i = 0; i < messages.length; i++) {
...
}
let
和var
是不同的,所以如果你只是做一個全球搜尋替換整個程式碼,可以破壞部分的程式碼(可能是無意中)。但在大多數情況下,在新ES6程式碼,你應該停止使用var
,並在之前使用var
的位置使用let
。因此有這樣的口號:“let
是新的var
”。
let和var之間到底有什麼區別?
-
let變數是塊作用域的。
用let宣告的變數的作用域只是封閉的塊,而不是整個封閉的函式。使用let還是會有變數提升,但不是不分青紅皁白。runTowerExperiment示例可以通過簡單地將var更改為let來修復。如果你在任何地方都使用let,你就不會有那種bug了。 -
全域性let變數不是全域性物件的屬性
也就是說,您不會通過寫入window.variableName
來訪問它們。相反,它們存在於一個無形的塊的範圍內,該塊理論上包含了在網頁中執行的所有JS程式碼。 -
for (let x…)形式的迴圈在每次迭代中為x建立一個新的繫結。
這是一個非常微妙的差別。這意味著,如果for (let…)
迴圈執行多次,並且該迴圈包含一個閉包,就像在我們正在討論的console.log
示例中那樣,每個閉包將捕獲迴圈變數的不同副本,而不是所有閉包捕獲相同的迴圈變數。所以上面那個例子可以用let替換var就可以解決錯誤:
var messages = ["Meow!", "I'm a talking cat!", "Callbacks are fun!"];
for (let i = 0; i < messages.length; i++) {
setTimeout(function () {
console.log(messages[i]);
}, i * 1500);
}
這適用於所有三種for迴圈:for-of
、for-in
和帶有分號的老式C型別迴圈。
- 在到達let變數宣告之前嘗試使用它是錯誤的。
在控制流到達宣告變數的程式碼行之前,變數是未初始化的。例如:
function update() {
console.log("current time:", t); // ReferenceError
...
let t = readTachymeter();
}
這條規則是用來幫助你捕捉bug的。你將在問題所在的程式碼行上得到一個異常,而不是NaN
。
當變數在作用域內但未初始化時,這個時間段稱為臨時死區(temporal dead zone)。我一直在期待這句有靈感的行話能一躍成為科幻小說。還沒有。
一個瑣碎的效能細節:在大多數情況下,你可以通過檢視程式碼來判斷宣告是否已經執行,因此JavaScript引擎實際上不需要在每次訪問變數時執行額外的檢查,以確保它已初始化。然而,在一個封閉的內部,有時是不清楚的。在這些情況下,JavaScript引擎將執行執行時檢查。這意味著let比var要慢。
一個複雜的交替域作用域細節:在一些程式語言中,變數的作用域從宣告點開始,而不是向後覆蓋整個封閉塊。標準委員會考慮對let
使用這種範圍規則。這樣的話,t
的使用導致這裡的ReferenceError不會在後面的let t
的範圍內,所以它根本不會引用那個變數。它可以指封閉作用域中的t
。但這種方法不適用於閉包或函式提升,因此最終被放棄。
- 用let重新宣告變數是一個SyntaxError錯誤。
這條規則也可以幫助你發現微小的錯誤。不過,如果你嘗試全域性的let-to-var
轉換,這種差異很可能會給你帶來一些問題,因為它甚至適用於全域性的let
變數。
如果你有幾個指令碼都宣告瞭相同的全域性變數,你最好繼續使用var。如果切換到let,那麼無論第二次載入哪個指令碼都會失敗並出現錯誤。
或者使用ES6模組。
一個的語法細節:let
是嚴格模式程式碼中的保留字。在非嚴格模式的程式碼中,為了向後相容,你仍然可以宣告變數、函式和名為let的引數——你可以寫var let = 'q'
! let let = 1
這是不允許的。
除了這些區別之外,let和var幾乎是相同的。例如,它們都支援宣告用逗號分隔的多個變數,並且都支援解構。注意,類宣告的行為類似於let,而不是var。如果你多次載入一個包含類的指令碼,第二次重新宣告類時就會得到一個錯誤。
const
ES6還引入了第三個可與let
一起使用的關鍵字:const
。
用const宣告的變數就像let一樣,你只能在它們被宣告的地方賦值。否則是一個SyntaxError。
const MAX_CAT_SIZE_KG = 3000; // ?
MAX_CAT_SIZE_KG = 5000; // SyntaxError
MAX_CAT_SIZE_KG++; // nice try, but still a SyntaxError
很明顯,不能在沒有賦值的情況下宣告const。
const theFairest; // SyntaxError, you troublemaker
祕密特工:名稱空間(namespace)
“Namespaces are one honking great idea—let’s do more of those!” —Tim Peters, “The Zen of Python”
在幕後,巢狀作用域是程式語言構建的核心概念之一。從什麼時候開始就這樣了,ALGOL?大概57年吧。今天更是如此。
在ES3之前,JavaScript只有全域性作用域
和函式作用域
。(讓我們忽略with
語句。)ES3引入了try-catch
語句,這意味著新增了一種新的作用域,僅用於catch塊中的異常變數。ES5新增了一個由strict eval()
使用的作用域。ES6新增了塊作用域
、for-loop作用域
、新的全域性let作用域
、模組作用域
以及在計算引數的預設值時使用的附加作用域
。
從ES3開始新增的所有額外作用域都是必要的,以使JavaScript的程式導向和麵向物件特性像閉包一樣流暢、精確和直觀地工作,並與閉包無縫合作。也許你在今天之前從未注意過這些範圍規則。如果是這樣的話,JS語言正在默默完成它的工作。