JavaScript 中的執行上下文和執行棧

衝冠為紅顏發表於2021-02-21

JavaScript - 原理系列

​ 在日常開發中,每當我們接手一個現有專案後,我們總喜歡先去看看別人寫的程式碼。每當我們看到別人寫出很酷的程式碼的時候,我們總會感慨!寫出這麼優美而又簡潔的程式碼的兄弟到底是怎麼養成的呢?

​ 我要怎樣才能達到和大佬一樣的水平呢!好了,廢話不多說,讓我們切入今天的主題。

一、執行上下文

​ 簡而言之,【執行上下文】就是JavaScript 程式碼被解析和執行時所在環境的抽象概念, 在JavaScript 中執行任何的程式碼都是在它的執行上下文中執行。

​ 在執行JavaScript程式碼時,每當需要執行程式碼時,執行程式碼會先進入一個環境(瀏覽器、Node客戶端),這時就會為該環境建立一個執行上下文,它會在你執行程式碼前做一些準備工作,如確定作用域,建立全域性、區域性變數物件等。

執行上下文的分類

  • 全域性執行上下文:

    ​ 這是預設的、最基礎的執行上下文。不在任何函式中的程式碼都位於全域性執行上下文中。

    它做了兩件事:

    • 建立一個全域性物件,在瀏覽器中這個全域性物件就是 window 物件。

    • this 指標指向這個全域性物件。一個程式中只能存在一個全域性執行上下文。

  • 函式執行上下文:

    ​ 每次呼叫函式時,都會為該函式建立一個新的執行上下文。每個函式都擁有自己的執行上下文,但是隻有在函式被呼叫的時候才會被建立。一個程式中可以存在任意數量的函式執行上下文。每當一個新的執行上下文被建立,它都會按照特定的順序執行一系列步驟,具體過程將在本文後面討論。

  • Eval 函式執行上下文:

    ​ 執行在 eval 函式中的程式碼也獲得了自己的執行上下文,但由於 Javascript 開發人員不常用 eval 函式,所以在這裡不再討論。

執行上下文的數量限制(堆疊溢位)

​ 執行上下文可存在多個,雖然沒有明確的數量限制,但如果超出棧分配的空間,會造成堆疊溢位。常見於遞迴呼叫,沒有終止條件造成死迴圈的場景。

下面是示例程式碼:

// 遞迴呼叫自身
function foo() {
    foo();
}
foo();
// 報錯:Uncaught RangeError: Maximum call stack size exceeded

Tips:

​ JS是“單執行緒”的,每次只執行一段程式碼

二、執行棧

​ JS中的執行棧,也就是在其它程式語言中所說的“呼叫棧”,是一種擁有 LIFO(後進先出)資料結構的棧,被用來儲存程式碼執行時建立的所有執行上下文。

​ 當 JavaScript 引擎第一次遇到你的指令碼時,它會建立一個全域性的執行上下文並且壓入當前執行棧。每當引擎遇到一個函式呼叫,它會為該函式建立一個新的執行上下文並壓入棧的頂部。

​ 引擎會執行那些執行上下文位於棧頂的函式。當該函式執行結束時,執行上下文從棧中彈出,控制流程到達當前棧中的下一個上下文。

棧資料結構

現在讓我們用一段程式碼來理解執行棧

let a = 'Hello World!';

function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}

function second() {
  console.log('Inside second function');
}

first();
console.log('Inside Global Execution Context');

下圖是上面程式碼的執行棧

​ 當上述程式碼在瀏覽器載入時,瀏覽器的JavaScript 引擎會建立一個全域性執行上下文並把它壓入當前執行棧。當遇到函式呼叫時,JavaScript 引擎為該函式建立一個新的執行上下文並把它壓入當前執行棧的頂部。

當從 first()函式內部呼叫 second()函式時,JavaScript 引擎為 second() 函式建立了一個新的執行上下文並把它壓入當前執行棧的頂部。當 second()函式執行完畢,它的執行上下文會從當前棧彈出,並且控制流程到達下一個執行上下文,即 first() 函式的執行上下文。

​ 當 first() 執行完畢,它的執行上下文從棧彈出,控制流程到達全域性執行上下文。一旦所有程式碼執行完畢,JavaScript 引擎從當前棧中移除全域性執行上下文。

The Creation Phase

​ 在 JavaScript 程式碼執行前,執行上下文將經歷建立階段。在建立階段會發生三件事:

  1. this 值的決定,即我們所熟知的 This 繫結
  2. 建立詞法環境元件。
  3. 建立變數環境元件。

所以執行上下文在概念上表示如下:

ExecutionContext = {
  ThisBinding = <this value>,
  LexicalEnvironment = { ... },
  VariableEnvironment = { ... },
}

This 繫結:

​ 在全域性執行上下文中,this 的值指向全域性物件。(在瀏覽器中,this引用 Window 物件)。

​ 在函式執行上下文中,this 的值取決於該函式是如何被呼叫的。如果它被一個引用物件呼叫,那麼 this 會被設定成那個物件,否則 this 的值被設定為全域性物件或者undefined(在嚴格模式下)。例如:

let foo = {
  baz: function() {
  console.log(this);
  }
}
foo.baz();   // 'this' 引用 'foo', 因為 'baz' 被
             // 物件 'foo' 呼叫
let bar = foo.baz;
bar();       // 'this' 指向全域性 window 物件,因為
             // 沒有指定引用物件

詞法環境

官方的 ES6 文件把詞法環境定義為

詞法環境是一種規範型別,基於 ECMAScript 程式碼的詞法巢狀結構來定義識別符號和具體變數和函式的關聯。一個詞法環境由環境記錄器和一個可能的引用外部詞法環境的空值組成。

​ 簡單來說詞法環境是一種持有識別符號—變數對映的結構。(這裡的識別符號指的是變數/函式的名字,而變數是對實際物件[包含函式型別物件]或原始資料的引用)。

​ 現在,在詞法環境的內部有兩個元件:(1) 環境記錄器和 (2) 一個外部環境的引用

  1. 環境記錄器是儲存變數和函式宣告的實際位置。
  2. 外部環境的引用意味著它可以訪問其父級詞法環境(作用域)。

詞法環境有兩種型別:

  • 全域性環境(在全域性執行上下文中)是沒有外部環境引用的詞法環境。全域性環境的外部環境引用是 null。它擁有內建的 Object/Array/等、在環境記錄器內的原型函式(關聯全域性物件,比如 window 物件)還有任何使用者定義的全域性變數,並且 this的值指向全域性物件。
  • 函式環境中,函式內部使用者定義的變數儲存在環境記錄器中。並且引用的外部環境可能是全域性環境,或者任何包含此內部函式的外部函式。

環境記錄器也有兩種型別(如上!):

  1. 宣告式環境記錄器儲存變數、函式和引數。

  2. 物件環境記錄器用來定義出現在全域性上下文中的變數和函式的關係。

簡而言之,

  • 全域性環境中,環境記錄器是物件環境記錄器。

  • 函式環境中,環境記錄器是宣告式環境記錄器。

注意

​ 對於函式環境宣告式環境記錄器還包含了一個傳遞給函式的 arguments 物件(此物件儲存索引和引數的對映)和傳遞給函式的引數的 length

抽象地講,詞法環境在虛擬碼中看起來像這樣:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 在這裡繫結識別符號
    }
    outer: <null>
  }
}

FunctionExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 在這裡繫結識別符號
    }
    outer: <Global or outer function environment reference>
  }
}

變數環境:

​ 它同樣是一個詞法環境,其環境記錄器持有變數宣告語句在執行上下文中建立的繫結關係。

如上所述,變數環境也是一個詞法環境,所以它有著上面定義的詞法環境的所有屬性。

​ 在 ES6 中,詞法環境元件和變數環境的一個不同就是前者被用來儲存函式宣告和變數(letconst)繫結,而後者只用來儲存 var 變數繫結。

我們看點樣例程式碼來理解上面的概念:

let a = 20;const b = 30;var c;
function multiply(e, f) { var g = 20; return e * f * g;}
c = multiply(20, 30);

執行上下文看起來像這樣:

GlobalExectionContext = {

  ThisBinding: <Global Object>,

  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 在這裡繫結識別符號
      a: < uninitialized >,
      b: < uninitialized >,
      multiply: < func >
    }
    outer: <null>
  },

  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 在這裡繫結識別符號
      c: undefined,
    }
    outer: <null>
  }
}

FunctionExectionContext = {
  ThisBinding: <Global Object>,

  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 在這裡繫結識別符號
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>
  },

VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 在這裡繫結識別符號
      g: undefined
    },
    outer: <GlobalLexicalEnvironment>
  }
}

注意

​ 只有遇到呼叫函式 multiply 時,函式執行上下文才會被建立。

可能你已經注意到 letconst 定義的變數並沒有關聯任何值,但 var 定義的變數被設成了 undefined

​ 這是因為在建立階段時,引擎檢查程式碼找出變數和函式宣告,雖然函式宣告完全儲存在環境中,但是變數最初設定為 undefinedvar 情況下),或者未初始化(letconst 情況下)。

​ 這就是為什麼你可以在宣告之前訪問 var 定義的變數(雖然是 undefined),但是在宣告之前訪問 letconst 的變數會得到一個引用錯誤。

這就是我們說的變數宣告提升。

執行階段

​ 這是整篇文章中最簡單的部分。在此階段,完成對所有這些變數的分配,最後執行程式碼。

注意

​ 在執行階段,如果 JavaScript 引擎不能在原始碼中宣告的實際位置找到 let 變數的值,它會被賦值為 undefined

結論

​ 我們已經討論過 JavaScript 程式內部是如何執行的。雖然要成為一名卓越的 JavaScript 開發者並不需要學會全部這些概念,但是如果對上面概念能有不錯的理解將有助於你更輕鬆,更深入地理解其他概念,如變數宣告提升,作用域和閉包。

參考文章:

相關文章