What is the Execution Context &
Stack in JavaScript?
在這篇文章中,我將深入探討JavaScript的最基本部分之一,即Execution Context
(執行上下文)。 在本文結束時,你應該對直譯器瞭解得更清楚:為什麼在宣告它們之前可以使用某些函式或變數?以及它們的值是如何確定的?
什麼是執行上下文?
JavaScript的執行環境非常重要,當JavaScript程式碼在行時,會被預處理為以下情況之一:
- Global code – 首次執行程式碼的預設環境。
- Function code – 每當執行流程進入函式體時。
- Eval code – 要在eval函式內執行的文字。
你可以閱讀大量涉及作用域
的線上資料,不過為了使事情更容易理解,讓我們將術語“執行上下文”
視為當前程式碼的執行環境或作用域。接下來讓我們看一個包含global和function / local上下文的程式碼示例。
這裡沒有什麼特別之處,我們有一個由紫色邊框表示的全域性上下文
,和由綠色,藍色和橙色邊框表示的3個不同的函式上下文
。 只能有1個全域性上下文
,可以從程式中的任何其他上下文訪問。
你可以擁有任意數量的函式上下文
,並且每個函式呼叫都會建立一個新的上下文,從而建立一個私有作用域,其中無法從當前函式作用域外直接訪問函式內部宣告的任何內容。 在上面的示例中,函式可以訪問在其當前上下文之外宣告的變數,但外部上下文無法訪問在其中宣告的變數或函式。 為什麼會這樣呢? 這段程式碼究竟是如何處理的?
Execution Context Stack(執行上下文堆疊)
瀏覽器中的JavaScript直譯器被實現為單個執行緒。 實際上這意味著在瀏覽器中一次只能做一件事,其他動作或事件在所謂的執行堆疊中排隊。 下圖是單執行緒堆疊的抽象檢視:
我們已經知道,當瀏覽器首次載入指令碼時,它預設進入全域性上下文
執行。 如果在全域性程式碼中呼叫函式,程式的順序流進入被呼叫的函式,建立新的執行上下文並將其推送到執行堆疊
的頂部。
如果在當前函式中呼叫另一個函式,則會發生同樣的事情。 程式碼的執行流程進入內部函式,該函式建立一個新的執行上下文
,該上下文被推送到現有堆疊的頂部。 瀏覽器將始終執行位於堆疊頂部的當前執行上下文
,並且一旦函式執行完當前執行上下文
後,它將從棧頂部彈出,把控制權返回到當前棧中的下一個上下文。 下面的示例顯示了遞迴函式和程式的執行堆疊
:
(function foo(i) {
if (i === 3) {
return;
} else {
foo(++i);
}
}(0));
複製程式碼
程式碼簡單地呼叫自身3次,並將i
的值遞增1。每次呼叫函式foo
時,都會建立一個新的執行上下文
。 一旦上下文完成執行,它就會彈出堆疊並且講控制返回到它下面的上下文,直到再次達到全域性上下文
。
關於執行堆疊execution stack
有5個關鍵要點:
- 單執行緒。
- 同步執行。
- 一個全域性上下文。
- 任意多個函式上下文。
- 每個函式呼叫都會建立一個新的執行上下文
execution context
,甚至是對自身的呼叫。
執行上下文的細節
所以我們現在知道每次呼叫一個函式時,都會建立一個新的執行上下文
。 但是,在JavaScript直譯器中,對執行上下文
的每次呼叫都有兩個階段:
-
建立階段 [呼叫函式時,但在執行任何程式碼之前]:
- 建立
作用域鏈
。 - 建立變數,函式和引數。
- 確定
“this”
的值。
- 建立
-
啟用/程式碼執行階段:
- 分配值,引用函式和解釋/執行程式碼。
可以將每個執行上下文
在概念上表示為具有3個屬性的物件:
executionContextObj = {
'scopeChain': {
/* variableObject + 所有父執行上下文的variableObject */
}, 'variableObject': {
/* 函式實參/形參,內部變數和函式宣告 */
}, 'this': {
}
}複製程式碼
啟用物件/變數物件 [AO/VO]
在呼叫該函式,並且在實際執行函式之前,會建立這個executionContextObj
。 這被稱為第1階段,即創造階段
。 這時直譯器通過掃描函式傳遞的實參或形參、本地函式宣告和區域性變數宣告來建立executionContextObj
。 此掃描的結果將成為executionContextObj
中的variableObject
。
以下是直譯器如何預處理程式碼的虛擬碼概述:
- 找一些程式碼來呼叫一個函式。
- 在執行功能程式碼之前,建立
執行上下文
。 - 進入建立階段:
- 初始化作用域鏈。
- 建立
variable object
:- 建立
arguments object
,檢查引數的上下文,初始化名稱和值並建立引用副本。 - 掃描上下文以獲取函式宣告:
- 對於找到的每個函式,在
variable object
中建立一個屬性,該屬性是函式的確切名稱,該屬性存在指向記憶體中函式的引用指標。 - 如果函式名已存在,則將覆蓋引用指標值。
- 對於找到的每個函式,在
- 掃描上下文以獲取變數宣告:
- 對於找到的每個變數宣告,在
variable object
中建立一個屬性作為變數名稱,並將該值初始化為undefined
。 - 如果變數名稱已存在於
variable object
中,則不執行任何操作並繼續掃描。
- 對於找到的每個變數宣告,在
- 建立
- 確定上下文中
“this”
的值。
- 啟用/執行階段:
- 在上下文中執行/解釋函式程式碼,並在程式碼逐行執行時分配變數值。
我們來看一個例子:
function foo(i) {
var a = 'hello';
var b = function privateB() {
};
function c() {
}
}foo(22);
複製程式碼
在呼叫foo(22)
時,建立階段
如下所示:
fooExecutionContext = {
scopeChain: {
...
}, variableObject: {
arguments: {
0: 22, length: 1
}, i: 22, c: pointer to function c() a: undefined, b: undefined
}, this: {
...
}
}複製程式碼
如你所見,建立階段
處理定義屬性的名稱,而不是為它們賦值,但正式的形參/實參除外。建立階段
完成後,執行流程
進入函式,啟用/程式碼執行階段
在函式執行完畢後如下所示:
fooExecutionContext = {
scopeChain: {
...
}, variableObject: {
arguments: {
0: 22, length: 1
}, i: 22, c: pointer to function c() a: 'hello', b: pointer to function privateB()
}, this: {
...
}
}複製程式碼
關於hoisting
你可以找到許多使用JavaScript定義術語hoisting
的線上資源,解釋變數和函式宣告被hoisting到其函式範圍的頂部。 但是沒有人能夠詳細解釋為什麼會發生這種情況,掌握了關於直譯器如何建立啟用物件
的新知識,很容易理解為什麼。 請看下面的程式碼示例:
(function() {
console.log(typeof foo);
// function pointer console.log(typeof bar);
// undefined var foo = 'hello', bar = function() {
return 'world';
};
function foo() {
return 'hello';
}
}());
複製程式碼
我們現在可以回答的問題是:
- 為什麼我們可以在宣告foo之前就能訪問?
- 如果我們理解了
建立階段
,就知道在啟用/程式碼執行
階段之前已經建立了變數。因此,當函式流開始執行時,已經在啟用物件中定義了foo。
- 如果我們理解了
- Foo被宣告兩次,為什麼foo顯示為
function
而不是undefined
或string
?- 即使
foo
被宣告兩次,我們通過建立階段
知道函式在變數之前就被建立在啟用物件
上了,而且如果啟用物件
上已經存在了屬性名稱,我們只是繞過了宣告這一步驟。 - 因此,首先在
啟用物件
上建立對函式foo()
的引用,並且當直譯器到達var foo
時,我們已經看到屬性名稱foo
存在,因此程式碼不執行任何操作並繼續處理。
- 即使
- 為什麼
bar
未定義?bar
實際上是一個具有函式賦值的變數,我們知道變數是在建立階段
被建立的,但它們是使用undefined
值初始化的。
總結
希望到這裡你已經能夠很好地掌握了JavaScript直譯器如何預處理你的程式碼。 理解執行上下文和堆疊可以讓你瞭解背後的原因:為什麼程式碼預處理後的值和你預期的不一樣。
你認為學習直譯器的內部工作原理是多此一舉還是非常必要的呢? 瞭解執行上下文階段是否能夠幫你你寫出更好的JavaScript呢?