本文將深入探討JAVAScript中最重要的基礎知識之一:執行上下文。通過對此篇文章的閱讀,對以下幾個方面的知識你將會有更加清晰的認識:
- 解釋器的執行機制
- 為何函數和變量可以在聲明前使用以及它們的值究竟是如何確定的
什么是執行上下文?
當代碼在JS中運行時,代碼的執行環境非常重要,JavaScript中可執行的代碼分為以下幾類:
- 全局代碼:代碼首次執行時所進入的默認執行環境
- 函數代碼:函數體內的代碼
- Eval代碼:eval內部的代碼
我們可以在網上找到很多與作用域相關的文檔等,本文為了便于知識點的理解,將執行上下文看作是當前代碼執行所處的環境/作用域。下面是一個包括全局和函數上下文的代碼示例:
以上示例代碼結構很簡明,一個由紫色實線包裹的全局上下文和三個分別由綠色、藍色和橙色實線包裹的函數上下文。每個程序中只能有一個可被其他程序所訪問的全局上下文。函數上下文可以有任意多個,并且每個函數在調用的時候都會產生一個新的函數上下文和一個私有的作用域,當前作用域中所聲明的任何變量都不能被外部所直接訪問或調用。上例中,函數可直接訪問當前上下文外部聲明的變量,但是外部函數上下文不能訪問內部聲明的變量或者函數。為何會出現這種情況呢?代碼到底是怎么執行的呢?
執行環境棧
瀏覽器中JavaScript解釋器的運行是單線程的。這也就意味著在瀏覽器中同一時刻只能做一件事情,其他行為或者事件需要在執行棧中排隊等待。下圖是對單線程的抽象展示:
當瀏覽器首次加載腳本語言的時候,會默認進入全局執行上下文。如果在全局代碼中調用其他函數,當前程序的時序會自動進入所調用的函數中,與此同時會創建一個新的執行上下文并將其壓入執行棧的頂部。如果在當前函數內部調用其他函數,執行過程如上所述。代碼的執行流程會進入到內部函數中,創建一個新的執行上下文并將它壓入執行棧的頂部。瀏覽器永遠執行位于棧頂的執行上下文,并且一旦當前函數執行上下文執行結束,它將從棧頂彈出,執行控制權也會回到當前棧的新棧頂。這樣,執行環境棧中的上下文就會被依次執行和彈出棧頂,直到回到全局上下文,下例所示:
(function foo(i){ if (i===3) { return; }else{ foo(++i); } }(0)) 復制代碼
代碼自調用三次,i的值不斷從1自增。每次函數foo被調用的時候,一個新的執行上下文就會創建。一旦當前上下文執行結束,它就會從棧頂彈出,回到棧頂的新的上下文,直到再次回到全局上下文。
執行棧中需要記住的5個關鍵點:
- 單線程
- 同步執行
- 唯一的一個全局上下文
- 不限個數的函數上下文
- 每個函數的調用都會產生一個新的執行上下文,即使是函數對自己的調用
詳解執行上下文
截至目前我們已經知道每當一個函數被調用的時候,就會產生一個新的執行上下文。但是,在JavaScript解釋器中,對每個執行上下文的調用都分為以下兩個階段:
- 創建階段[當函數被調用,內部代碼被執行之前的階段]:
- 創建作用域鏈
- 創建變量、函數、參數
- 確定this的值
- 激活/代碼執行階段:
- 確定函數的值和引用,然后執行代碼
因為可以將執行上下文概念性的描述為含有三個屬性的對象:
executionContextObj = { 'scopeChain':{/*variableObject+所有父類執行上下文的variableObject*/}, 'variableObject':{/*函數形參/實參,內部的變量和函數聲明*/}, 'this':{} } 復制代碼
激活/變量對象[AO/VO]
執行上下文對象是在函數被調用,但是在函數被執行前所產生的。也就是上文所述的階段1—創建階段。比部分中,解釋器對執行上下文對象的創建主要是通過瀏覽函數的實參和形參、當前函數內部的變量聲明和函數聲明。這部分的瀏覽結果會成為執行上下文對象中的變量對象。
解釋器對代碼執行的偽邏輯概述:
- 查找函數調用的代碼
- 在執行代碼前,創建執行上下文
- 進入創建上下文階段:
- 初始化作用域鏈
- 創建變量對象:
- 創建參數對象,檢查上下文中的參數,初始化參數名稱和值并創建引用副本
- 瀏覽上下文中的函數聲明:
- 每找到一個函數,就在變量對象中添加一個新的屬性,該屬性命名為當前函數名,指向函數在內存中的引用
- 如果函數名已經存在,所對應的屬性值將被重寫,指向新的函數引用
- 瀏覽上下文中的變量聲明:
- 每找到一個變量聲明,在變量對象中添加一個新的屬性,該屬性命名為當前變量名,并給該屬性賦值為undefined
- 如果變量名已經在變量對象中存在,將不進行任何操作,繼續瀏覽當前上下文
- 確定上下文中this的指向
- 代碼執行階段:
- 分配變量值并且逐行執行當前上下文中的代碼
下面看一個例子:
function foo(i){ var a = 'hello', var b = function privateB(){ }, function c(){ } } foo(22); 復制代碼
當調用函數foo的時候,創建階段如下所示:
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':{...} } 復制代碼
變量提升
網上很多關于JavaScript中變量提升的定義,定義中指出變量和函數的聲明會被提升至當前函數作用域的頂部。但是,并沒有解釋為什么會存在變量提升以及解釋器如何創建激活對象,其實原因很簡單,以下面的代碼為例:
(function() { console.log(typeof foo); // function pointer console.log(typeof bar); // undefiend var foo = 'hello', bar = function (){ return 'world'; }; function foo(){ return 'hello'; }; }()) 復制代碼
對于疑問和解答如下:
- 為什么我們可以在聲明foo前訪問它?
- 回顧創建階段,變量在函數執行前已經被創建。因此在函數執行前,foo已經在激活對象中創建。
- foo被聲明了兩次,為什么foo的類型是function而不是undefined或者string?
- 盡管foo被聲明兩次,在創建階段中,函數先于變量在激活對象中創建,并且如果激活對象中已經存在屬性名,則不會影響已經存在的屬性。
- 所以,對于函數foo的引用首先在激活對象中已經創建,并且當解釋器到達var foo語句,解釋器發現在變量對象中foo已經被創建,因此就會跳過然后繼續后續操作。
- 為什么bar的值是undefined?
- bar實際上是一個值為函數的變量,在創建階段變量會被初始化為undefined 。
注:以上部分譯自此文,如有侵權請告知;如有翻譯不妥,還請各位讀者指正。以下是我對本文知識點的簡要總結。
簡要總結
- 每個函數被調用的時候,都會創建一個新的執行上下文,并將當前執行上下文壓入棧頂
- 每個執行上下文可以看作是具有以下3個屬性的對象:
- 作用域鏈
- 變量對象/激活對象(VO/AO)
- this
- 每個執行上下文的建立分為兩個階段:創建階段和執行階段
- 執行上下文創建階段,變量對象VO初始化的先后順序:函數參數、函數聲明、變量聲明。關于此部分兩個常見問題的解答如下:
- 1、"函數聲明過程中,變量對象中如果已存在同名的屬性,則替換它的值"這句話如何理解?以下述代碼為例:
function foo(i){ console.log(i); // function pointer var i = function (){ } } foo(2); 復制代碼 變量對象初始化第一步:函數參數 復制代碼 executionContextObj = { 'scopeChain':{...}, 'variableObject':{ arguments:{ 0:2, length: 1, }, i:2 } } 復制代碼 變量對象初始化第二步:函數聲明 函數聲明過程中,變量對象中已存在同名的屬性i,將其值由"1"替換為新值"function" 復制代碼 executionContextObj = { 'scopeChain':{...}, 'variableObject':{ arguments:{ 0:2, length: 1, }, i: function (){ } } } 復制代碼
- 2、"變量聲明過程中,變量對象中如果已存在同名的屬性,則不進行任何操作"這句話如何理解?以下述代碼為例:
function foo(i){ console.log(i); // function pointer var i = function (){ }, var i = 9; } foo(2); 復制代碼 變量對象初始化第一步:函數參數 復制代碼 executionContextObj = { 'scopeChain':{...}, 'variableObject':{ arguments:{ 0:2, length: 1, }, i:2 } } 復制代碼 變量對象初始化第二步:函數聲明 函數聲明過程中,變量對象中已存在同名的屬性i,將其值由‘1’替換為新值‘function’ 復制代碼 executionContextObj = { 'scopeChain':{...}, 'variableObject':{ arguments:{ 0:2, length: 1, }, i: function (){ } } } 復制代碼 變量對象初始化第三步:變量聲明 變量聲明過程中,變量對象中已存在同名的屬性i,不進行任何操作。 復制代碼 executionContextObj = { 'scopeChain':{...}, 'variableObject':{ arguments:{ 0:2, length: 1, }, i: function (){ } }, 'this':{...} } 復制代碼