[ES6深度解析]13:let const

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