JS引擎執行緒的執行過程的三個階段

奮鬥的小舟發表於2019-03-02


瀏覽器首先按順序載入由<script>標籤分割的js程式碼塊,載入js程式碼塊完畢後,立刻進入以下三個階段,然後再按順序查詢下一個程式碼塊,再繼續執行以下三個階段,無論是外部指令碼檔案(不非同步載入)還是內部指令碼程式碼塊,都是一樣的原理,並且都在同一個全域性作用域中。

JS引擎執行緒的執行過程的三個階段:

  • 語法分析
  • 預編譯階段
  • 執行階段

一. 語法分析

分析該js指令碼程式碼塊的語法是否正確,如果出現不正確,則向外丟擲一個語法錯誤(SyntaxError),停止該js程式碼塊的執行,然後繼續查詢並載入下一個程式碼塊;如果語法正確,則進入預編譯階段。

下面階段的程式碼執行不會再進行語法校驗,語法分析在程式碼塊載入完畢時統一檢驗語法。

二. 預編譯階段

1. js的執行環境

  • 全域性環境(JS程式碼載入完畢後,進入程式碼預編譯即進入全域性環境)

  • 函式環境(函式呼叫執行時,進入該函式環境,不同的函式則函式環境不同)

  • eval(不建議使用,會有安全,效能等問題)

每進入一個不同的執行環境都會建立一個相應的執行上下文(Execution Context),那麼在一段JS程式中一般都會建立多個執行上下文,js引擎會以棧的方式對這些執行上下文進行處理,形成函式呼叫棧(call stack),棧底永遠是全域性執行上下文(Global Execution Context),棧頂則永遠是當前執行上下文。

2. 函式呼叫棧/執行棧

呼叫棧,也叫執行棧,具有LIFO(後進先出)結構,用於儲存在程式碼執行期間建立的所有執行上下文。

首次執行JS程式碼時,會建立一個全域性執行上下文並Push到當前的執行棧中。每當發生函式呼叫,引擎都會為該函式建立一個新的函式執行上下文並Push到當前執行棧的棧頂。

當棧頂函式執行完成後,其對應的函式執行上下文將會從執行棧中Pop出,上下文控制權將移到當前執行棧的下一個執行上下文。

var 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');

// Inside first function
// Inside second function
// Again inside first function
// Inside Global Execution Context複製程式碼

函式呼叫棧

3. 執行上下文的建立

執行上下文可理解為當前的執行環境,與該執行環境相對應,具體分類如上面所說分為全域性執行上下文和函式執行上下文。建立執行上下文的三部曲:

  • 建立變數物件(Variable Object)

  • 建立作用域鏈(Scope Chain)

  • 確定this的指向

3.1 建立變數物件

建立變數物件

  • 建立arguments物件:檢查當前上下文中的引數,建立該物件的屬性與屬性值,僅在函式環境(非箭頭函式)中進行,全域性環境沒有此過程

  • 檢查當前上下文的函式宣告:按程式碼順序查詢,將找到的函式提前宣告,如果當前上下文的變數物件沒有該函式名屬性,則在該變數物件以函式名建立一個屬性,屬性值則為指向該函式所在堆記憶體地址的引用,如果存在,則會被新的引用覆蓋。

  • 檢查當前上下文的變數宣告:按程式碼順序查詢,將找到的變數提前宣告,如果當前上下文的變數物件沒有該變數名屬性,則在該變數物件以變數名建立一個屬性,屬性值為undefined;如果存在,則忽略該變數宣告

函式宣告提前和變數宣告提升是在建立變數物件中進行的,且函式宣告優先順序高於變數宣告。具體是如何函式和變數宣告提前的可以看後面。

建立變數物件發生在預編譯階段,但尚未進入執行階段,該變數物件都是不能訪問的,因為此時的變數物件中的變數屬性尚未賦值,值仍為undefined,只有進入執行階段,變數物件中的變數屬性進行賦值後,變數物件(Variable Object)轉為活動物件(Active Object)後,才能進行訪問,這個過程就是VO –> AO過程。

3.2 建立作用域鏈

通俗理解,作用域鏈由當前執行環境的變數物件(未進入執行階段前)與上層環境的一系列活動物件組成,它保證了當前執行環境對符合訪問許可權的變數和函式的有序訪問。

可以通過一個例子簡單理解:

var num = 30;

function test() {
    var a = 10;

    function innerTest() {
        var b = 20;

        return a + b
    }

    innerTest()
}

test()複製程式碼

在上面的例子中,當執行到呼叫innerTest函式,進入innerTest函式環境。全域性執行上下文和test函式執行上下文已進入執行階段,innerTest函式執行上下文在預編譯階段建立變數物件,所以他們的活動物件和變數物件分別是AO(global),AO(test)和VO(innerTest),而innerTest的作用域鏈由當前執行環境的變數物件(未進入執行階段前)與上層環境的一系列活動物件組成,如下:

innerTestEC = {

    //變數物件
    VO: {b: undefined}, 

    //作用域鏈
    scopeChain: [VO(innerTest), AO(test), AO(global)],  
    
    //this指向
    this: window
}複製程式碼

深入理解的話,建立作用域鏈,也就是建立詞法環境,而詞法環境有兩個組成部分:

  • 環境記錄:儲存變數和函式宣告的實際位置
  • 對外部環境的引用:可以訪問其外部詞法環境

詞法環境型別虛擬碼如下:

// 第一種型別: 全域性環境
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>  
  }  
}複製程式碼

變數提升的具體原因:在建立階段,函式宣告儲存在環境中,而變數會被設定為 undefined(在 var 的情況下)或保持未初始化(在 letconst 的情況下)。所以這就是為什麼可以在宣告之前訪問 var 定義的變數(儘管是 undefined ),但如果在宣告之前訪問 letconst 定義的變數就會提示引用錯誤的原因。此時let 和 const處於未初始化狀態不能使用,只有進入執行階段,變數物件中的變數屬性進行賦值後,變數物件(Variable Object)轉為活動物件(Active Object)後,letconst才能進行訪問。

關於函式宣告和變數宣告,這篇文章講的很好:github.com/yygmind/blo…

另外關於閉包的理解,如例子:

function foo() {
    var num = 20;

    function bar() {
        var result = num + 20;

        return result
    }

    bar()
}

foo()複製程式碼

瀏覽器分析如下:

閉包

chrome瀏覽器理解閉包是foo,那麼按瀏覽器的標準是如何定義閉包的,總結為三點:

  • 在函式內部定義新函式

  • 新函式訪問外層函式的區域性變數,即訪問外層函式環境的活動物件屬性

  • 新函式執行,建立新的函式執行上下文,外層函式即為閉包

3.3 this指向

比較複雜,後面專門弄一篇文章來整理。

三. 執行階段

1. 網頁的執行緒

永遠只有JS引擎執行緒在執行JS指令碼程式,其他三個執行緒只負責將滿足觸發條件的處理函式推進事件佇列,等待JS引擎執行緒執行, 不參與程式碼解析與執行。

  • JS引擎執行緒: 也稱為JS核心,負責解析執行Javascript指令碼程式的主執行緒(例如V8引擎)

  • 事件觸發執行緒: 歸屬於瀏覽器核心程式,不受JS引擎執行緒控制。主要用於控制事件(例如滑鼠,鍵盤等事件),當該事件被觸發時候,事件觸發執行緒就會把該事件的處理函式推進事件佇列,等待JS引擎執行緒執行

  • 定時器觸發執行緒:主要控制計時器setInterval和延時器setTimeout,用於定時器的計時,計時完畢,滿足定時器的觸發條件,則將定時器的處理函式推進事件佇列中,等待JS引擎執行緒執行。 注:W3C在HTML標準中規定setTimeout低於4ms的時間間隔算為4ms。

  • HTTP非同步請求執行緒:通過XMLHttpRequest連線後,通過瀏覽器新開的一個執行緒,監控readyState狀態變更時,如果設定了該狀態的回撥函式,則將該狀態的處理函式推進事件佇列中,等待JS引擎執行緒執行。 注:瀏覽器對通一域名請求的併發連線數是有限制的,Chrome和Firefox限制數為6個,ie8則為10個。

2. 巨集任務

巨集任務(macro-task)可分為同步任務非同步任務

  • 同步任務指的是在JS引擎主執行緒上按順序執行的任務,只有前一個任務執行完畢後,才能執行後一個任務,形成一個執行棧(函式呼叫棧)。

  • 非同步任務指的是不直接進入JS引擎主執行緒,而是滿足觸發條件時,相關的執行緒將該非同步任務推進任務佇列(task queue),等待JS引擎主執行緒上的任務執行完畢,空閒時讀取執行的任務,例如非同步Ajax,DOM事件,setTimeout等。

理解巨集任務中同步任務和非同步任務的執行順序,那麼就相當於理解了JS非同步執行機制–事件迴圈(Event Loop)。

3. 事件迴圈

事件迴圈可以理解成由三部分組成,分別是:

  • 主執行緒執行棧

  • 非同步任務等待觸發

  • 任務佇列

任務佇列(task queue)就是以佇列的資料結構對事件任務進行管理,特點是先進先出,後進後出。

事件迴圈

setTimeout和setInterval的區別:

  • setTimeout是在到了指定時間的時候就把事件推到任務佇列中,只有當在任務佇列中的setTimeout事件被主執行緒執行後,才會繼續再次在到了指定時間的時候把事件推到任務佇列,那麼setTimeout的事件執行肯定比指定的時間要久,具體相差多少跟程式碼執行時間有關

  • setInterval則是每次都精確的隔一段時間就向任務佇列推入一個事件,無論上一個setInterval事件是否已經執行,所以有可能存在setInterval的事件任務累積,導致setInterval的程式碼重複連續執行多次,影響頁面效能。

4. 微任務

微任務是在es6和node環境中出現的一個任務型別,如果不考慮es6和node環境的話,我們只需要理解巨集任務事件迴圈的執行過程就已經足夠了,但是到了es6和node環境,我們就需要理解微任務的執行順序了。 微任務(micro-task)的API主要有:Promise, process.nextTick

微任務

例子理解:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');複製程式碼

執行過程如下:

  • 程式碼塊通過語法分析和預編譯後,進入執行階段,當JS引擎主執行緒執行到console.log('script start');,JS引擎主執行緒認為該任務是同步任務,所以立刻執行輸出script start,然後繼續向下執行;

  • JS引擎主執行緒執行到setTimeout(function() { console.log('setTimeout'); }, 0);,JS引擎主執行緒認為setTimeout是非同步任務API,則向瀏覽器核心程式申請開啟定時器執行緒進行計時和控制該setTimeout任務。由於W3C在HTML標準中規定setTimeout低於4ms的時間間隔算為4ms,那麼當計時到4ms時,定時器執行緒就把該回撥處理函式推進任務佇列中等待主執行緒執行,然後JS引擎主執行緒繼續向下執行

  • JS引擎主執行緒執行到Promise.resolve().then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); });,JS引擎主執行緒認為Promise是一個微任務,這把該任務劃分為微任務,等待執行

  • JS引擎主執行緒執行到console.log('script end');,JS引擎主執行緒認為該任務是同步任務,所以立刻執行輸出script end

  • 主執行緒上的巨集任務執行完畢,則開始檢測是否存在可執行的微任務,檢測到一個Promise微任務,那麼立刻執行,輸出promise1和promise2

  • 微任務執行完畢,主執行緒開始讀取任務佇列中的事件任務setTimeout,推入主執行緒形成新巨集任務,然後在主執行緒中執行,輸出setTimeout

最後的輸出結果即為:

script start
script end
promise1
promise2
setTimeout複製程式碼

文章參考:

github.com/yygmind/blo…

heyingye.github.io/2018/03/19/…

heyingye.github.io/2018/03/26/…

github.com/yygmind/blo…


相關文章