JS中的執行上下文(Execution Context)和棧(stack)

本文翻譯之 http://davidshariff.com/blog/what-is-the-execution-context-in-javascript/

在這篇文章中,我將深入探討JavaScript中一個最基本的部分,即Execution Context。 在本文結束時,您應該更清楚地知道解釋器是怎么工作的,為什么某些函數/變量在聲明之前就可以使用以及它們的值是如何確定的。

一:什么是執行上下文?

當JavaScript代碼運行的時候,確定它運行所在的環境是非常重要的。運行環境由下面三種不同的代碼類型確定

  • 全局代碼(Global Code):代碼首次執行時候的默認環境
  • 函數代碼(Function Code):每當執行流程進入到一個函數體內部的時候
  • Eval代碼(Eval Code):當eval函數內部的文本執行的時候

您可以在網上找到大量關于scope的參考資料。為了更易于理解,我們將execution context簡單視為運行當前代碼的environment/scope。好了,話不多說,先讓我們看個例子,其中包含了global context和function/local context 代碼。

Example 1

在上圖中,我們有1個全局上下文(Global Context),使用紫色邊框表示;有3個不同的函數上下文(Function Context)由綠色,藍色,和橙色邊框表示。注意!全局上下文有且只有一個,程序中其他任意的上下文都可以訪問全局上下文。
你可以擁有任意數量的函數上下文。每一次函數調用都會創建一個新的上下文,它會創建一個私有域,函數內部做出的所有聲明都會放在這個私有域中,并且這些聲明在當前函數作用域外無法直接訪問。在上面的例子中,一個函數可以訪問它所在的上下文尾部的變量,但是一個外部的上下文無法訪問內部函數內部聲明的變量/函數。為什么會發生這樣的情況?代碼究竟是如何被解析的呢?

二:執行上下文棧

瀏覽器中的JS解釋器是單線程的。也就是說在瀏覽器中同一時間只能做一個事情,其他的action和event都會被排隊放入到執行棧中(Execution Stack)。下圖表示了一個單線程棧的抽象視圖


Execution Context Stack

如我們所知,當一個瀏覽器第一次load你的代碼的時候,首先它會進入到一個全局執行上下文中。如果在你的全局代碼中,你調用了一個函數,那么程序的執行流程會進入到被調用的函數中,并創建一個新的執行上下文,并將這個上下文推入到執行棧頂。
如果在當前的函數中,你由調用了一個函數,那么也會執行同樣的操作。執行流程計入到剛被調用的函數內部,重新創建一個新的執行上下文,并再次推入到執行棧頂。瀏覽器會一直執行當前棧頂的執行上下文,一旦函數執行完畢,該上下文就會被推出執行棧。下面的例子展示了一個遞歸函數以及該程序的執行棧:

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

這個代碼循環調用了三次,每次對i累加1。每次函數foo調用的時候,都會有一個創建新的執行上下文。一旦上下文完成了執行,就會推出棧,將控制流返回給它下面的執行上下文,這樣一直到全局上下文。
關于執行棧,有5點需要記?。?/p>

  • 單線程
  • 同步執行
  • 一個全局上下文
  • 無數的函數上下文
  • 每次函數調用都會床架一個新的執行上下文,即使是調用自身

三:執行上下文詳解

我們已經知道每當一個函數調用發生,都會創建一個新的執行上下文。但是在JS解釋器內部,每次調用一個執行上下文都分為兩個步驟

  1. 創建階段[在函數被調用,但還未執行任何代碼之前]
  1. 激活/代碼執行階段
  • 分配變量,以及到函數的引用,然后解析/執行代碼

一個執行上下文從概念上可以視為一個包含三個property的Object

executionContextObj = {
    'scopeChain': { /* variableObject + all parent execution context's variableObject */ },
    'variableObject': { /* function arguments / parameters, inner variable and function declarations */ },
    'this': {}
}

四: Activation / Variable Object [AO/VO]

當調用函數的時候,就會創建executionContextObj對象,此時真正的函數邏輯還未執行。這就是第一階段---創建階段。在這里,解釋器會掃描函數,根據獲取到的參數/傳參內部函數聲明/內部變量聲明,來創建executionContextObj對象。掃描的結果存放在executionContextObj對象的variableObject屬性中。

下面是解釋器解析代碼的流程概述:

  1. 找到被調用函數的代碼內容
  2. 在執行function代碼前,先創建執行上下文execution context
  3. 進入創建階段
    • 初始化 作用域鏈.
    • 創建 variable object:
      • 創建 arguments object;檢查上下文獲取入參,初始化形參名稱和數值,并創建一個引用拷貝
      • 掃描上下文獲取內部函數聲明:
        • 對發現的每一個內部函數,都在variable object中創建一個和函數名一樣的property,該property作為一個引用指針指向函數代碼在內存中的地址
        • 如果在variable object中已經存在相同名稱的property,那么相應的property會被重寫
      • 掃描上下文獲取內部變量聲明:
        • 對發現的每一個內部變量聲明,都在variable object中創建一個和變量名一樣的property,并且將其初始化為 undefined
        • 如果在variable object中已經存在相同變量名稱的property,那么就跳過,不做任何動作,繼續掃描
    • 決定在上下文中"this" 的值
  4. 激活/代碼執行階段:
    • 執行上下文中的函數代碼,逐行運行JS代碼,并給變量賦值

讓我們看個例子

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,  // 檢查上下文,發現內部聲明變量a,初始化為undefined
        b: undefined   // 檢查上下文,發現內部聲明變量b,初始化為undefined,此時并不賦值,右側的函數作為賦值語句,在代碼未執行前,并不存在
    },
    this: { ... }
}

參見代碼中的備注,在創建階段除了形參參數進行了定義和賦值外,其他只定義了property的名稱,并沒有賦值。一旦創建階段完成,執行流程就進入到函數內部進入激活/代碼執行階段。在執行完后的上下文大致如下:

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: 'hello',
        b: pointer to function privateB()
    },
    this: { ... }
}

五:關于提升(Hoisting)

網上有很多資源會提到JS特有的變量提升(Hoisting),其中會解釋說JS會將變量和函數聲明提升到函數作用域的頂部。但是,并沒有人詳細解釋為什么會出現這種情況。在掌握了關于解釋器如何創建上下文的知識后,這就非常容易解釋了??聪旅娴拇a:

?(function() {

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

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

    function foo() {
        return 'hello';
    }
    console.log(typeof foo); // string
}());?

我們現在可以回答的問題是:

  • 為什么我們可以在聲明foo之前就訪問它?

    • 如果我們遵循creation stage,我們知道變量在activation / code execution stage之前就創建了。所以當功能流程開始執行時,在上下文中,foo已經做了定義。
  • foo是聲明了兩次,為什么顯示foo的是 function ,不 undefined 還是 string?

    • 即使foo聲明了兩次,我們也知道在creation stage階段,在上下文中,函數是在變量之前創建的,并且如果上下文中一個變量名稱的屬性名已經存在,我們就會忽略掉這個變量聲明。
    • 因此,function foo()首先在上下文中創建一個名為foo的引用property,當解釋器到達時var foo時,我們看到屬性名稱foo已經存在,所以代碼什么都不做并繼續執行。
  • 為什么bar是 undefined?

    • bar實際上是一個具有函數賦值的變量,我們知道變量是在creation stage階段創建的,但它們是用值會被初始化為undefined。
  • 為什么最后foo是string

    • foo在創造階段按照規則被賦予了function的類型,但在執行階段,隨著var foo = 'hello'的執行,將其變為了String類型,下面的函數聲明在創造階段已經執行,因此跳過后,foo還是String類型

六:總結

希望到現在您已經很好地掌握了JavaScript中解釋器是如何處理您的代碼的。理解執行上下文和??梢宰屇私鉃槭裁创a運行的結果和你最初預期的不同的原因。

進一步閱讀:

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容