當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語言正在默默完成它的工作。