Photo by Edu Lauton on Unsplash
一開始決定著手撰寫這個系列,只是為了用最簡單的方式解釋什麼是閉包,以及閉包到底有什麼用,甚至連標題都想好了「閉包是什麼,能吃嗎?」。後來發現想用圖解的方式來解說閉包,勢必要一同解釋什麼是作用域鏈、詞彙環境等背景知識。因此本系列預計分成三篇,分別解釋什麼是拉升、作用域以及閉包。
執行環境 Execution context
JavaScript底層要執行的時候,第一步會先建立一個「執行環境」(Execution Context)。
執行環境內部有許多物件,其中最重要的是「詞彙環境」Lexical Environment(也有翻譯成「詞法環境」)。 詞彙環境主要儲存了:
- 全域物件,在有瀏覽器的環境下,指的是window內的物件
- 登記這個環境所宣告的變數和function(函式)
- 外部環境,也就是Scope Chain,台灣多半翻譯成「作用域(鏈)」,中國則翻譯成「範疇」
- 這個環境的”this”是什麼

執行環境解析程式碼的過程中,主要分成「建立階段(Creation Phase)」和「執行階段(Execution Phase)」。
- 建立階段:建立「詞彙環境」。
- 執行階段:由上到下、一行一行地執行程式,只會先跳過函式「裡面」的程式碼。畢竟你宣告了函式,不代表你要立刻執行它,可以等到別的事情先做完了,再執行這個函式。
第一個執行環境就是全域執行環境,當全域執行環境建立完,進入執行階段後,一行一行往下跑著,當遇到呼叫A函式時,這個全域執行環境就會為A函式建立一個A函式執行環境,再進入A函式中逐行執行。
若在A函式中又呼叫另一個B函式,就會再建立新的B函式執行環境,並堆疊在A函式執行環境的上方。如果不斷地在函式中呼叫另一個函式,所有執行環境就會不斷往上堆疊,如下面的動畫:

JS會優先處理堆疊(stack)中最上面的執行環境。一旦最上面的環境執行完畢,該執行環境就會被移除,以此類推。
理解了執行環境的堆疊之後,下一步就可以說明什麼是「拉升」(hoisting)。
什麼是拉升 hoisting?
先說結論,比較「粗淺易懂」的回答是: 拉升是執行環境登記「這裡宣告了哪些變數」的行為。
以function、var作為關鍵字來宣告的情況下,會產生下列兩點現象:
- 變數、函式的「宣告」會被拉升
- 只有「宣告」會拉升,「賦值」不會拉升
接著,再用下列範例來解釋「拉升」的細節。
變數
宣告的拉升
第一個範例,使用log印出一個尚未宣告的變數:
console.log(a)
// ReferenceError: a is not defined
// a 還沒有被宣告執行環境:「我沒看到你有宣告變數a啊」,因此拋出Error:a 還沒有被宣告。
ReferenceError的意思是,記憶體當中找不到任何宣稱是a變數的位址。
對執行環境而言,只要看到宣告變數的程式碼,例如var a,就會在記憶體中標記:「留一塊地給a。」
因此,只要執行環境找不到任何一塊地是a的,就會拋出錯誤:「沒有a變數的地址,你沒宣告啦!」
那麼,我在log下一行宣告a變數,會發生什麼事呢?
console.log(a) // undefined
var a這次,執行環境看到程式碼有宣告變數a了,於是在記憶體上圈出一小塊地:「這塊地是a的。」並將型別預設為undefined。
剛接觸JS的新人,看到這一段code會滿滿的不適應:「程式碼不是應該由上往下,一行一行執行下來的嗎?怎麼我是在第2行才宣告,JS就先圈出一塊地給a了?」
別忘了一開始提到的執行環境,在執行程式之前,執行環境會先建立「詞彙環境」,把環境中宣告的變數先登記下來、發配記憶體,這個紀錄的動作就是「拉升」。等環境建立完成了,才會一行一行往下執行。
因此,下面這張圖不論是左邊或右邊,對執行環境來說都是一樣的。

下面再來一個執行函式例子:
function printName() {
name = "Harry"; console.log(name); // Harry var name;}
printName()一樣是閱讀上很違反直覺的範例:「第4行才想起來要補上宣告,但2、3行就存取,而且都沒有跳錯,什麼鬼啊!」
在進入printName()之前,全域執行環境會先為printName函式建立一個printName()執行環境。
printName()的詞彙環境(Lexical Environment)看到了var name,把變數登記在「環境變數宣告記錄表」裡面,分配記憶體給name,型別預設為undefined。這個環境變數宣告記錄表,在此先簡寫為DeclEnvRec(Declarative Environment Record)。

進入執行階段,首先看到變數name被賦值為字串"Harry"。下一行console.log,理所當然印出name的值為Harry。

這個例子主要說明,var拉升在程式碼閱讀上「看起來」是可以先存取,再宣告的。但背後的原因其實是JS「先建置環境,後執行」的邏輯。
賦值不會拉升
現在,我要a變數的值是10:
console.log(a) // undefined
var a = 10可是..等等,不是拉升了嗎?怎麼a還沒有被賦值呢?
因為「環境變數宣告記錄表」只登記這裡宣告了哪些變數,並預設為undefined,賦值是等到「執行階段」一行一行執行下來,且執行到「賦值的那一行」才會做的。
站在JS的角度來看,畫面是這樣的:

所以,以var來說,若要賦值,還是乖乖寫在log上面吧!
var a = 10
console.log(a) // 10進階變化
function run(n){ console.log(n) // ?
var n = 2
}
run(10)這裡你可能懵了,執行環境是怎麼看待「參數」呢?
對執行環境環境來說,參數也是宣告的變數。run函式執行環境是這樣看的:

環境建立 階段
「參數」(Arguments)是函式的執行環境特別的物件,它包含0~n個參數,以及參數的長度(length)。
在run()執行環境的「環境建立」階段,Arguments紀錄了run函式有1個參數n。比較特殊的是,由於參數的值是從外部傳進來的,在環境建立的階段已知n=10。
run函式裡面也另外宣告了一個var n。兩個n要怎麼登記在「環境變數宣告記錄表」呢?對「環境變數宣告記錄表」來說,相同的變數它只會登記一個,因此只會有一個n。
那麼n的值呢?在環境建立階段,已知參數n的值為10,而n=2是要等到「執行階段」執行到第3行,才會賦值。因此,目前n的值為10。
環境執行 階段
進入函式執行後,第2行遇到了console.log(),目前n=10,因此log印出來為10。
第3行遇到n=2,於是將n賦予2的值,在「環境變數宣告記錄表」中,n=2。
回到「函式的執行環境是怎麼看待參數呢?」這個問題,在環境建置的階段來說,參數也是這個環境宣告的變數之一,會登記在「環境變數宣告記錄表」當中。由於參數是外部環境傳進來的,因此在「環境建立階段」就已知道它的值。唯有「參數」會在「環境建立階段」賦值,其餘宣告只會先登記,直到「執行階段」執行到那一行才會賦值。
換言之,「除了參數之外,宣告會拉升,但賦值不會拉升」。
下一題的變化,觀念也是一樣的:
var v = 3
var v
console.log(v) // ?有些人會猜,v可能是undefined。第二個v蓋過第一個v,那v不就是undefined了嗎?
但掌握「宣告會拉升,但賦值不會拉升」原則就會理解,「賦值」是執行階段才會做的事情。 對執行環境而言,它是這樣看的:

這樣即可很容易理解,v的值為什麼是3了。
function
函式的宣告也會被拉升。
console.log(a)
var a
function b(){}全域執行環境看到這段code,會先在「環境變數宣告記錄表」登記變數a、b。
globalEC = {
lexicalEnv: {
DeclEnvRec: {
a: undefined,
b: function
}
}
}函式的拉升基本上沒什麼陷阱要注意,但以下範例可以當作冷知識,看看就好了:
console.log(a) // [Function: a]
var afunction a(){}同時宣告變數a以及function a()的時候,JS會默認「function的權限比較高」,所以上面的log會印出它是function,而不是預設的undefined。
既然hoisting這麼多地雷,那當初設計hoisting是為了什麼?
<我知道你懂 hoisting,可是你了解到多深?>的作者寫得很好:「可以反過來想,如果沒有拉升會怎樣?」
一、變數一定要先宣告才能使用
不能「先存取變數,後來才想起來要宣告變數」,例如下面這樣違反閱讀習慣的行為:NONO!
a = 10
console.log(a)
var a嗯,很好!變數先宣告才能使用是好習慣。因此es6的let、const補上了這一點。
二、函式也要先宣告才能呼叫
嗯~有點麻煩,這樣每次要呼叫函式的時候,都要努力將滑鼠滾到上面,找出自己需要呼叫的函式。 但也不是不行啦!上面都是function,下面都是執行的程式,看起來也滿整齊的啊!
三、沒辦法做到函式彼此之間互相呼叫
這裡需要舉個例子:
var students = [
{
name: "Eren Jaeger",
gender: "male"
},
{
name: "Mikasa Ackerman",
gender: "female"
},
{
name: "Armin Arlert",
gender: "male"
},
{
name: "Reiner Braun",
gender: "male"
},
{
name: "Historia Reiss",
gender: "female"
},
]
function printAll(stu){
var arr = stu.map(i => i)
if (arr.length > 0) {
logDetail(arr) }
}
function logDetail(arr) {
console.log(arr[0].name + ', ' + arr[0].gender)
arr.shift()
printAll(arr)}
printAll(students)printAll()裡面呼叫了logDetail(),又在logDetail()裡面呼叫了printAll()。如果函式的宣告都要放在呼叫的上面,那麼根本做不到互相呼叫。
這就是為什麼拉升在JS當中仍然是必要的,對function來說尤其重要。
let, const有沒有拉升?
先在檯面上覆蓋一張考前(?)複習表:

自從es6推出了let、const,網路上已有非常多文章解釋var、let、const的差異。
多數人的既定印象,包括過去的我自己,都抱有簡單的結論:用let跟const來宣告變數,就沒有「拉升」的現象啦!var是屬於舊時代的冷知識,新專案也不再使用var來宣告變數了。彷彿用了let跟const就揮別了hoisting的惡夢。
沒有拉升?就結果來看是這樣沒錯。反正知道進一步的細節對於完成專案似乎沒有什麼幫助。
先來一個let跟var的基礎比較:
console.log(a) // undefined
var a
/**
* 有拉升,先預設為undefined
* /console.log(a) // ReferenceError: a is not defined
let a
/**
* 沒有拉升,a還沒有被宣告,找不到地址
* /就上面的比較來說,let確實是沒有被拉升的。
let跟const一定要先宣告才能使用,這確實是解決很多痛點的改良。
但下面是一個很故意的例子:
var x = "global"var y = "global"
function run(){
console.log(x) // ?
console.log(y) // ?
var x = "local" let y = "local"}
run()在全域,變數x、y都已宣告,並賦予了字串"global"的值。
進入了run()函式之後,x, y又重複宣告,並賦予字串"local"的值,只是宣告的位置在log下方。
此時,log印出的x、y,分別是什麼?
為了避免混亂,以下先解釋執行環境怎麼看變數x。
var x = "global"// var y = "global"
function run(){
console.log(x)
// console.log(y)
var x = "local" // let y = "local"
}
run()
當run()執行環境正在建立時,它會登記run()函式當中宣告了一個變數x。
進入執行階段後,對於尚未賦值的變數通通預設為undefined。
取值時,run()執行環境會先尋找環境內有沒有x,有的話,直接取用環境內的x(沒有的話,再向外尋找x)。
因此log印出來,x為undefined,合情合理。
接著,重頭戲是,執行環境怎麼看變數y。run()當中的let y真的沒有拉升嗎?
// var x = "global"
var y = "global"
function run(){
// console.log(x)
console.log(y)
// var x = "local"
let y = "local"}
run()如果let y真的沒有拉升,那麼log印出來的值,就應該"global"。如下圖:

如果let y沒有拉升,那麼run()執行環境在建立時不會先登記y。
而開始運行後,第一行看見y,因尚未登記,run()執行環境會向外尋找y的值,然後在全域執行環境找到y = "global"。
但實際上y印出來的,卻是ReferenceError,這到底怎麼回事呢?
其實,let與const一樣是有拉升的,只是在「賦值」之前,它們是不能被使用的:

在run()執行環境建立時,一樣會先登記環境裡宣告了哪些變數。與var的差別在於,執行環境並不會給未賦值的let預設undefined,而是先設定為「尚未初始化,不能使用」。直到let賦值了,才能存取。
因此,若對「尚未初始化」的let進行存取,JS在執行時會直接拋出錯誤ReferenceError。
簡單來說,var與let/const/class的宣告差別在於「初始化」。
var在賦值之前,會初始化為undefined。而let/const/class則保持「未初始化」的狀態,無法存取值。當試圖存取,會拋出ReferenceError的錯誤。
所以,回到那張考前複習表,與其說let/const沒有拉升,更貼切的說法應該是:「拉升時,沒有初始化。」

最後,Huli大神在<我知道你懂 hoisting,可是你了解到多深?>內文提到,hoisting要追根究柢,分為地下10層。小妹我目前停留在地下5層,若想進一步往深淵走去,請移駕參考Huli大神的文章。
Summary
- 拉升是執行環境登記「這裡宣告了哪些變數」的行為。在執行程式之前,執行環境會先建立「詞彙環境」,把環境中宣告的變數先登記在「環境變數宣告記錄表」、發配記憶體,這個紀錄的動作就是「拉升」。等環境建立完成了,才會一行一行往下執行。
- 不論是
var、let、const,都有拉升的行為。 - 宣告會拉升,但賦值不會拉升。
- 對「環境變數宣告記錄表」來說,命名相同的變數它只會登記一個。
- 函式的參數對函式的執行環境來說,也是這個環境宣告的變數之一,會登記在「環境變數宣告記錄表」當中。由於參數是外部環境傳進來的,唯有「參數」會在「環境建立階段」賦值,其餘宣告只會先登記,直到「執行階段」執行到那一行才會賦值。換言之,除了參數之外,宣告會拉升,但賦值不會拉升。
- 拉升在JS當中是必要的,對function的宣告及呼叫尤其重要。
var與let/const/class的宣告差別在於「初始化」。var在賦值之前,會初始化為undefined。而let/const/class則保持「未初始化」的狀態,無法存取值。在賦值之前試圖存取,會拋出ReferenceError的錯誤。
Reference
- 秒懂!JavaSript 執行環境與堆疊 作者 - Coding Monster
- 你一直在用,但從沒搞懂的閉包 作者 - Schaos
- 我知道你懂 hoisting,可是你了解到多深? 作者 - Huli
- Execution context, Scope chain and JavaScript internals 作者 - Rupesh Mishra