在JavaScript的運行過程中,經常會遇到一些"奇怪"的行為,不理解為什么JavaScript會這么工作。
這時候可能就需要了解一下JavaScript執行過程中的相關內容了。
執行上下文
在JavaScript中有三種代碼運行環境:
-
Global Code
- JavaScript代碼開始運行的默認環境
-
Function Code
- 代碼進入一個JavaScript函數
-
Eval Code
- 使用eval()執行代碼
為了表示不同的運行環境,JavaScript中有一個執行上下文(Execution context,EC)的概念。也就是說,當JavaScript代碼執行的時候,會進入不同的執行上下文,這些執行上下文就構成了一個執行上下文棧(Execution context stack,ECS)。
例如對如下面的JavaScript代碼:
var a = "global var"; function foo(){ console.log(a); } function outerFunc(){ var b = "var in outerFunc"; console.log(b); function innerFunc(){ var c = "var in innerFunc"; console.log(c); foo(); } innerFunc(); } outerFunc()
代碼首先進入Global Execution Context,然后依次進入outerFunc,innerFunc和foo的執行上下文,執行上下文棧就可以表示為:
當JavaScript代碼執行的時候,第一個進入的總是默認的Global Execution Context,所以說它總是在ECS的最底部。
對於每個Execution Context都有三個重要的屬性,變量對象(Variable object,VO),作用域鏈(Scope chain)和this。這三個屬性跟代碼運行的行為有很重要的關系,下面會一一介紹。
當然,除了這三個屬性之外,根據實現的需要,Execution Context還可以有一些附加屬性。
VO和AO
從上面看到,在Execution Context中,會保存變量對象(Variable object,VO),下面就看看變量對象是什么。
變量對象(Variable object)
變量對象是與執行上下文相關的數據作用域。它是一個與上下文相關的特殊對象,其中存儲了在上下文中定義的變量和函數聲明。也就是說,一般VO中會包含以下信息:
- 變量 (var, Variable Declaration);
- 函數聲明 (Function Declaration, FD);
- 函數的形參
當JavaScript代碼運行中,如果試圖尋找一個變量的時候,就會首先查找VO。對於前面例子中的代碼,Global Execution Context中的VO就可以表示如下:
注意,假如上面的例子代碼中有下面兩個語句,Global VO仍將不變。
(function bar(){}) // function expression, FE baz = "property of global object"
也就是說,對於VO,是有下面兩種特殊情況的:
- 函數表達式(與函數聲明相對)不包含在VO之中
- 沒有使用var聲明的變量(這種變量是,"全局"的聲明方式,只是給Global添加了一個屬性,並不在VO中)
活動對象(Activation object)
只有全局上下文的變量對象允許通過VO的屬性名稱間接訪問;在函數執行上下文中,VO是不能直接訪問的,此時由激活對象(Activation Object,縮寫為AO)扮演VO的角色。激活對象 是在進入函數上下文時刻被創建的,它通過函數的arguments屬性初始化。
Arguments Objects 是函數上下文里的激活對象AO中的內部對象,它包括下列屬性:
- callee:指向當前函數的引用
- length: 真正傳遞的參數的個數
- properties-indexes:就是函數的參數值(按參數列表從左到右排列)
對於VO和AO的關系可以理解為,VO在不同的Execution Context中會有不同的表現:當在Global Execution Context中,可以直接使用VO;但是,在函數Execution Context中,AO就會被創建。
當上面的例子開始執行outerFunc的時候,就會有一個outerFunc的AO被創建:
通過上面的介紹,我們現在了解了VO和AO是什么,以及他們之間的關系了。下面就需要看看JavaScript解釋器是怎么執行一段代碼,以及設置VO和AO了。
細看Execution Context
當一段JavaScript代碼執行的時候,JavaScript解釋器會創建Execution Context,其實這里會有兩個階段:
-
創建階段(當函數被調用,但是開始執行函數內部代碼之前)
- 創建Scope chain
- 創建VO/AO(variables, functions and arguments)
- 設置this的值
-
激活/代碼執行階段
- 設置變量的值、函數的引用,然后解釋/執行代碼
這里想要詳細介紹一下"創建VO/AO"中的一些細節,因為這些內容將直接影響代碼運行的行為。
對於"創建VO/AO"這一步,JavaScript解釋器主要做了下面的事情:
- 根據函數的參數,創建並初始化arguments object
-
掃描函數內部代碼,查找函數聲明(Function declaration)
- 對於所有找到的函數聲明,將函數名和函數引用存入VO/AO中
- 如果VO/AO中已經有同名的函數,那么就進行覆蓋
-
掃描函數內部代碼,查找變量聲明(Variable declaration)
- 對於所有找到的變量聲明,將變量名存入VO/AO中,並初始化為"undefined"
- 如果變量名稱跟已經聲明的形式參數或函數相同,則變量聲明不會干擾已經存在的這類屬性
看下面的例子:
function foo(i) { var a = 'hello'; var b = function privateB() { }; function c() { } } foo(22);
對於上面的代碼,在"創建階段",可以得到下面的Execution Context object:
fooExecutionContext = { scopeChain: { ... }, variableObject: { arguments: { 0: 22, length: 1 }, i: 22, c: pointer to function c() a: undefined, b: undefined }, this: { ... } }
在"激活/代碼執行階段",Execution Context object就被更新為:
fooExecutionContext = { scopeChain: { ... }, variableObject: { arguments: { 0: 22, length: 1 }, i: 22, c: pointer to function c() a: 'hello', b: pointer to function privateB() }, this: { ... } }
例子分析
前面介紹了Execution Context,VO/AO等這么多的理論知識,當然是為了方便我們去分析代碼中的一些行為。這里,就通過幾個簡單的例子,結合上面的概念來分析結果。
Example 1
首先看第一個例子:
(function(){ console.log(bar); console.log(baz); var bar = 20; function baz(){ console.log("baz"); } })()
在Chrome中運行代碼運行后將輸出:
代碼解釋:匿名函數會首先進入"創建結果",JavaScript解釋器會創建一個"Function Execution Context",然后創建Scope chain,VO/AO和this。根據前面的介紹,解釋器會掃描函數和變量聲明,如下的AO會被創建:
所以,對於bar,我們會得到"undefined"這個輸出,表現的行為就是,我們在聲明一個變量之前就訪問了這個變量。這個就是JavaScript中"Hoisting"。
Example 2
接着上面的例子,進行一些修改:
(function(){ console.log(bar); console.log(baz); bar = 20; console.log(window.bar); console.log(bar); function baz(){ console.log("baz"); } })()
運行這段代碼會得到"bar is not defined(…)"錯誤。當代碼執行到"console.log(bar);"的時候,會去AO中查找"bar"。但是,根據前面的解釋,函數中的"bar"並沒有通過var關鍵字聲明,所有不會被存放在AO中,也就有了這個錯誤。
注釋掉"console.log(bar);",再次運行代碼,可以得到下面結果。"bar"在"激活/代碼執行階段"被創建。
Example 3
現在來看最后一個例子:
(function(){ console.log(foo); console.log(bar); console.log(baz); var foo = function(){}; function bar(){ console.log("bar"); } var bar = 20; console.log(bar); function baz(){ console.log("baz"); } })()
代碼的運行結果為:
代碼中,最"奇怪"的地方應該就是"bar"的輸出了,第一次是一個函數,第二次是"20"。
其實也很好解釋,回到前面對"創建VO/AO"的介紹,在創建VO/AO過程中,解釋器會先掃描函數聲明,然后"foo: <function>"就被保存在了AO中;但解釋器掃描變量聲明的時候,雖然發現"var bar = 20;",但是因為"foo"在AO中已經存在,所以就沒有任何操作了。
但是,當代碼執行到第二句"console.log(bar);"的時候,"激活/代碼執行階段"已經把AO中的"bar"重新設置了。
總結
本文介紹了JavaScript中的執行上下文(Execution Context),以及VO/AO等概念,最后通過幾個例子展示了這幾個概念對我們了解JavaScript代碼運行的重要性。
通過對VO/AO在"創建階段"的具體細節,如何掃描函數聲明和變量聲明,就可以對JavaScript中的"Hoisting"有清晰的認識。所以說,了解JavaScript解釋器的行為,以及相關的概念,對理解JavaScript代碼的行為是很有幫助的。
后面會對Execution Context中的Scope chain和this進行介紹。