JavaScript的執行環境和執行環境棧

JavaScript的執行環境和執行環境棧,本文將會深入講解Javascript中最重要的概念的之一,執行環境(Execution Context)。讀完本文你將對解析器所做的事情有更深的理解,也知道為什麼有些變量/函數可以先執行後聲明以及它們的值是如何確定的。

執行環境(Execution Context)的含義

JavaScript代碼執行的環境非常重要,而執行環境可以歸納為以下三種:

全局代碼(Global Code) – 代碼首次執行時的默認環境 函數代碼(Function Code) – 程序執行到函數體內時 Eval函數代碼(Eval Code) – 內置Eval函數計算的字符串

網上很多資料都提到瞭作用域(scope)這個詞,為瞭便於理解在此我們暫且把執行環境(execution context)理解為代碼運行時所處的環境(environment / scope )。閑話少敘,先來看一個同時包含全局環境(global context)和函數/局部環境(funtion / local context)的代碼范例:

類似的代碼很常見。上圖中全局環境(global context)用紫色框表示,三個函數環境(function context)分別用藍橙綠框表示。在JavaScript代碼中隻能有一個全局環境,它可以被其他執行環境訪問。

函數環境(function context)則可以有任意多個。函數的每次調用都會新建一個新的執行環境,當前函數環境外的代碼無法訪問在該函數內聲明的變量或函數。上圖的代碼范例中,函數可以訪問其環境外聲明的變量(父環境中的變量),但是環境外的代碼則無法訪問該函數體內聲明的變量或函數。這是為何?上圖的代碼又是如何運行的呢?vcD4NCjxoMiBpZD0=”執行環境棧execution-context-stack”>執行環境棧(Execution Context Stack)

瀏覽器中的JavaScript解析器是單線程的。瀏覽器一次隻能執行一個任務,其他操作或事件隻能在一個叫執行棧(Execution Stack)的地方排隊等待執行。下圖為單線程棧的抽象表示:

正如上文所說,瀏覽器加載代碼時會默認進入全局執行環境(global execution context)。在全局環境下如果調用函數,執行流就會進入該函數體內,創建一個新的執行環境並將其置於執行棧的頂部。

如果在該函數體內再調用一個函數,同樣的事情會再發生一次。代碼執行流會進入最裡的函數體內,創建新的執行環境並將其推入執行棧。瀏覽器總會先運行執行棧頂部的環境裡的代碼,一旦代碼執行完畢,該環境就會被推出並將控制權交給下一個執行環境。下方的范例代碼展示瞭一個遞歸函數及其執行棧:

(function foo(i) {
    if (i === 3) {
        return;
    }
    else {
        foo(++i);
    }
}(0));

]

上述代碼調用瞭自己3次,每次將參數遞增1。每次函數foo被調用,就會創建一個新的執行環境。每次環境中的代碼執行完畢,其就會被從執行棧中推出並將控制權交給棧中下一個環境,直至控制權回到全局環境為止。

關於執行棧有5點需要記牢:

單線程 同步執行 隻能有1個全局環境 任意多個函數環境 每次調用函數就會新建一個環境,即使函數調用自身也是如此

執行環境詳解

我們已經知道每次調用函數都會新建一個執行環境。但是在JavaScript解析器內部,調用執行環境要經歷兩個階段:

第一階段: 創建階段(Creation Stage) – 當函數被調用,但尚未執行函數體內的代碼時:

創建作用域鏈(Scope Chain) 創建變量、函數和參數 確定this的值

第二階段: 活動 / 代碼執行階段(Activation / Code Execution Stage)

將變量和引用分配給函數並解析/執行代碼

可以用帶有三個屬性的對象來概念化地表示執行環境:

executionContextObj = {
    'scopeChain': { /* 變量對象 + 所有父級執行環境的變量對象(variableObject + all parent execution context's variableObject) */ },
    'variableObject': { /*函數形參實參、局部變量和函數聲明(function arguments / parameters, inner variable and function declarations) */ },
    'this': {}
}

活動/變量對象(Activation / Variable Object [AO/VO])

譯者註:關於activation object和variable object的同異,可以查看一下兩個鏈接的內容:
What is an Activation object in JavaScript?
Activation and Variable Object in JavaScript?

在函數被調用但尚未執行前,executionContextObj對象會被創建,也就是所謂的名為創建階段的第一階段。此時解析器會掃描被傳參的函數、局部函數和變量的聲明,然後創建executionContextObj對象。掃描的結果就成為瞭executionContextObj對象裡的variableObject屬性的內容。

下面是對JavaScript解析器如何解析代碼的概覽:

找到調用函數的代碼段 在執行函數體內代碼前,創建執行環境 進入創建階段
初始化作用域鏈 創建變量對象
創建參數對象(arguments object),在環境裡查找參數並初始化參數名值,創建引用的拷貝。 在環境裡查找函數聲明
每找到一個函數,就在變量對象裡創建一個同名屬性,屬性中有引用指針指向該函數在內存中的位置 如果在變量對象中該函數名已經存在,引用指針的值將會被覆蓋 在環境裡查找變量聲明
每找到一個變量,就在變量對象裡創建一個同名屬性,並將該變量初始化為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)進行瞭定義,對其解釋是變量和函數聲明被提升到瞭函數作用域的頂部。然後並沒有人詳細解釋為什麼會發生變量提升。用本文提出的解析器如何創建活動對象(activation object)的知識,就可以輕易地解釋這種現象。看看下列代碼范例:

?(function() {

    console.log(typeof foo); // function pointer
    console.log(typeof bar); // undefined

    var foo = 'hello',
        bar = function() {
            return 'world';
        };

    function foo() {
        return 'hello';
    }

}());?

我們可以回答下列問題:
1. 為什麼在foo被聲明前就可以訪問它?
我們知道變量在創建階段已經被創建,在活動 / 代碼執行階段之前。所以當函數體開始被執行時,變量foo已經在活動對象中被定義。

2. foo被聲明瞭兩次,為什麼它的值打印出來是“function”而不是“undefined”或者“string”呢?
盡管foo被聲明瞭兩次,但在創建階段時活動對象中函數名對應的屬性先於變量被創建。而對於變量來說,如果在活動對象中對應的屬性名已經存在,則不會再進行聲明。因此在活動對象中函數foo()對應的屬性先被創建。而解析器碰到變量foo時,由於屬性foo已經存在,所以它會略過不作處理。

3. 為什麼bar打印出來是undefined?
bar實際上是一個值為函數返回值的變量。在創建階段變量已經被創建,但其值被初始化為undefined。

總結

希望現在你已經掌握瞭解析器是如何解析你的代碼的。理解執行環境和執行環境棧有助於理解為何代碼的執行結果和你預期的不同。

你覺得解析器內部工作原理是學習JavaScriptb必須掌握的知識還是已超出你的承受程度?理解執行環境調用的不同階段是否有助於你寫出更好的JavaScript代碼?

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *