聊一聊JavaScript中的執行上下文和堆疊

京程一燈發表於2019-01-25

What is the Execution Context & Stack in JavaScript?

連結:davidshariff.com/blog/what-i…

在這篇文章中,我將深入探討JavaScript的最基本部分之一,即Execution Context(執行上下文)。 在本文結束時,你應該對直譯器瞭解得更清楚:為什麼在宣告它們之前可以使用某些函式或變數?以及它們的值是如何確定的?

什麼是執行上下文?

JavaScript的執行環境非常重要,當JavaScript程式碼在行時,會被預處理為以下情況之一:

  • Global code - 首次執行程式碼的預設環境。
  • Function code - 每當執行流程進入函式體時。
  • Eval code - 要在eval函式內執行的文字。

你可以閱讀大量涉及作用域的線上資料,不過為了使事情更容易理解,讓我們將術語“執行上下文”視為當前程式碼的執行環境或作用域。接下來讓我們看一個包含global和function / local上下文的程式碼示例。

聊一聊JavaScript中的執行上下文和堆疊

這裡沒有什麼特別之處,我們有一個由紫色邊框表示的全域性上下文,和由綠色,藍色和橙色邊框表示的3個不同的函式上下文。 只能有1個全域性上下文,可以從程式中的任何其他上下文訪問。

你可以擁有任意數量的函式上下文,並且每個函式呼叫都會建立一個新的上下文,從而建立一個私有作用域,其中無法從當前函式作用域外直接訪問函式內部宣告的任何內容。 在上面的示例中,函式可以訪問在其當前上下文之外宣告的變數,但外部上下文無法訪問在其中宣告的變數或函式。 為什麼會這樣呢? 這段程式碼究竟是如何處理的?

Execution Context Stack(執行上下文堆疊)

瀏覽器中的JavaScript直譯器被實現為單個執行緒。 實際上這意味著在瀏覽器中一次只能做一件事,其他動作或事件在所謂的執行堆疊中排隊。 下圖是單執行緒堆疊的抽象檢視:

聊一聊JavaScript中的執行上下文和堆疊

我們已經知道,當瀏覽器首次載入指令碼時,它預設進入全域性上下文執行。 如果在全域性程式碼中呼叫函式,程式的順序流進入被呼叫的函式,建立新的執行上下文並將其推送到執行堆疊的頂部。

如果在當前函式中呼叫另一個函式,則會發生同樣的事情。 程式碼的執行流程進入內部函式,該函式建立一個新的執行上下文,該上下文被推送到現有堆疊的頂部。 瀏覽器將始終執行位於堆疊頂部的當前執行上下文,並且一旦函式執行完當前執行上下文後,它將從棧頂部彈出,把控制權返回到當前棧中的下一個上下文。 下面的示例顯示了遞迴函式和程式的執行堆疊

(function foo(i) {
    if (i === 3) {
        return;
    }
    else {
        foo(++i);
    }
}(0));
複製程式碼

聊一聊JavaScript中的執行上下文和堆疊

程式碼簡單地呼叫自身3次,並將i的值遞增1。每次呼叫函式foo時,都會建立一個新的執行上下文。 一旦上下文完成執行,它就會彈出堆疊並且講控制返回到它下面的上下文,直到再次達到全域性上下文

關於執行堆疊execution stack有5個關鍵要點:

  • 單執行緒。
  • 同步執行。
  • 一個全域性上下文。
  • 任意多個函式上下文。
  • 每個函式呼叫都會建立一個新的執行上下文execution context,甚至是對自身的呼叫。

執行上下文的細節

所以我們現在知道每次呼叫一個函式時,都會建立一個新的執行上下文。 但是,在JavaScript直譯器中,對執行上下文的每次呼叫都有兩個階段:

  1. 建立階段 [呼叫函式時,但在執行任何程式碼之前]:

    • 建立作用域鏈
    • 建立變數,函式和引數。
    • 確定“this”的值。
  2. 啟用/程式碼執行階段:

    • 分配值,引用函式和解釋/執行程式碼。

可以將每個執行上下文在概念上表示為具有3個屬性的物件:

executionContextObj = {
    'scopeChain': { /* variableObject + 所有父執行上下文的variableObject */ },
    'variableObject': { /* 函式實參/形參,內部變數和函式宣告 */ },
    'this': {}
}
複製程式碼

啟用物件/變數物件 [AO/VO]

在呼叫該函式,並且在實際執行函式之前,會建立這個executionContextObj。 這被稱為第1階段,即創造階段。 這時直譯器通過掃描函式傳遞的實參或形參、本地函式宣告和區域性變數宣告來建立executionContextObj。 此掃描的結果將成為executionContextObj中的variableObject

以下是直譯器如何預處理程式碼的虛擬碼概述:

  1. 找一些程式碼來呼叫一個函式。
  2. 在執行功能程式碼之前,建立執行上下文
  3. 進入建立階段:
    • 初始化作用域鏈。
    • 建立variable object
      • 建立arguments object,檢查引數的上下文,初始化名稱和值並建立引用副本。
      • 掃描上下文以獲取函式宣告:
        • 對於找到的每個函式,在variable object中建立一個屬性,該屬性是函式的確切名稱,該屬性存在指向記憶體中函式的引用指標。
        • 如果函式名已存在,則將覆蓋引用指標值。
      • 掃描上下文以獲取變數宣告:
        • 對於找到的每個變數宣告,在variable object中建立一個屬性作為變數名稱,並將該值初始化為undefined
        • 如果變數名稱已存在於variable object中,則不執行任何操作並繼續掃描。
    • 確定上下文中“this”的值。
  4. 啟用/執行階段:
    • 在上下文中執行/解釋函式程式碼,並在程式碼逐行執行時分配變數值。

我們來看一個例子:

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的線上資源,解釋變數和函式宣告被hoisting到其函式範圍的頂部。 但是沒有人能夠詳細解釋為什麼會發生這種情況,掌握了關於直譯器如何建立啟用物件的新知識,很容易理解為什麼。 請看下面的程式碼示例:

(function() {

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

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

    function foo() {
        return 'hello';
    }

}());
複製程式碼

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

  • 為什麼我們可以在宣告foo之前就能訪問?
    • 如果我們理解了建立階段,就知道在啟用/程式碼執行階段之前已經建立了變數。因此,當函式流開始執行時,已經在啟用物件中定義了foo。
  • Foo被宣告兩次,為什麼foo顯示為function而不是undefinedstring
    • 即使foo被宣告兩次,我們通過建立階段知道函式在變數之前就被建立在啟用物件上了,而且如果啟用物件上已經存在了屬性名稱,我們只是繞過了宣告這一步驟。
    • 因此,首先在啟用物件上建立對函式foo()的引用,並且當直譯器到達var foo時,我們已經看到屬性名稱foo存在,因此程式碼不執行任何操作並繼續處理。
  • 為什麼bar未定義?
    • bar實際上是一個具有函式賦值的變數,我們知道變數是在建立階段被建立的,但它們是使用undefined值初始化的。

總結

希望到這裡你已經能夠很好地掌握了JavaScript直譯器如何預處理你的程式碼。 理解執行上下文和堆疊可以讓你瞭解背後的原因:為什麼程式碼預處理後的值和你預期的不一樣。

你認為學習直譯器的內部工作原理是多此一舉還是非常必要的呢? 瞭解執行上下文階段是否能夠幫你你寫出更好的JavaScript呢?

歡迎關注京程一燈公眾號:京程一燈,獲取更多前端乾貨內容。

相關文章