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

前言

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

編譯階段

對於常見編譯型語言(例如:Java )來說,編譯步驟分爲:詞法分析 -> 語法分析 -> 語義檢查 -> 代碼優化和字節碼生成

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

詞法分析

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

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中的執行上下文和執行棧

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章