當(dāng)然不是說用完整的兩個(gè)月時(shí)間來理解 let,而是零零碎碎地理解,同時(shí)我還要想著怎么寫出一篇文章把這個(gè)事情說清楚。
在 let 剛出來的時(shí)候,我就「以為」我理解了 let。然鵝在過去的兩個(gè)月里,我對(duì) let 的理解發(fā)生了一波三折的變化。
我寫這篇文章,是希望我的學(xué)習(xí)過程,能對(duì)你自學(xué)有幫助。
初識(shí) let
跟很多人一樣,我第一次了解 let 的特性是從 MDN 的文檔:

我得到的信息有這么幾條:
- let 聲明的變量的作用域是塊級(jí)的;
- let 不能重復(fù)聲明已存在的變量;
- let 有暫時(shí)死區(qū),不會(huì)被提升。
大部分人應(yīng)該都是這么認(rèn)為的,我也是這么理解的。
這個(gè)理解「沒有問題」,但是不夠「全面和深刻」。
第一次質(zhì)疑
我第一次質(zhì)疑我的理解是在遇到 for 循環(huán)的時(shí)候,代碼如下。
// 代碼段1
var liList = document.querySelectorAll('li') // 共5個(gè)li
for( var i=0; i<liList.length; i++){
liList[i].onclick = function(){
console.log(i)
}
}
大家都知道依次點(diǎn)擊 li 會(huì)打印出 5 個(gè) 5。如果把 var i 改成 let i,就會(huì)分別打印出 0、1、2、3、4:
// 代碼段2
var liList = document.querySelectorAll('li') // 共5個(gè)li
for( let i=0; i<liList.length; i++){
liList[i].onclick = function(){
console.log(i)
}
}
然而,用我之前的知識(shí)來理解這個(gè)代碼是不能自圓其說的。因?yàn)榇a中依然只聲明了一個(gè) i,在 for 循環(huán)結(jié)束后,i 的值還是會(huì)變成 5 才對(duì)。
這說明我的理解有誤。
于是我去看 MDN 的例子,發(fā)現(xiàn)雞賊的 MDN 巧妙地避開了這個(gè)問題,它的例子是這樣的:

你看 MDN 的例子,在每次循環(huán)的時(shí)候用 let j 保留的 i 的值,所以在 i 變化的時(shí)候,j 并不會(huì)變化。而console.log 的是 j,所以不會(huì)出現(xiàn) 5 個(gè) 5。
為什么 MDN 要故意聲明一個(gè) j 呢,為什么不直接用 i 呢?
我猜測(cè) MDN 為了簡(jiǎn)化知識(shí),隱瞞了什么。
于是我去看了看 ES 文檔,其中的 13.7.4.7 章節(jié) 清楚地說明了個(gè)中緣由,但是由于說得太清楚了,很多人都看不下去,不信你可以試試。
我說一下我的理解:
- for( let i = 0; i< 5; i++) 這句話的圓括號(hào)之間,有一個(gè)隱藏的作用域
- for( let i = 0; i< 5; i++) { 循環(huán)體 } 在每次執(zhí)行循環(huán)體之前,JS 引擎會(huì)把 i 在循環(huán)體的上下文中重新聲明及初始化一次。
- 其他細(xì)節(jié)就不說了,太細(xì)碎了
也就是說上面的代碼段2可以近似近似近似地理解為
// 代碼段3
var liList = document.querySelectorAll('li') // 共5個(gè)li
for( let i=0; i<liList.length; i++){
let i = 隱藏作用域中的i // 看這里看這里看這里
liList[i].onclick = function(){
console.log(i)
}
}
那樣的話,5 次循環(huán),就會(huì)有 5 個(gè)不同的 i,console.log 出來的 i 當(dāng)然也是不同的值。
再加上隱藏作用域里的 i,一共有 6 個(gè) i。
這就是 MDN 加那句 let j = i 的原因:方便新人理解。
總得來說就是 let/const 在與 for 一起用時(shí),會(huì)有一個(gè) perIterationBindings 的概念(一種語法糖)。
從此之后,我就開始懷疑我對(duì) let 的所有理解了。
第二次質(zhì)疑
我在 StackOverflow 上閑逛的時(shí)候,無意中發(fā)現(xiàn)了一個(gè)是關(guān)于「let 到底有沒有提升」的問題:
Are variables declared with let or const not hoisted in ES6?
其中一個(gè)高票回答認(rèn)為 JS 中所有的聲明(var/let/const/function/class),都存在提升,理由是如下代碼:
x = "global";
// function scope:
(function() {
x; // not "global"
var/let/… x;
}());
// block scope (not for `var`s):
{
x; // not "global"
let/const/… x;
}
我覺得他說得挺有道理的。于是我又去 MDN 和 ECMAScript 翻了翻,發(fā)現(xiàn)兩處疑點(diǎn):
- MDN 關(guān)于 let 是否存在提升的章節(jié),被編輯了兩次,第一次說存在提升,第二次說不存在提升(參考 2017 年 3 月 10 號(hào)的變更記錄)。也就是說 MDN 的維護(hù)者都在這個(gè)問題上產(chǎn)生過分歧,更何況我們了。
- ES 文檔里出現(xiàn)了「var/let hoisting」字樣。
鑒于此,我認(rèn)為應(yīng)該尊重 ES 文檔,認(rèn)為 let 確實(shí)存在提升。只不過由于暫時(shí)死區(qū)的限制,你不能在 let x 之前使用 let(詳見 let 聲明會(huì)提升(hoist)嗎?)。
故事并沒有結(jié)束
當(dāng)一個(gè)疑問一直存在你腦中時(shí),你會(huì)在潛意識(shí)中不停地對(duì)它進(jìn)行消化和思考。
上面說到我認(rèn)為 let 存在提升的主要原因是 ES 文檔中出現(xiàn)了「var/let hoisting」字樣。
但是我在咨詢 TC39 的成員 <u style="text-decoration: none; border-bottom: 1px dashed gray;">Rick Waldron</u> 時(shí),他是這么說的:
You're misunderstanding the intention of that (non-normative) Note. let and const do not hoist as var and function decls do.
而且還細(xì)心地專門寫了一個(gè) gist 來詳細(xì)解釋(twitter 有 140 字限制)。
既然 TC39 的 representative 都說了 let hoisting 不是一個(gè)正式詞匯,那我就應(yīng)該重新思考一下什么是「提升」了。
于是就有了這篇文章,我想要說一下我對(duì)「提升」的理解。你看完之后,就知道 let 到底有沒有「提升」。
首先明確一點(diǎn):提升不是一個(gè)技術(shù)名詞。
要搞清楚提升的本質(zhì),需要理解 JS 變量的「創(chuàng)建create、初始化initialize 和賦值assign」
有的地方把創(chuàng)建說成是聲明(declare),為了將這個(gè)概念與變量聲明區(qū)別開,我故意不使用聲明這個(gè)字眼。
有的地方把初始化叫做綁定(binding),但我感覺這個(gè)詞不如初始化形象。
我們來看看 var 聲明的「創(chuàng)建、初始化和賦值」過程
假設(shè)有如下代碼:
function fn(){
var x = 1
var y = 2
}
fn()
在執(zhí)行 fn 時(shí),會(huì)有以下過程(不完全):
- 進(jìn)入 fn,為 fn 創(chuàng)建一個(gè)環(huán)境。
- 找到 fn 中所有用 var 聲明的變量,在這個(gè)環(huán)境中「創(chuàng)建」這些變量(即 x 和 y)。
- 將這些變量「初始化」為 undefined。
- 開始執(zhí)行代碼
- x = 1 將 x 變量「賦值」為 1
- y = 2 將 y 變量「賦值」為 2
也就是說 var 聲明會(huì)在代碼執(zhí)行之前就將「創(chuàng)建變量,并將其初始化為 undefined」。
這就解釋了為什么在 var x = 1 之前 console.log(x) 會(huì)得到 undefined。
接下來來看 function 聲明的「創(chuàng)建、初始化和賦值」過程
假設(shè)代碼如下:
fn2()
function fn2(){
console.log(2)
}
JS 引擎會(huì)有一下過程:
- 找到所有用 function 聲明的變量,在環(huán)境中「創(chuàng)建」這些變量。
- 將這些變量「初始化」并「賦值」為 function(){ console.log(2) }。
- 開始執(zhí)行代碼 fn2()
也就是說 function 聲明會(huì)在代碼執(zhí)行之前就「創(chuàng)建、初始化并賦值」。
接下來看 let 聲明的「創(chuàng)建、初始化和賦值」過程
假設(shè)代碼如下:
{
let x = 1
x = 2
}
我們只看 {} 里面的過程:
- 找到所有用 let 聲明的變量,在環(huán)境中「創(chuàng)建」這些變量
- 開始執(zhí)行代碼(注意現(xiàn)在還沒有初始化)
- 執(zhí)行 x = 1,將 x 「初始化」為 1(這并不是一次賦值,如果代碼是 let x,就將 x 初始化為 undefined)
- 執(zhí)行 x = 2,對(duì) x 進(jìn)行「賦值」
這就解釋了為什么在 let x 之前使用 x 會(huì)報(bào)錯(cuò):
let x = 'global'
{
console.log(x) // Uncaught ReferenceError: x is not defined
let x = 1
}
原因有兩個(gè)
- console.log(x) 中的 x 指的是下面的 x,而不是全局的 x
- 執(zhí)行 log 時(shí) x 還沒「初始化」,所以不能使用(也就是所謂的暫時(shí)死區(qū))
看到這里,你應(yīng)該明白了 let 到底有沒有提升:
- let 的「創(chuàng)建」過程被提升了,但是初始化沒有提升。
- var 的「創(chuàng)建」和「初始化」都被提升了。
- function 的「創(chuàng)建」「初始化」和「賦值」都被提升了。
最后看 const,其實(shí) const 和 let 只有一個(gè)區(qū)別,那就是 const 只有「創(chuàng)建」和「初始化」,沒有「賦值」過程。
這四種聲明,用下圖就可以快速理解:

完了嗎?
故事依然沒有結(jié)束,這周我在知乎上問了一個(gè)問題:如何理解 let x = x 報(bào)錯(cuò)之后,再次 let x 依然會(huì)報(bào)錯(cuò)?

這個(gè)問題說明:如果 let x 的初始化過程失敗了,那么
- x 變量就將永遠(yuǎn)處于 created 狀態(tài)。
- 你無法再次對(duì) x 進(jìn)行初始化(初始化只有一次機(jī)會(huì),而那次機(jī)會(huì)你失敗了)。
- 由于 x 無法被初始化,所以 x 永遠(yuǎn)處在暫時(shí)死區(qū)(也就是盜夢(mèng)空間里的 limbo)!
- 有人會(huì)覺得 JS 坑,怎么能出現(xiàn)這種情況;其實(shí)問題不大,因?yàn)榇藭r(shí)代碼已經(jīng)報(bào)錯(cuò)了,后面的代碼想執(zhí)行也沒機(jī)會(huì)。
細(xì)節(jié)參見另一篇文章:JS變量封禁大法:薛定諤的X
以上,就是一個(gè) let 引發(fā)的思考。
重要參考:JavaScript variables lifecycle: why let is not hoisted
重要參考:鏈接