- 原文作者:Tyler McGinnis
- 原文連結:tylermcginnis.com/ultimate-gu…
- 文中部分連結可能需要梯子。
- 歡迎批評指正。
說出來可能嚇你一跳,在我看來,理解Javascript的最重要最基本的思路就是理解執行上下文。吃透了執行上下文,你就能更好地學習諸如變數提升、作用域鏈和閉包等進階知識。說到這個,到底什麼是“執行上下文”?為了更好理解,我們先來看一看我們是怎麼寫程式碼的。
程式設計的一個策略就是把程式碼拆分開。雖然那些拆開的“零件”有不同的名字(函式、方法、包等等),它們都是為了一個目的而存在——降低應用的複雜度,便於管理。現在,拋開開發者的思維,設想你是解析程式碼的Javascript引擎,這種情景下,我們能像寫程式碼時候那樣,用相同的策略拆分程式碼來解析程式碼嗎?事實證明我們可以,這些“零件”就叫做執行上下文。就像函式/模組/包等能幫你進行復雜的開發,執行上下文幫助Javascript引擎管理整個解析和執行程式碼的複雜過程。那麼現在我們瞭解了執行上下文的存在目的,下一個問題就是執行上下文是怎麼建立的?它們由什麼組成?
當Javascript引擎執行程式碼,第一個被建立的執行上下文叫做“全域性執行上下文”。最初,這個全域性上下文由這二位組成:一個全域性物件和一個this變數。this引用的是全域性物件,如果在瀏覽器中執行Javascript,那麼這個全域性物件就是window
物件,如果在Node環境中執行,這個全域性物件就是global
物件。
從上圖可以看出,即使沒有任何程式碼,全域性執行上下文中仍然有window
和this
。這就是最基本的全域性執行上下文。
讓我們看看新增了程式碼會怎麼樣:
能看出上面兩張圖的區別嗎?關鍵在於每個執行上下文有兩個獨立的階段,一個是建立階段,一個是執行階段,每個階段都有其各自職責。
在全域性執行上下文的建立階段,Javascript引擎會:
1. 建立一個全域性物件;
2. 建立this物件;
3. 給變數和函式分配記憶體;
4. 給變數賦預設值undefined,把所有函式宣告放進記憶體。
複製程式碼
直到執行階段,Javascript引擎才會一行一行地執行你的程式碼並執行它們。
通過下面的動圖我們可以看到從建立階段到執行階段的流程:
在建立階段,window
和this
被建立出來,變數宣告被設為預設值undefined,所有函式宣告都被存入記憶體。一旦進入執行階段,Javascript引擎就開始一行行執行程式碼,把記憶體中已經存在的變數賦予真實值。
動圖確實很炫酷,但也不如你手敲一遍,親自體會這個處理過程。你需要一個工具,所以我建立了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
}
}
複製程式碼
在上面的程式碼中,你覺得控制檯會列印出什麼結果?當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
}
}
複製程式碼
譯者注:本人實操程式碼的結果與原作者的結果有出入,見下圖:
且將變數name改為其他字串,列印的結果如下
在建立階段將變數宣告賦予預設值的過程就叫做變數提升。
是不是有恍然大悟的感覺?可能之前對變數提升的理解不是很清晰。關於變數提升讓你困惑之處在於,沒有誰真的被“提升”或者移動了。現在你理解了執行上下文,理解了變數宣告在建立階段被賦予預設值,那你就理解了“提升”,因為那完全就是字面意思。
此刻你應該對全域性執行上下文和它的兩個階段一點都不感覺彆扭了。好訊息是,你只需再學習另一個執行上下文就夠了,而且它和全域性執行上下文幾乎一樣。它叫做函式執行上下文,當函式被呼叫,它就被建立出來了。
再重申一遍關鍵:僅當Javascript引擎首次開始解析程式碼(對應全域性執行上下文)或當一個函式被呼叫時,才會建立執行上下文。
現在我們需要搞清楚的主要問題就是,全域性執行上下文和函式執行上下文有什麼區別。回想一下,我們之前學到過,在全域性建立階段,Javascript引擎會:
1. 建立一個全域性物件;
2. 建立this物件;
3. 給變數和函式分配記憶體;
4. 給變數賦預設值undefined,把所有函式宣告放進記憶體。
複製程式碼
現在換成函式執行上下文,想想看,哪個步驟就對不上號了呢?對,就是第一步。我們有一個全域性物件就夠了,那就是在全域性執行上下文的建立階段所建立的那個,而不是每次函式呼叫都建立一個。函式執行上下文中應該建立的應該是arguments
物件,所以當建立函式執行上下文時,Javascript引擎會:
1.
建立一個全域性物件1.建立一個arguments物件;
2. 建立this物件;;
3. 給變數和函式分配記憶體;;
4. 給變數賦預設值undefined,把所有函式宣告放進記憶體。
讓我們回過頭看看之前的程式碼,但這次我們不僅僅定義getUser
,還要呼叫一次,看看實際效果是什麼。
正如我們所說,當呼叫了getUser
,就建立了新的執行上下文。在getUser
執行上下文的建立階段的建立階段,Javascript引擎建立了this
物件和arguments
物件。getUser
沒有任何變數,所以Javascript引擎不需要再次分配記憶體或進行“提升”。
你可能注意到了,當getUser
函式執行完畢,它就從檢視中消失了。事實上,Javascript引擎建立了一個叫“執行棧”(也叫呼叫棧)的東西。每當函式被呼叫,就建立一個新的執行上下文並把它加入到呼叫棧;每當一個函式執行完畢,就被從呼叫棧中彈出來。因為Javascript是單執行緒的,通過Javascript Visualizer能看到,每一個新的執行上下文都巢狀在另一箇中,形成了呼叫棧。
現在我們知道了函式呼叫是如何建立它們各自的執行上下文並放到呼叫棧中的。但我們沒有看到區域性變數是如何作用的,那就讓我們來改寫之前的程式碼,讓函式擁有區域性變數。
這裡有幾處重要細節需要注意。首先,傳入函式的所有引數都作為區域性變數存在於該函式的執行上下文中。在例子中,handle
同時存在與全域性執行上下文和getURL
執行上下文中,因為我們把它傳入了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
。然後到了執行階段,把字串'Declare in foo'
賦予bar
。到這裡執行階段就結束了,foo
執行上下文從呼叫棧彈出。foo
彈出後,程式碼就執行到了列印bar
到控制檯的部分。此刻,根據Javascript Visualizer所展示的狀態,bar
似乎根本不存在,因此我們得到的是undefined
。(譯者注:實際上執行這個例子會報錯:Uncaught ReferenceError: bar is not defined
)這告訴我們,在函式中建立的變數,它的作用域是區域性的。這意味著(通常如此,後面會講例外)一旦函式的執行上下文從呼叫棧彈出,該函式中宣告的變數就訪問不到了。
再看一個例子。程式碼執行完畢後控制檯會列印出什麼?
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);
複製程式碼
控制檯會依次列印出undefined
、Jordyn
、Jake
、Tyler
。你可以這麼想:每個新的執行上下文都有它自己的變數環境。就算另有其他執行上下文包含變數name
,Javascript引擎仍會先從當前執行上下文裡找起。
這就帶來一個問題,要是當前執行上下文裡沒有要找的變數呢?Javascript會就此罷手嗎?下面的例子裡有答案。
var name='Tyler';
function logName(){
console.log(name);
}
logName();
複製程式碼
你的直覺可能會是:既然在logName
的執行上下文中找不到name
變數,那肯定列印出undefined
。其實不然。如果Javascript引擎在函式執行上下文找不到匹配的區域性變數,它會到最接近的父級上下文中查詢。這條查詢鏈會一直延伸到全域性執行上下文。如果此時仍然找不到該變數,Javascript引擎就會丟擲一個引用錯誤。
每逢當前執行上下文中找不到所需變數,Javascript引擎就向上逐級查詢,這個處理過程就是
作用域鏈
。Javascript Visualizer通過把每個執行上下文表示為不同顏色的區域並按層級縮排,來描述作用域鏈。你能直觀體會到,子級執行上下文可以引用父級執行上下文中宣告的變數,但反過來就不行。
之前我們瞭解到函式中建立的變數僅區域性有效,一旦函式執行上下文從呼叫棧彈出,這些變數就訪問不到了(通常如此)。現在是時候研究一下不在“通常如此”範圍的情況了。如果你在一個函式中嵌入了另一個函式,例外情況就產生了。這種函式套函式的情況下,即使父級函式的執行上下文從呼叫棧彈出了,子級函式仍然能夠訪問父級函式的作用域。囉嗦了一堆,我們還是用Javascript Visualizer看看吧。
var count=0;
function makeAdder(x){
return function inner(y){
return x+y;
}
}
var add5=makeAdder(5);
count+=add5(2);
複製程式碼
注意,makeAdder
執行上下文從呼叫棧彈出後,Javascript Visualizer建立了一個Closure Scope(閉包作用域)
。Closure Scope
中的變數環境和makeAdder
執行上下文中的變數環境相同。這是因為我們在函式中嵌入了另一個函式。在本例中,inner
函式嵌在makeAdder
中,所以inner
在makeAdder
變數環境的基礎上建立了一個閉包。因為閉包作用域的存在,即使makeAdder
已經從呼叫棧彈出了,inner
仍然能夠訪問到x
變數(通過作用域鏈)。
你可能已經猜到了,這種子函式在其父級函式的變數環境上“關閉”(譯者注:原文為a child function “closing” over the variable environment of its parent function
)的概念,就叫做閉包。
福利部分
下面是一些相關話題,我知道如果我不提及的話,肯定會有人揪我出來補充。
全域性變數
在瀏覽器中,你在全域性執行上下文中(不被任何函式包裹)建立的變數,都會成為window
物件的屬性。
在瀏覽器和Node環境中,如果你不宣告(比如使用var
/let
/const
)就直接建立了一個變數,這個變數同樣會成為全域性物件的屬性。
// In the browser
var name = 'Tyler'
function foo () {
bar = 'Created in foo without declaration'
}
foo()
console.log(window.name) // Tyler
console.log(window.bar) // Created in foo without declaration
複製程式碼
let和const
this
在本文中,我們瞭解到,每個執行上下文的建立階段中,Javascript引擎都會建立一個叫做this
的物件。如果你想深入學習關於this的知識,我建議你讀讀WTF is this - Understanding the this keyword, call, apply, and bind in JavaScript。