深入瞭解JavaScript執行過程(JS系列之一)

JefferyXZF發表於2020-03-06

前言

JavaScript  執行過程分為兩個階段,編譯階段和執行階段。在編譯階段 JS 引擎主要做了三件事:詞法分析、語法分析和程式碼生成;編譯完成後 JS 引擎開始建立執行上下文(JavaScript 程式碼執行的環境),並執行 JS 程式碼。

編譯階段

對於常見編譯型語言(例如:Java )來說,編譯步驟分為:詞法分析 -> 語法分析 -> 語義檢查 -> 程式碼優化和位元組碼生成

對於解釋型語言(例如:JavaScript )來說,編譯階通過詞法分析 -> 語法分析 -> 程式碼生成,就可以解釋並執行程式碼了。

詞法分析

JS 引擎會將我們寫的程式碼當成字串分解成詞法單元(token)。例如,var a = 2 ,這段程式會被分解成:“var、a、=、2、;” 五個 token 。每個詞法單元token不可再分割。可以試試這個網站地址檢視 tokenesprima.org/demo/parse.…

1詞法分析1.png
1詞法分析2.png

語法分析

語法分析階段會將詞法單元流(陣列),也就是上面所說的token, 轉換成樹狀結構的 “抽象語法樹(AST)”

2語法分析.png

程式碼生成

AST轉換為可執行程式碼的過程稱為程式碼生成,因為計算機只能識別機器指令,需要通過某種方法將 var a = 2; 的 AST 轉化為一組機器指令,用來建立 a 的變數(包括分配記憶體),並將值儲存在 a 中。

執行階段

執行程式需要有執行環境, Java 需要 Java 虛擬機器,同樣解析 JavaScript 也需要執行環境,我們稱它為“執行上下文”。

什麼是執行上下文

簡而言之,執行上下文是對 JavaScript 程式碼執行環境的一種抽象,每當 JavaScript 執行時,它都是在執行上下文中執行。

執行上下文型別

JavaScript 執行上下文有三種:

  • 全域性執行上下文 —— 當 JS 引擎執行全域性程式碼的時候,會編譯全域性程式碼並建立執行上下文,它會做兩件事:1、建立一個全域性的 window 物件(瀏覽器環境下),2、將 this 的值設定為該全域性物件;全域性上下文在整個頁面生命週期有效,並且只有一份。

  • 函式執行上下文 —— 當呼叫一個函式的時候,函式體內的程式碼會被編譯,並建立函式執行上下文,一般情況下,函式執行結束之後,建立的函式執行上下文會被銷燬。

  • eval 執行上下文 —— 呼叫 eval 函式也會建立自己的執行上下文(eval函式容易導致惡意攻擊,並且執行程式碼的速度比相應的替代方法慢,因此不推薦使用)

執行棧

執行棧這個概念是比較貼近我們程式設計師的,學習它能讓我們理解 JS 引擎背後工作的原理,開發中幫助我們除錯程式碼,同時也能應對面試中有關執行棧的面試題。

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

JS 引擎開始執行第一行 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');
複製程式碼

stack.png

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

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

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

如何建立執行上下文

現在我們已經瞭解了 JS 引擎是如何去管理執行上下文的,那麼,執行上下文是如何建立的呢?

執行上下文的建立分為兩個階段:

  • 建立階段;
  • 執行階段;

建立階段

執行上下文建立階段會做三件事:

  • 繫結 this
  • 建立詞法環境
  • 建立變數環境

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

ExecutionContext = { // 執行上下文
  Binding This, // this值繫結
  LexicalEnvironment = { ... }, // 詞法環境
  VariableEnvironment = { ... }, // 變數環境
}
複製程式碼
繫結 this

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

在函式執行上下文中,this 的值取決於該函式是如何被呼叫的

  • 通過物件方法呼叫函式,this 指向呼叫的物件
  • 宣告函式後使用函式名稱普通呼叫,this 指向全域性物件,嚴格模式下 this 值是 undefined
  • 使用 new 方式呼叫函式,this 指向新建立的物件
  • 使用 callapplybind 方式呼叫函式,會改變 this 的值,指向傳入的第一個引數,例如

function fn () {
  console.log(this)
}

function fn1 () {
  'use strict'
  console.log(this)
}

fn() // 普通函式呼叫,this 指向window物件
fn() // 嚴格模式下,this 值為 undefined

let foo = {
  baz: function() {
  console.log(this);
  }
}

foo.baz();   // 'this' 指向 'foo'

let bar = foo.baz;

bar();       // 'this' 指向全域性 window 物件,因為沒有指定引用物件

let obj {
  name: 'hello'
}

foo.baz.call(obj) // call 改變this值,指向obj物件
複製程式碼
詞法環境

每一個詞法環境由下面兩部分組成:

  • 環境記錄:變數物件 =》儲存宣告的變數和函式( let, const, function,函式引數)
  • 外部環境引用:作用域鏈

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

詞法環境(Lexical Environments)是一種規範型別,用於根據ECMAScript程式碼的詞法巢狀結構來定義識別符號與特定變數和函式的關聯。詞法環境由一個環境記錄(Environment Record)和一個可能為空的外部詞法環境(outer Lexical Environment)引用組成。

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

舉個例子,看看下面的程式碼:

var a = 20;
var b = 40;
function foo() {
  console.log('bar');
}
複製程式碼

上面程式碼的詞法環境類似這樣:

lexicalEnvironment = {
  a: 20,
  b: 40,
  foo: <ref. to foo function>
}
複製程式碼

環境記錄

所謂的環境記錄就是詞法環境中記錄變數和函式宣告的地方

環境記錄也有兩種型別:

宣告類環境記錄。顧名思義,它儲存的是變數和函式宣告,函式的詞法環境內部就包含著一個宣告類環境記錄。

物件環境記錄。全域性環境中的詞法環境中就包含的就是一個物件環境記錄。除了變數和函式宣告外,物件環境記錄還包括全域性物件(瀏覽器的window物件)。因此,對於物件的每一個新增屬性(對瀏覽器來說,它包含瀏覽器提供給window物件的所有屬性和方法),都會在該記錄中建立一個新條目。

注意:對函式而言,環境記錄還包含一個arguments物件,該物件是個類陣列物件,包含引數索引和引數的對映以及一個傳入函式的引數的長度屬性。舉個例子,一個arguments物件像下面這樣:

function foo(a, b) {
  var c = a + b;
}
foo(2, 3);
// argument 物件類似下面這樣
Arguments: { 0: 2, 1: 3, length: 2 }
複製程式碼

環境記錄物件在建立階段也被稱為變數物件(VO),在執行階段被稱為活動物件(AO)。之所以被稱為變數物件是因為此時該物件只是儲存執行上下文中變數和函式宣告,之後程式碼開始執行,變數會逐漸被初始化或是修改,然後這個物件就被稱為活動物件

外部環境引用

對於外部環境的引用意味著在當前執行上下文中可以訪問外部詞法環境。也就是說,如果在當前的詞法環境中找不到某個變數,那麼Javascript引擎會試圖在上層的詞法環境中尋找。(Javascript引擎會根據這個屬性來構成我們常說的作用域鏈)

詞法環境抽象出來類似下面的虛擬碼:

GlobalExectionContext = { // 全域性執行上下文
  this: <global object> // this 值繫結
  LexicalEnvironment: { // 全域性執行上下文詞法環境
    EnvironmentRecord: {  // 環境記錄
      Type: "Object",
     	// 識別符號在這裡繫結
    }
    outer: <null> // 外部引用
  }
}
FunctionExectionContext = { // 函式執行上下文
  this: <depends on how function is called> // this 值繫結
  LexicalEnvironment: { // 函式執行上下文詞法環境
    EnvironmentRecord: { // 環境記錄
      Type: "Declarative",
      // 識別符號在這裡繫結
    }
    outer: <Global or outer function environment reference> // 引用全域性環境
   }
}
複製程式碼
變數環境

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

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

在 ES6 中,詞法環境變數環境的一個不同就是前者被用來儲存函式宣告和變數(let 和 const)繫結,而後者只用來儲存 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 程式碼了。在執行階段,如果 JavaScript 引擎不能在原始碼中宣告的實際位置找到 let 變數的值,它會被賦值為 undefined 。

執行棧應用

利用瀏覽器檢視棧的呼叫資訊

我們知道執行棧是用來管理執行上下文呼叫關係的資料結構,那麼我們在實際工作中如何運用它呢。

答案是我們可以藉助瀏覽器“開發者工具” source 標籤,選擇 JavaScript 程式碼打上斷點,就可以檢視函式的呼叫關係,並且可以切換檢視每個函式的變數值

呼叫棧.png

我們在 second 函式內部打上斷點,就可以看到右邊 Call Stack 呼叫棧顯示 secondfirst(anonymous) 呼叫關係,second 是在棧頂(anonymous 在棧底相當於全域性執行上下文),執行second函式我們可以檢視該函式作用域 Scope 區域性變數abnum的值,通過檢視呼叫棧的呼叫關係我們可以快速定位到我們程式碼執行的情況。

那如果程式碼執行出錯,也不知道在哪個地方打斷點除錯,那怎麼檢視出錯地方的呼叫棧呢,告訴大家一個技巧,如下圖

呼叫棧2.png

我們不用打斷點,執行上面兩步操作,就可以在程式碼執行異常的地方自動打上斷點。知道這個技巧後,再也不用擔心程式碼出錯了。

除了上面通過斷點來檢視呼叫棧,還可以使用 console.trace() 來輸出當前的函式呼叫關係,比如在示例程式碼中的 second 函式裡面加上了 console.trace(),就可以看到控制檯輸出的結果,如下圖:

呼叫棧3.png

總結

JavaScript執行分為兩個階段,編譯階段和執行階段。編譯階段會經過詞法分析、語法分析、程式碼生成步驟生成可執行程式碼; JS 引擎執行可執行性程式碼會建立執行上下文,包括繫結this、建立詞法環境和變數環境;詞法環境建立外部引用(作用域鏈)和 記錄環境(變數物件,let, const, function, arguments), JS 引擎建立執行上下完成後開始單執行緒從上到下一行一行執行 JS 程式碼了。

最後,分享了在開發過程中一些呼叫棧的的應用技巧。

引用連結

JavaScript 語法解析、AST、V8、JIT

[譯] 理解 JavaScript 中的執行上下文和執行棧

理解Javascript中的執行上下文和執行棧

推薦閱讀

相關文章