在這篇文章里,我將深入研究JavaScript中最基本的部分——執行上下文(execution context)。讀完本文后,你應該清楚了解解釋器做了什么,為什么函數和變量能在聲明前使用以及他們的值是如何決定的。
什么是執行上下文?
當JavaScript代碼運行,執行環境非常重要,有下面幾種不同的情況:
- 全局代碼——你的代碼首次執行的默認環境。
- 函數代碼——每當進入一個函數內部。
- Eval代碼——eval內部的文本被執行時。
在網上你能讀到許多關於作用域(scope)的資源,本文的目的是讓事情變得更簡單,讓我們將術語執行上下文想象為當前被執行代碼的環境/作用域。說的夠多了,現在讓我們看一個包含全局和函數上下文的代碼例子。
很簡單的例子,我們有一個被紫色邊框圈起來的全局上下文和三個分別被綠色,藍色和橘色框起來的不同函數上下文。只有全局上下文(的變量)能被其他任何上下文訪問。
你可以有任意多個函數上下文,每次調用函數創建一個新的上下文,會創建一個私有作用域,函數內部聲明的任何變量都不能在當前函數作用域外部直接訪問。在上面的例子中,函數能訪問當前上下文外面的變量聲明,但在外部上下文不能訪問內部的變量/函數聲明。為什么會發生這種情況?代碼到底是如何被解釋的?
執行上下文堆棧
瀏覽器里的JavaScript解釋器被實現為單線程。這意味着同一時間只能發生一件事情,其他的行文或事件將會被放在叫做執行棧里面排隊。下面的圖是單線程棧的抽象視圖:
我們已經知道,當瀏覽器首次載入你的腳本,它將默認進入全局執行上下文。如果,你在你的全局代碼中調用一個函數,你程序的時序將進入被調用的函數,並穿件一個新的執行上下文,並將新創建的上下文壓入執行棧的頂部。
如果你調用當前函數內部的其他函數,相同的事情會在此上演。代碼的執行流程進入內部函數,創建一個新的執行上下文並把它壓入執行棧的頂部。瀏覽器將總會執行棧頂的執行上下文,一旦當前上下文函數執行結束,它將被從棧頂彈出,並將上下文控制權交給當前的棧。下面的例子顯示遞歸函數的執行棧調用過程:
(function foo(i) { if (i === 3) { return; } else { foo(++i); } }(0));
這代碼調用自己三次,每次給i的值加一。每次foo函數被調用,將創建一個新的執行上下文。一旦上下文執行完畢,它將被從棧頂彈出,並將控制權返回給下面的上下文,直到只剩全局上下文能為止。
有5個需要記住的關鍵點,關於執行棧(調用棧):
- 單線程。
- 同步執行。
- 一個全局上下文。
- 無限制函數上下文。
- 每次函數被調用創建新的執行上下文,包括調用自己。
執行上下文的細節
我們現在已經知道沒次調用函數,都會創建新的執行上下文。然而,在JavaScript解釋器內部,每次調用執行上下文,分為兩個階段:
- 創建階段【當函數被調用,但未執行任何其內部代碼之前】:
- 創建作用域鏈(Scope Chain)
- 創建變量,函數和參數。
- 求”this“的值。
- 激活/代碼執行階段:
- 指派變量的值和函數的引用,解釋/執行代碼。
可以將每個執行上下文抽象為一個對象並有三個屬性:
executionContextObj = { scopeChain: { /* 變量對象(variableObject)+ 所有父執行上下文的變量對象*/ },
variableObject: { /*函數 arguments/參數,內部變量和函數聲明 */ },
this: {}
}
激活/變量對象【AO/VO】
當函數被調用是executionContextObj被創建,但在實際函數執行之前。這是我們上面提到的第一階段,創建階段。在此階段,解釋器掃描傳遞給函數的參數或arguments,本地函數聲明和本地變量聲明,並創建executionContextObj對象。掃描的結果將完成變量對象的創建。
Here is a pseudo-overview of how the interpreter evaluates the code:
- 查找調用函數的代碼。
- 執行函數代碼之前,先創建執行上下文。
- 進入創建階段:
- 初始化作用域鏈:
- 創建變量對象:
- 創建arguments對象,檢查上下文,初始化參數名稱和值並創建引用的復制。
- 掃描上下文的函數聲明:
- 為發現的每一個函數,在變量對象上創建一個屬性——確切的說是函數的名字——其有一個指向函數在內存中的引用。
- 如果函數的名字已經存在,引用指針將被重寫。
- 掃面上下文的變量聲明:
- 為發現的每個變量聲明,在變量對象上創建一個屬性——就是變量的名字,並且將變量的值初始化為undefined
- 如果變量的名字已經在變量對象里存在,將不會進行任何操作並繼續掃描。
- 求出上下文內部“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術語的資源,解釋變量和函數聲明被提升到函數作用域的頂部。然而,沒有人解釋為什么會發生這種情況的細節,學習了上面關於解釋器如何創建愛你活動對象的新知識,很容易明白為什么。看下面的例子:
(function() { console.log(typeof foo); // 函數指針 console.log(typeof bar); // undefined var foo = 'hello', bar = function() { return 'world'; }; function foo() { return 'hello'; } }());
我們能回答下面的問題:
- 為什么我們能在foo聲明之前訪問它?
- 如果我們跟隨創建階段,我們知道變量在激活/代碼執行階段已經被創建。所以在函數開始執行之前,foo已經在活動對象里面被定義了。
- Foo被聲明了兩次,為什么foo顯示為函數而不是undefined或字符串?
- 盡管foo被聲明了兩次,我們知道從創建階段函數已經在活動對象里面被創建,這一過程發生在變量創建之前,並且如果屬性名已經在活動對象上存在,我們僅僅更新引用。
- 因此,對foo()函數的引用首先被創建在活動對象里,並且當我們解釋到var foo時,我們看見foo屬性名已經存在,所以代碼什么都不做並繼續執行。
- 為什么bar的值是undefined?
- bar實際上是一個變量,但變量的值是函數,並且我們知道變量在創建階段被創建但他們被初始化為undefined。
總結
希望現在你了解JavaScript解釋器如何執行你的代碼。了解執行上下文和堆棧,將有助於你了解背后的原因——為什么你的代碼被解釋為和你最初希望不同的值。
你想知道解釋器內部的運作的開銷太大,或者你的JavaScript知識的必要性?知道執行上下文相幫你寫出更好的JavaScript?
你想知道解釋器的內部工作原理,需要太多篇幅,和必要的JavaScript知識。知道執行上下文能幫你寫出更好的JavaScript代碼。
注意:有些人一直在問閉包,回調,延時等問題,我將在下一篇文章里提到,更多關注域執行上下文有關的作用域鏈相關方面。
深入閱讀
- ECMA-262 5th Edition
- ECMA-262-3 in detail. Chapter 2. Variable object
- Identifier Resolution, Execution Contexts and scope chains
注
原文:http://davidshariff.com/blog/what-is-the-execution-context-in-javascript/
相關閱讀
- 在JavaScript中什么時候使用==是正確的?
- 我希望我知道的七個JavaScript技巧
- 僅100行的JavaScript DOM操作類庫
- 每一個JavaScript開發者應該了解的浮點知識
- 揭秘javascript中謎一樣的this




