1. 程式人生 > 其它 >手把手教會你JavaScript引擎如何執行JavaScript程式碼

手把手教會你JavaScript引擎如何執行JavaScript程式碼

JavaScript 在執行過程中與其他語言有所不一樣,如果不理解 JavaScript 的詞法環境、執行上下文等內容,很容易會在開發過程中產生 Bug,比如this指向和預期不一致、某個變數不知道為什麼被改了,等等。所以今天我們就來聊一聊 JavaScript 程式碼的執行過程。

大家都知道,JavaScript 程式碼是需要在 JavaScript 引擎中執行的。我們在說到 JavaScript 執行的時候,常常會提到執行環境、詞法環境、作用域、執行上下文、閉包等內容。這些概念看起來都差不多,卻好像又不大容易區分清楚,它們分別都在描述什麼呢?

這些詞語都是與 JavaScript 引擎執行程式碼的過程有關,為了搞清楚這些概念之間的區別,我們可以回顧下 JavaScript 程式碼執行過程中的各個階段。

JavaScript 程式碼執行的各個階段

JavaScript 是弱型別語言,在執行時才能確定變數型別。JavaScript 引擎在執行 JavaScript 程式碼時,也會從上到下進行詞法分析、語法分析、語義分析等處理,並在程式碼解析完成後生成 AST(抽象語法樹),最終根據 AST 生成 CPU 可以執行的機器碼並執行。

這個過程,我們稱之為語法分析階段。除了語法分析階段,JavaScript 引擎在執行程式碼時還會進行其他的處理。以 V8 引擎為例,在 V8 引擎中 JavaScript 程式碼的執行過程主要分成三個階段。

  1. 語法分析階段。該階段會對程式碼進行語法分析,檢查是否有語法錯誤(SyntaxError),如果發現語法錯誤,會在控制檯丟擲異常並終止執行。

  2. 編譯階段。該階段會進行執行上下文(Execution Context)的建立,包括建立變數物件、建立作用域鏈、確定 this 的指向等。每進入一個不同的執行環境時,V8 引擎都會建立一個新的執行上下文。

  3. 執行階段。將編譯階段中建立的執行上下文壓入呼叫棧,併成為正在執行的執行上下文,程式碼執行結束後,將其彈出呼叫棧。

其中,語法分析階段屬於編譯器通用內容,就不再贅述。前面提到的執行環境、詞法環境、作用域、執行上下文等內容都是在編譯和執行階段中產生的概念。

執行上下文的建立

執行上下文的建立離不開 JavaScript 的執行環境,JavaScript 執行環境包括全域性環境、函式環境和eval,其中全域性環境和函式環境的建立過程如下:

  1. 第一次載入 JavaScript 程式碼時,首先會建立一個全域性環境。全域性環境位於最外層,直到應用程式退出後(例如關閉瀏覽器和網頁)才會被銷燬。
  2. 每個函式都有自己的執行環境,當函式被呼叫時,則會進入該函式的執行環境。當該環境中的程式碼被全部執行完畢後,該環境會被銷燬。不同的函式執行環境不一樣,即使是同一個函式,在被多次呼叫時也會建立多個不同的函式環境。

在不同的執行環境中,變數和函式可訪問的其他資料範圍不同,環境的行為(比如建立和銷燬)也有所區別。而每進入一個不同的執行環境時,JavaScript 都會建立一個新的執行上下文,該過程包括:

  • 建立作用域鏈(Scope Chain);
  • 建立變數物件(Variable Object,簡稱 VO);
  • 確定 this 的指向。

由於建立作用域鏈過程中會涉及變數物件的概念,因此我們先來看看變數物件的建立,再看建立作用域鏈和確定 this 的指向。

建立變數物件

變數物件(VO)

每個執行上下文都會有一個關聯的變數物件,該物件上會儲存這個上下文中定義的所有變數和函式。

在瀏覽器中,全域性環境的變數物件是window物件,因此所有的全域性變數和函式都是作為window物件的屬性和方法建立的。相應的,在 Node 中全域性環境的變數物件則是global物件。

建立VO的過程

建立變數物件將會建立arguments物件(僅函式環境下),同時會檢查當前上下文的函式宣告和變數宣告。

  • 對於變數宣告:此時會給變數分配記憶體,並將其初始化為undefined(該過程只進行定義宣告,執行階段才執行賦值語句)。
  • 對於函式宣告:此時會在記憶體裡建立函式物件,並且直接初始化為該函式物件。

變數宣告和函式宣告的處理過程,便是我們常說的變數提升和函式提升,其中函式宣告提升會優先於變數宣告提升。因為變數提升容易帶來變數在預期外被覆蓋掉的問題,同時還可能導致本應該被銷燬的變數沒有被銷燬等情況。因此 ES6 中引入了let和const關鍵字,從而使 JavaScript 也擁有了塊級作用域。

作用域

在各類程式語言中,作用域分為靜態作用域和動態作用域。JavaScript 採用的是詞法作用域(Lexical Scoping),也就是靜態作用域。詞法作用域中的變數,在編譯過程中會產生一個確定的作用域。

詞法作用域中的變數,在編譯過程中會產生一個確定的作用域,這個作用域即當前的執行上下文,在 ES5 後我們使用詞法環境(Lexical Environment)替代作用域來描述該執行上下文。因此,詞法環境可理解為我們常說的作用域,同樣也指當前的執行上下文(注意,是當前的執行上下文)。

在 JavaScript 中,詞法環境又分為詞法環境(Lexical Environment)和變數環境(Variable Environment)兩種,其中:

  • 變數環境用來記錄var/function等變數宣告;
  • 詞法環境是用來記錄let/const/class等變數宣告。

也就是說,建立變數過程中會進行函式提升和變數提升,JavaScript 會通過詞法環境來記錄函式和變數宣告。通過使用兩個詞法環境(而不是一個)分別記錄不同的變數宣告內容,JavaScript 實現了支援塊級作用域的同時,不影響原有的變數宣告和函式宣告。

這就是建立變數的過程,它屬於執行上下文建立中的一環。建立變數的過程會產生作用域,作用域也被稱為詞法環境。

建立作用域鏈

作用域鏈,就是將各個作用域通過某種方式連線在一起。作用域就是詞法環境,而詞法環境由兩個成員組成。

  1. 環境記錄(Environment Record):用於記錄自身詞法環境中的變數物件。
  2. 外部詞法環境引用(Outer Lexical Environment):記錄外層詞法環境的引用。

通過外部詞法環境的引用,作用域可以層層拓展,建立起從裡到外延伸的一條作用域鏈。當某個變數無法在自身詞法環境記錄中找到時,可以根據外部詞法環境引用向外層進行尋找,直到最外層的詞法環境中外部詞法環境引用為null,這便是作用域鏈的變數查詢。

JavaScript 程式碼執行過程分為定義期和執行期,前面提到的編譯階段則屬於定義期,程式碼示例如下:

function foo() { // 定義全域性函式foo
    console.dir(bar);
    var a = 1;
    function bar() { // 在foo函式內部定義函式bar
        a = 2;
 }
}
console.dir(foo);
foo();

前面我們說到,JavaScript 使用的是靜態作用域,因此函式的作用域在定義期已經決定了。在上面的例子中,全域性函式foo建立了一個foo[[scope]]屬性,包含了全域性[[scope]]

foo[[scope]] = [globalContext];

而當我們執行foo()時,也會分別進入foo函式的定義期和執行期。

在foo函式的定義期時,函式bar的[[scope]]將會包含全域性[[scope]]和foo的[[scope]]:

bar[[scope]] = [fooContext, globalContext];

執行上述程式碼,我們可以在控制檯看到符合預期的輸出:

可以看到:

  • foo的[[scope]]屬性包含了全域性[[scope]]
  • bar的[[scope]]將會包含全域性[[scope]]和foo的[[scope]]

也就是說,JavaScript 會通過外部詞法環境引用來建立變數物件的一個作用域鏈,從而保證對執行環境有權訪問的變數和函式的有序訪問。除了建立作用域鏈之外,在這個過程中還會對建立的變數物件做一些處理。

在編譯階段會進行變數物件(VO)的建立,該過程會進行函式宣告和變數宣告,這時候變數的值被初始化為 undefined。在程式碼進入執行階段之後,JavaScript 會對變數進行賦值,此時變數物件會轉為活動物件(Active Object,簡稱 AO),轉換後的活動物件才可被訪問,這就是 VO -> AO 的過程,示例如下:

function foo(a) {
    var b = 2; 
    function c() {}
    var d = function() {};
}

foo(1);

在執行foo(1)時,首先進入定義期,此時:

  • 引數變數a的值為1
  • 變數b和d初始化為undefined
  • 函式c建立函式並初始化
AO = {
 arguments: {
  0: 1,
  length: 1
 },
 a: 1,
 b: undefined,
 c: reference to function() c() {}
 d:undefined
}

前面我們也有提到,進入執行期之後,會執行賦值語句進行賦值,此時變數b和d會被賦值為 2 和函式表示式:

AO = {
   arguments: {
    0: 1,
    length: 1
  },
  a: 1,
  b: 2,
  c: reference to function c(){},
  d: reference to FunctionExpression "d"
}

這就是 VO -> AO 過程。

  • 在定義期(編譯階段):該物件值仍為undefined,且處於不可訪問的狀態。
  • 進入執行期(執行階段):VO 被啟用,其中變數屬性會進行賦值。

實際上在執行的時候,除了 VO 被啟用,活動物件還會新增函式執行時傳入的引數和arguments這個特殊物件,因此 AO 和 VO 的關係可以用以下關係來表達:

AO = VO + function parameters + arguments

現在,我們知道作用域鏈是在進入程式碼的執行階段時,通過外部詞法環境引用來建立的。總結如下:

  • 在編譯階段,JavaScript 在建立執行上下文的時候會先建立變數物件(VO);
  • 在執行階段,變數物件(VO)被啟用為活動物件( AO),函式內部的變數物件通過外部詞法環境的引用建立作用域鏈。

通過作用域鏈,我們可以在函式內部可以直接讀取外部以及全域性變數,但外部環境是無法訪問內部函式裡的變數。示例如下:

function foo() {
  var a = 1;
}
foo();
console.log(a); // undefined

我們在全域性環境下無法訪問函式foo中的變數a,這是因為全域性函式的作用域鏈裡,不含有函式foo內的作用域。

如果我們想要訪問內部函式的變數,可以通過函式foo中的函式bar返回變數a,並將函式bar返回,這樣我們在全域性環境中也可以通過呼叫函式foo返回的函式bar,來訪問變數a:

function foo() {
  var a = 1;
  function bar() {
    return a;
  }
  return bar;
}
var b = foo();
console.log(b()); // 1

當函式執行結束之後,執行期上下文將被銷燬,其中包括作用域鏈和啟用物件。

在上面的例項中;當b()執行時,foo函式上下文包括作用域都已經被銷燬了,但是foo作用域下的a依然可以被訪問到;這是因為bar函式引用了foo函式變數物件中的值,此時即使建立bar函式的foo函式執行上下文被銷燬了,但它的變數物件依然會保留在 JavaScript 記憶體中,bar函式依然可以通過bar函式的作用域鏈找到它,並進行訪問。這就是閉包;

閉包使得我們可以從外部讀取區域性變數,常見的用途包括:

  1. 用於從外部讀取其他函式內部變數的函式;
  2. 可以使用閉包來模擬私有方法;
  3. 讓這些變數的值始終保持在記憶體中。

注意,在使用閉包的時候,需要及時清理不再使用到的變數,否則可能導致記憶體洩漏問題。

確定 this 的指向

在 JavaScript 中,this指向執行當前程式碼物件的所有者,可簡單理解為this指向最後呼叫當前程式碼的那個物件。

根據 JavaScript 中函式的呼叫方式不同,this的指向分為以下情況。

  1. 在全域性環境中,this指向全域性物件(在瀏覽器中為window)
  2. 在函式內部,this的值取決於函式被呼叫的方式
  3. 函式作為物件的方法被呼叫,this指向呼叫這個方法的物件
  4. 函式用作建構函式時(使用new關鍵字),它的this被繫結到正在構造的新物件
  5. 在類的建構函式中,this是一個常規物件,類中所有非靜態的方法都會被新增到this的原型中
  6. 在箭頭函式中,this指向它被建立時的環境
  7. 使用apply、call、bind等方式呼叫:根據 API 不同,可切換函式執行的上下文環境,即this繫結的物件

可以看到,this在不同的情況下會有不同的指向,在 ES6 箭頭函式還沒出現之前,為了能正確獲取某個執行環境下this物件,我們常常會使用以下程式碼:

var that = this;
var self = this;

這樣的程式碼將變數分配給this,便於使用。但是降低了程式碼可讀性,不推薦使用,通過正確使用箭頭函式,我們可以更好地管理作用域。

總結

今天我們瞭解了 JavaScript 程式碼的執行過程,該過程分為語法分析階段、編譯階段、執行階段三個階段。

在編譯階段,JavaScript會進行執行上下文的建立,在執行階段,變數物件(VO)會被啟用為活動物件(AO),變數會進行賦值,此時活動物件才可被訪問。在執行結束之後,作用域鏈和活動物件均被銷燬,使用閉包可使活動物件依然被保留在記憶體中。這就是 JavaScript 程式碼的執行過程。