[譯]JavaScript 終極指南之執行上下文、變數提升、作用域和閉包
視訊: ofollow,noindex">The Ultimate Guide to Execution Contexts, Hoisting, Scopes, and Closures in JavaScript
我認為理解 JavaScript 語言的最重要的基本概念是理解執行上下文(Execution Context),這點可能令人感到意外。正確的學習執行上下文,可以讓你更容易學習更高階的內容,比如變數提升(hoisting)、作用域鏈(scope chains)和閉包(closures)。既然如此,那到底什麼是“執行上下文”呢?為了更好的理解它,我們先來看看我們是如何寫軟體的。
編寫軟體的一種策略是把程式碼拆分成獨立的塊。雖然這些“塊”有不同的命名(函式、模組、包等等),但是他們有同樣的目的——分解和處理應用的複雜性。現在我們不要以編寫程式碼的思維來思考,而是以 JavaScript 引擎的角度來思考,JavaScript 引擎是用來解釋程式碼的。那麼我們是否也可以使用和我們在寫程式碼的時候一樣的策略,把程式碼拆分成塊,來處理解釋程式碼的複雜性。答案是可以,這些“塊”被稱為執行上下文。 正如可以使用函式、模組、包(functions/modules/packages)來處理編寫程式碼的複雜性,JavaScript 引擎可以通過執行上下文來處理解釋和執行程式碼的複雜性。 現在我們知道執行上下文的用途了,接下來需要解答的問題是:它們是如何建立的和它們是什麼組成的?
JavaScript 引擎執行程式碼時,第一個被建立的執行上下文叫做全域性執行上下文(Global Execution Context)。最開始這個執行上下文包含兩個東西——一個全域性物件和 this
變數。 this
會引用全域性物件,在瀏覽器執行 JavaScript 則全域性物件是 window
,在 Node 環境執行則全域性物件是 global
。
上圖我們可以看到,即使沒有任何程式碼,全域性執行上下文還是會包含兩個東西—— window
和 this
。這是全域性執行上下文的最基本形式。
我們一步一步來,看看當向程式新增程式碼時會發生什麼。我們先新增一些變數。
你可以看出上面兩張圖的不同之處嗎?關鍵的是每個執行上下文都有兩個獨立的階段——建立( Creation
)階段和執行( Execution
)階段,每個階段都有它特有的職責。
在全域性 建立
階段,JavaScript 引擎將會:
this undefined
直到 執行
階段,JavaScript 引擎才會開始一行一行的執行程式碼。
我們從下面的 GIF 圖可以看到從 建立
階段到 執行
階段這一流程。
在 建立
階段,建立 window
和 this
,變數宣告( name
和 handle
)預設賦值為 undefined
,所有函式宣告( getUser
)全部放入記憶體中。然後一旦進入 執行
階段,JavaScript 引擎開始一行一行的執行程式碼,然後給記憶體中已存在的變數賦上真實的值。
Gif 很酷,但是一步一步執行程式碼並親自檢視執行過程更酷。我為你建立了 JavaScript Visualizer ,你值得擁有。如果你想檢視上面的確切程式碼,開啟這個 連結 。
為了真正鞏固 建立
階段和 執行
階段的知識點,我們列印一些 建立
階段之後和 執行
階段之前的值出來。
console.log('name: ', name) console.log('handle: ', handle) console.log('getUser :', getUser) var name = 'Tyler' var handle = '@tylermcginnis' function getUser () { return { name: name, handle: handle } }
在上面的程式碼,你想在 console 中打印出什麼?當 JavaScript 開始一行一行執行程式碼和呼叫 console.log
時, 建立
階段已經完成了。這意味著,正如之前看到的,變數宣告已經賦值為 undefined
,而函式宣告則整個放在記憶體中了。所以正如我們期望的那樣, name
和 handle
值為 undefined
, getUser
引用記憶體中的函式。
console.log('name: ', name) // name: undefined console.log('handle: ', handle) // handle: undefined console.log('getUser :', getUser) // getUser: ƒ getUser () {} var name = 'Tyler' var handle = '@tylermcginnis' function getUser () { return { name: name, handle: handle } }
在建立階段給變數宣告預設賦值為 undefined
的過程稱為 變數提升( Hoisting
)
之前你可能嘗試過向自己解釋“變數提升”,但是不盡人意。“變數提升”令人迷惑的點在於實際上沒有任何東西“提升”或者移動。現在你理解了執行上下文和變數宣告在 建立
階段預設賦值為 undefined
,從而你也理解了“變數提升”,因為這就是“變數提升”。
現在,你應該對全域性執行上下文和它的兩個階段—— 建立
和 執行
相當熟悉了。好訊息是隻剩一個其他的執行上下文你需要學習,而且它幾乎和全域性執行上下文完全相同。它就是函式執行上下文,它在函式 呼叫 的時候建立。
關鍵的是,執行上下文只有在 JavaScript 引擎第一次開始解釋程式碼時(全域性執行上下文)或者函式呼叫時才建立。
現在主要的問題是,全域性執行上下文和函式執行上下文的不同點是什麼?如果你還記得,之前提到過在全域性 建立
階段,JavaScript 引擎將會:
this undefined
這些步驟對於函式執行上下文來說哪些是不對的?步驟 1。我們有且只有一個全域性物件,它在全域性執行上下文的 建立
階段建立,而不會在函式呼叫和 JavaScript 引擎建立函式執行上下文時建立。函式執行上下文不需要建立全域性變數,而是需要考慮引數(arguments)問題,而全域性執行上下文沒有這個問題。考慮到這些,我們可以調整之前的列表。當 函式 執行上下文建立時,JavaScript 引擎將會:
-
建立全域性物件 - 建立一個引數物件
- 建立
this
物件 - 為變數和函式設定記憶體空間
- 變數宣告預設賦值為
undefined
,同時函式宣告存入記憶體
我們回到之前提到的程式碼,來看看這個過程,但是這次除了定義 getUser
,還要看看呼叫它時會發生什麼。
正如我們所說,呼叫 getUser
時會建立新的執行上下文。在 getUser
執行上下文的 建立
階段,JavaScript 引擎建立 this
物件和 arguments
物件。因為 getUser
沒有任何變數,所以 JavaScript 不需要給變數設定記憶體空間和進行“提升”。
你可能也注意到,當 getUser
函式執行完,它在視覺化圖裡被移除了。實際上,JavaScript 引擎建立了“執行棧”(也被成為“呼叫棧”)。當函式呼叫時,建立新的執行上下文並把它加入執行棧。當函式執行結束,完成了 建立
和 執行
階段,它會從執行棧中彈出。因為 JavaScript 是單執行緒的(意味著同時只能執行一個任務),所以這個過程很容易實現視覺化。使用 “JavaScript Visualizer”,執行棧以巢狀形式顯示,每個巢狀項對應執行棧中的新的執行上下文。
現在,我們已經知道函式呼叫如何建立自己的執行上下文,並加入執行棧中。我們還不知道的是有區域性變數時會怎麼樣。我們修改程式碼,讓函式有區域性變數。
URL%20%28handle%29%20%7B%0A%20%20var%20twitterURL%20%3D%20%27https%3A%2F%2Ftwitter.com%2F%27%0A%0A%20%20return%20twitterURL%20%2B%20handle%0A%7D%0A%0AgetURL%28handle%29" target="_blank" rel="nofollow,noindex">檢視視覺化程式碼
這裡有一些重要的細節要注意。第一點是,你傳入的任何引數都會作為區域性變數新增到函式的執行上下文。在例子中, handle
作為變量出現在 全域性
執行上下文(在變數定義的地方),也出現在 getURL
執行上下文中,因為把它當做引數傳入了。然後是,函式內部宣告的變數,存在於函式執行上下文中。所以當我們建立 twitterURL
時,它存在於 getURL
執行上下文——它定義的地方,而不在 全域性
執行上下文中。這點看起來很明顯,但它是我們下個主題——作用域的基本原理。
過去,你可能聽到過對“作用域”的定義,即為“可訪問到變數的地方”。現在不管這個定義是否正確,使用你新學到的知識——執行上下文和 JavaScript Visualizer 工具,作用域的概念會變得比之前更加清晰。實際上,MDN 將 “作用域” 定義為 “當前執行的上下文”。聽起來很熟悉?我們可以用類似執行上下文的思維來思考“作用域”和“可訪問到變數的地方”。
這裡有個測試。下面程式碼中, bar
會打印出什麼?
function foo () { var bar = 'Declared in foo' } foo() console.log(bar)
我們用 JavaScript Visualizer 來驗證。
foo
呼叫時,我們在執行棧中建立新的執行上下文。在 建立
階段建立 this
、 arguments
,並給 bar
賦值為 undefined
。然後開始 執行
階段,把字串 Declared in foo
賦值給 bar
。在 執行
階段結束後, foo
執行上下文從棧中彈出。當 foo
從執行棧中移除時,我們嘗試在 console 中列印 bar
。此時通過 JavaScript Visualizer,發現 bar
好像是從來沒有出現過,所以我們得到 undefined
。這個告訴我們,函式內部定義的變數是區域性作用域的。這意味著(對大多數而已,後面會看到例外的情況)一旦函式執行上下文從執行棧彈出,變數就無法訪問到了。
下面是另一個測試。下面的程式碼執行完之後 console 會打印出什麼?
function first () { var name = 'Jordyn' console.log(name) } function second () { var name = 'Jake' console.log(name) } console.log(name) var name = 'Tyler' first() second() console.log(name)
我們還是來看看 JavaScript Visualizer。
我們得到的結果是: undefined
、 Jordyn
、 Jake
和 Tyler
。這個告訴我們,我們可以認為每個新的執行上下文有它自己的特有的變數環境。即使還有其他執行上下文包含變數 name
,JavaScript 引擎會先從當前執行上下文查詢變數。
這就引出新的問題,如果當前執行上下文中不存在變數怎麼辦?JavaScript 引擎是否就停止查詢該變數?我們來看個例子,它會告訴我們答案。下面的程式碼,會列印什麼結果?
var name = 'Tyler' function logName () { console.log(name) } logName()
可能直覺告訴你會列印 undefined
,因為 logName
執行上下文的作用域下沒有 name
變數。這樣想是正常的,但是是錯誤的。如果 JavaScript 引擎在函式執行上下文中找不到變數會發生什麼呢?它會在最近的父級執行上下文中查詢該變數。這個查詢鏈將會一直持續,直到引擎查詢到全域性執行上下文。這種情況下,如果全域性執行上下文也沒有該變數,那麼將會丟擲引用錯誤(Reference Error)。
如果變數在區域性執行上下文中不存在,JavaScript 引擎會逐個檢查各自的父級執行上下文,這個過程稱為 作用域鏈
。在 JavaScript Visualizer 中顯示,每個新的執行上下文添加了縮排並加上特別的背景顏色。通過視覺化,你可以看到每個子級執行上下文可以引用它父級執行上下文中的任何變數,但是反之則不行。
前面我們學習到,函式內部定義的變數是區域性作用域的,當函式執行上下文從執行棧彈出後,變數就無法訪問了(針對大多數情況)。這個說法錯誤的一種情況是:當一個函式內嵌在另一個函式裡時。這種情況下,即使父級函式的執行上下文已經從執行棧中移除,子函式也可以保持能訪問外部函式作用域。這個說起來就複雜了。還是使用 JavaScript Visualizer,它可以幫助我們。
在 makeAdder
執行上下文在執行棧彈出後,JavaScript Visualizer 建立了 閉包作用域(Closure Scope)
。在 閉包作用域(Closure Scope)
裡擁有和 makeAdder
執行上下文裡一樣的變數環境。產生這個情況的原因是,我們在把函式嵌入到另一個函式裡。在我們這個例子裡,函式 inner
內嵌在函式 makeAdder
裡,所以 inner
建立了包含 makeAdder
變數環境的 閉包
。因為建立了 閉包作用域(Closure Scope)
,所以即使 makeAdder
執行環境已經從執行棧彈出了, inner
還是可以訪問變數 x
(通過作用域鏈)。
正如你所想,子函式“包含”它父級函式的變數環境,把這個概念稱為“閉包”。