[ES6深度解析]13:let const

Max力出奇跡發表於2021-08-27

當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引擎用一個小小的程式碼起重機將每個varfunction提升到外圍函式的頂部。

變數提升有它的優點。如果沒有它,許多在全域性作用域中工作良好的完美的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++) {
  ...
}

letvar是不同的,所以如果你只是做一個全球搜尋替換整個程式碼,可以破壞部分的程式碼(可能是無意中)。但在大多數情況下,在新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-offor-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語言正在默默完成它的工作。

相關文章