傻傻分不清的javascript執行機制

不做祖國的韭菜發表於2018-12-24

學習到javascript的執行機制時,有幾個概念經常出現在各種文章中且容易混淆。Execution Context(執行環境或執行上下文),Context Stack (執行棧),Variable Object(VO: 變數物件),Active Object(AO: 活動物件),LexicalEnvironment(詞法環境),VariableEnvironment(變數環境)等,特別是 VO,AO以及LexicalEnvironment,VariableEnvironment的區別很多文章都沒有涉及到。因此我檢視了一些國內外的文章,結合自身理解寫下了下面的筆記。雖然因為自身不足導致理解上的偏差,但是依然相信讀完下文會對理解javascript的一些概念如變數提升,作用域和閉包有很大的幫助。

一, 執行環境和執行棧

瞭解javascript的執行機制,首先必須掌握兩個基本的概念。Execution Context(執行環境或執行上下文)和Context Stack (執行棧)

1. 何為執行環境(執行上下文)(Execution Context)

我們知道javascript是單執行緒語言,也就是同一時間只能執行一個任務。當javascript直譯器初始化程式碼後,預設會進入全域性的執行環境,之後每呼叫一個函式,javascript直譯器會建立一個新的執行環境。

    var a = 1;                       // 1.初始化預設進入全域性執行環境
    function b() {                   // 3.進入b 的執行環境
        function c() {               // 5. 進入c的執行環境
            ···
        }
        c()                          // 4.在b的執行環境裡呼叫c, 建立c的執行環境
    }
    b()                              // 2. 呼叫b 建立 b 的執行環境
複製程式碼

執行環境的分類:

  • 全域性執行環境:簡單的理解,一個程式只有一個全域性物件即window物件,全域性物件所處的執行環境就是全域性執行環境。
  • 函式執行環境:函式呼叫過程會建立函式的執行環境,因此每個程式可以有無數個函式執行環境。
  • Eval執行環境:eval程式碼特定的環境。

2. 如何單執行緒執行(Context Stack)

從一個簡單的例子開始講起

function foo(i) {
  if (i < 0) return;
  console.log('begin:' + i);
  foo(i - 1);
  console.log('end:' + i);
}
foo(2);
複製程式碼

如何儲存程式碼執行時的執行環境(全域性執行環境,函式執行環境)呢,答案是執行棧。而棧遵循的是先進後出的原理,javascript初始化完程式碼後,首先會建立全域性執行環境並推入當前的執行棧,當呼叫一個函式時,javascript引擎會建立新的執行環境並推到當前執行棧的頂端,在新的執行環境中,如果繼續發生一個新函式呼叫時,則繼續建立新的執行環境並推到當前執行棧的頂端,直到再無新函式呼叫。最上方的函式執行完成後,它的執行環境便從當前棧中彈出,並將控制權移交到當前執行棧的下一個執行環境,直到全域性執行環境。當程式或瀏覽器關閉時,全域性環境也將退出並銷燬。

傻傻分不清的javascript執行機制

因此輸出的結果為:

begin:2
begin:1
begin:0
end:0
end:1
end:2
複製程式碼

3. 如何建立執行環境

我們現在知道每次呼叫函式時,javascript 引擎都會建立一個新的執行環境,而如何建立這一系列的執行環境呢,答案是執行器會分為兩個階段來完成, 分別是建立階段和啟用(執行)階段。而即使步驟相同但是由於規範的不同,每個階段執行的過程有很大的不同。

3.1 ES3 規範

建立階段:

  • 1.建立作用域鏈。
  • 2.建立變數物件VO(包括引數,函式,變數)。
  • 3.確定this的值。

啟用/執行階段:

  • 完成變數分配,執行程式碼。
3.2 ES5 規範

建立階段:

  • 1.確定 this 的值。
  • 2.建立詞法環境(LexicalEnvironment)。
  • 3.建立變數環境(VariableEnvironment)。

啟用/執行階段:

  • 完成變數分配,執行程式碼。

我們從規範上可以知道,ES3和ES5在執行環境的建立階段存在差異,當然他們都會在這個階段確定this 的值 (關於this 的指向問題我們以後會在專門的文章中分析各種this 的指向問題,這裡便不做深究)。我們將圍繞這兩個規範不同點展開。儘管ES3的一些規範已經被拋棄,但是掌握ES3 建立執行環境的過程依然有助於我們理解javascript深層次的概念。

二, Variable Object(VO: 變數物件),Active Object(AO: 活動物件)

2.1 基本概念

VO 和 AO 是ES3規範中的概念,我們知道在建立過程的第二個階段會建立變數物件,也就是VO,它是用來存放執行環境中可被訪問但是不能被 delete 的函式識別符號,形參,變數宣告等,這個物件在js環境下是不可訪問的。而AO 和VO之間區別就是AO 是一個啟用的VO,僅此而已。

  • 變數物件(Variable) object)是說JS的執行上下文中都有個物件用來存放執行上下文中可被訪問但是不能被delete的函式標示符、形參、變數宣告等。它們會被掛在這個物件上,物件的屬性對應它們的名字物件屬性的值對應它們的值但這個物件是規範上或者說是引擎實現上的不可在JS環境中訪問到活動物件

  • 啟用物件(Activation object)有了變數物件存每個上下文中的東西,但是它什麼時候能被訪問到呢?就是每進入一個執行上下文時,這個執行上下文兒中的變數物件就被啟用,也就是該上下文中的函式標示符、形參、變數宣告等就可以被訪問到了

2.2 執行細節

如何建立VO物件可以大致分為四步

  • 1.建立arguments物件
  • 2.掃描上下文的函式宣告(而非函式表示式),為發現的每一個函式,在變數物件上建立一個屬性——確切的說是函式的名字——其有一個指向函式在記憶體中的引用。如果函式的名字已經存在,引用指標將被重寫。
  • 3.掃描上下文的變數宣告,為發現的每個變數宣告,在變數物件上建立一個屬性——就是變數的名字,並且將變數的值初始化為undefined。如果變數的名字已經在變數物件裡存在,將不會進行任何操作並繼續掃描。

注意: 整個過程可以大概描述成: 函式的形參=>函式宣告=>變數宣告, 其中在建立函式宣告時,如果名字存在,則會被重寫,在建立變數時,如果變數名存在,則忽略不會進行任何操作。

一個簡單的例子

function foo(i) {
    var a = 'hello';
    var b = function privateB() {

    };
    function c() {

    }
}

foo(22);
複製程式碼

執行的虛擬碼

// 建立階段
fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: undefined,
        b: undefined
    },
    this: { ... }
}
// 啟用階段
fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: 'hello',
        b: pointer to function privateB()
    },
    this: { ... }
}
複製程式碼

三, LexicalEnvironment(詞法環境),VariableEnvironment(變數環境)

3.1 基本概念

詞法環境和變數環境是ES5以後提到的概念,官方對詞法環境的解釋如下。

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

簡單的理解,詞法環境是一個包含識別符號變數對映的結構。(這裡的識別符號表示變數/函式的名稱,變數是對實際物件【包括函式型別物件】或原始值的引用)。

ES3的VO,AO為什麼可以被拋棄?個人認為有兩個原因,第一個是在建立過程中所執行的建立作用域鏈和建立變數物件(VO)都可以在建立詞法環境的過程中完成。第二個是針對es6中儲存函式宣告和變數(let 和 const)以及儲存變數(var)的繫結,可以通過兩個不同的過程(詞法環境,變數環境)區分開來。

3.2 詞法環境(lexicalEnvironment)

詞法環境由兩個部分組成

  • 環境記錄(enviroment record),儲存變數和函式宣告
  • 對外部環境的引用(outer),可以通過它訪問外部詞法環境

對外部環境的引用關係到作用域鏈,之後再分析,我們先來看看環境記錄的分類。

環境記錄分兩部分

  • 宣告性環境記錄(declarative environment records): 儲存變數、函式和引數, 但是主要用於函式 、catch詞法環境。 注意:函式環境下會儲存arguments的值。而詳細的過程可以參考VO 的執行細節,基本大同小異
  • 物件環境記錄(object environment records), 主要用於with 和全域性的詞法環境

虛擬碼如下

// 全域性環境
GlobalExectionContext = {  
// 詞法環境
  LexicalEnvironment: {  
    EnvironmentRecord: {  
    
        ···
    }
    outer: <null>  
  }  
}
// 函式環境
FunctionExectionContext = {  
// 詞法環境
  LexicalEnvironment: {  
    EnvironmentRecord: {  
        // 包含argument
        
    }
    outer: <Global or outer function environment reference>  
  }  
}
複製程式碼

3.3 變數環境(objectEnvironment)

變數環境也是個詞法環境,主要的區別在於lexicalEnviroment用於儲存函式宣告和變數( let 和 const )繫結,而ObjectEnviroment僅用於儲存變數( var )繫結。

3.4 虛擬碼展示

ES5規範下的整個建立過程可以參考下方的虛擬碼

let a = 20;  
const b = 30;  
var c;

function d(e, f) {  
 var g = 20;  
 return e * f * g;  
}

c = d(20, 30);
複製程式碼
// 全域性環境
GlobalExectionContext = {

  this: <Global Object>,
    // 詞法環境
  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  // 環境記錄分類: 物件環境記錄
      a: < uninitialized >,  // 未初始化
      b: < uninitialized >,  
      d: < func >  
    }  
    outer: <null>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  // 環境記錄分類: 物件環境記錄
      c: undefined,  // undefined
    }  
    outer: <null>  
  }  
}
// 函式環境
FunctionExectionContext = {  
   
  this: <Global Object>,

  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  // 環境記錄分類: 宣告環境記錄
      Arguments: {0: 20, 1: 30, length: 2},  // 函式環境下,環境記錄比全域性環境下的環境記錄多了argument物件
    },  
    outer: <GlobalLexicalEnvironment>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  // 環境記錄分類: 宣告環境記錄
      g: undefined  
    },  
    outer: <GlobalLexicalEnvironment>  
  }  
}
複製程式碼

四,作用域鏈

前面講建立過程中,我們留下了一個伏筆,ES3規範中有建立作用域鏈的過程,而ES5中在建立詞法環境或變數環境的過程中,也有生成外部環境的引用的過程。那這個過程有什麼作用呢。我們通過一個簡單的例子來說明。

function one() {

    var a = 1;
    two();

    function two() {

        var b = 2;
        three();

        function three() {

            var c = 3;
            alert(a + b + c); // 6

        }

    }

}

one();
複製程式碼

當執行到three 的執行環境時,此時 a和b 都不在c 的變數內,因此作用域鏈則起到了引用外部執行環境變數的作用。ES3中建立的作用域鏈如圖:

傻傻分不清的javascript執行機制

當直譯器執行alert(a + b + c),他首先會找自身執行環境下是否有a這個變數的存在,如果不存在,則通過檢視作用域鏈,判斷a是否在上一個執行環境內部。它檢查是否a存在於內部,若找不到,則沿著作用域鏈往上一個執行環境找,直到找到,或者到頂級的全域性作用域。同理ES6規範中也可以這樣分析。

因此這會引入一個javascript一個重要的概念,閉包。從上面對執行環境的解釋我們可以這樣理解,閉包就是內部環境通過作用域鏈訪問到上層環境的變數。因此也存在無法進行變數回收的問題,只要函式的作用域鏈在,變數的值便因為閉包無法被回收。

注意: 此作用域鏈和原型鏈的作用域鏈不是同一個概念。

五, 小結

通過對javascript執行機制的介紹,對一些javasript高階概念有了更深的認識,特別是對一些雲裡霧裡的概念區別有了更深刻的認識。不同規範下,不同概念的解釋更有利於深挖javascript底層的執行思想。我相信這是理解javascipt語言最重要的一步。

參考資料:

本文為博主原創文章,轉載請註明出處 juejin.im/post/5c2052…

相關文章