(譯文)JavaScript中的執行上下文和執行棧

不吃藥不喝水發表於2019-08-05

JavaScript中的執行上下文和執行棧(原文地址)

本文將深入探討JavaScript中最重要的基礎知識之一:執行上下文。通過對此篇文章的閱讀,對以下幾個方面的知識你將會有更加清晰的認識:

  • 直譯器的執行機制
  • 為何函式和變數可以在宣告前使用以及它們的值究竟是如何確定的

什麼是執行上下文?

當程式碼在JS中執行時,程式碼的執行環境非常重要,JavaScript中可執行的程式碼分為以下幾類:

  • 全域性程式碼:程式碼首次執行時所進入的預設執行環境
  • 函式程式碼:函式體內的程式碼
  • Eval程式碼:eval內部的程式碼

我們可以在網上找到很多與作用域相關的文件等,本文為了便於知識點的理解,將執行上下文看作是當前程式碼執行所處的環境/作用域。下面是一個包括全域性和函式上下文的程式碼示例:

img1
以上示例程式碼結構很簡明,一個由紫色實線包裹的全域性上下文和三個分別由綠色、藍色和橙色實線包裹的函式上下文。每個程式中只能有一個可被其他程式所訪問的全域性上下文。

函式上下文可以有任意多個,並且每個函式在呼叫的時候都會產生一個新的函式上下文和一個私有的作用域,當前作用域中所宣告的任何變數都不能被外部所直接訪問或呼叫。上例中,函式可直接訪問當前上下文外部宣告的變數,但是外部函式上下文不能訪問內部宣告的變數或者函式。為何會出現這種情況呢?程式碼到底是怎麼執行的呢?

執行環境棧

瀏覽器中JavaScript直譯器的執行是單執行緒的。這也就意味著在瀏覽器中同一時刻只能做一件事情,其他行為或者事件需要在執行棧中排隊等待。下圖是對單執行緒的抽象展示:

img2
當瀏覽器首次載入指令碼語言的時候,會預設進入全域性執行上下文。如果在全域性程式碼中呼叫其他函式,當前程式的時序會自動進入所呼叫的函式中,與此同時會建立一個新的執行上下文並將其壓入執行棧的頂部。

如果在當前函式內部呼叫其他函式,執行過程如上所述。程式碼的執行流程會進入到內部函式中,建立一個新的執行上下文並將它壓入執行棧的頂部。瀏覽器永遠執行位於棧頂的執行上下文,並且一旦當前函式執行上下文執行結束,它將從棧頂彈出,執行控制權也會回到當前棧的新棧頂。這樣,執行環境棧中的上下文就會被依次執行和彈出棧頂,直到回到全域性上下文,下例所示:

(function foo(i){
    if (i===3) {
        return;
    }else{
        foo(++i);
    }
}(0))
複製程式碼

程式碼自呼叫三次,i的值不斷從1自增。每次函式foo被呼叫的時候,一個新的執行上下文就會建立。一旦當前上下文執行結束,它就會從棧頂彈出,回到棧頂的新的上下文,直到再次回到全域性上下文。

img3

執行棧中需要記住的5個關鍵點:
  • 單執行緒
  • 同步執行
  • 唯一的一個全域性上下文
  • 不限個數的函式上下文
  • 每個函式的呼叫都會產生一個新的執行上下文,即使是函式對自己的呼叫

詳解執行上下文

截至目前我們已經知道每當一個函式被呼叫的時候,就會產生一個新的執行上下文。但是,在JavaScript直譯器中,對每個執行上下文的呼叫都分為以下兩個階段:

  • 建立階段[當函式被呼叫,內部程式碼被執行之前的階段]:
    • 建立作用域鏈
    • 建立變數、函式、引數
    • 確定this的值
  • 啟用/程式碼執行階段:
    • 確定函式的值和引用,然後執行程式碼

因為可以將執行上下文概念性的描述為含有三個屬性的物件:

executionContextObj = {
    'scopeChain':{/*variableObject+所有父類執行上下文的variableObject*/},
    'variableObject':{/*函式形參/實參,內部的變數和函式宣告*/},
    'this':{}
}
複製程式碼

啟用/變數物件[AO/VO]

執行上下文物件是在函式被呼叫,但是在函式被執行前所產生的。也就是上文所述的階段1—建立階段。比部分中,直譯器對執行上下文物件的建立主要是通過瀏覽函式的實參和形參、當前函式內部的變數宣告和函式宣告。這部分的瀏覽結果會成為執行上下文物件中的變數物件。

直譯器對程式碼執行的偽邏輯概述:
  • 查詢函式呼叫的程式碼
  • 在執行程式碼前,建立執行上下文
  • 進入建立上下文階段:
    • 初始化作用域鏈
    • 建立變數物件:
      • 建立引數物件,檢查上下文中的引數,初始化引數名稱和值並建立引用副本
      • 瀏覽上下文中的函式宣告:
        • 每找到一個函式,就在變數物件中新增一個新的屬性,該屬性命名為當前函式名,指向函式在記憶體中的引用
        • 如果函式名已經存在,所對應的屬性值將被重寫,指向新的函式引用
      • 瀏覽上下文中的變數宣告:
        • 每找到一個變數宣告,在變數物件中新增一個新的屬性,該屬性命名為當前變數名,並給該屬性賦值為undefined
        • 如果變數名已經在變數物件中存在,將不進行任何操作,繼續瀏覽當前上下文
      • 確定上下文中this的指向
    • 程式碼執行階段:
      • 分配變數值並且逐行執行當前上下文中的程式碼

下面看一個例子:

function foo(i){
    var a = 'hello',
    var b = function privateB(){
        
    },
    function c(){
        
    }
}
foo(22);
複製程式碼

當呼叫函式foo的時候,建立階段如下所示:

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':{...}
}
複製程式碼

變數提升

網上很多關於JavaScript中變數提升的定義,定義中指出變數和函式的宣告會被提升至當前函式作用域的頂部。但是,並沒有解釋為什麼會存在變數提升以及直譯器如何建立啟用物件,其實原因很簡單,以下面的程式碼為例:

(function() {
    console.log(typeof foo); // function pointer
    console.log(typeof bar); // undefiend
    
    var foo = 'hello',
        bar = function (){
            return 'world';
        };
    
    function foo(){
        return 'hello';
    };
}())
複製程式碼

對於疑問和解答如下:

  • 為什麼我們可以在宣告foo前訪問它?
    • 回顧建立階段,變數在函式執行前已經被建立。因此在函式執行前,foo已經在啟用物件中建立。
  • foo被宣告瞭兩次,為什麼foo的型別是function而不是undefined或者string?
    • 儘管foo被宣告兩次,在建立階段中,函式先於變數在啟用物件中建立,並且如果啟用物件中已經存在屬性名,則不會影響已經存在的屬性。
    • 所以,對於函式foo的引用首先在啟用物件中已經建立,並且當直譯器到達var foo語句,直譯器發現在變數物件中foo已經被建立,因此就會跳過然後繼續後續操作。
  • 為什麼bar的值是undefined?
    • bar實際上是一個值為函式的變數,在建立階段變數會被初始化為undefined 。
注:以上部分譯自此文,如有侵權請告知;如有翻譯不妥,還請各位讀者指正。以下是我對本文知識點的簡要總結。

簡要總結

  • 每個函式被呼叫的時候,都會建立一個新的執行上下文,並將當前執行上下文壓入棧頂
  • 每個執行上下文可以看作是具有以下3個屬性的物件:
    • 作用域鏈
    • 變數物件/啟用物件(VO/AO)
    • this
  • 每個執行上下文的建立分為兩個階段:建立階段和執行階段
  • 執行上下文建立階段,變數物件VO初始化的先後順序:函式引數、函式宣告、變數宣告。關於此部分兩個常見問題的解答如下:
    • 1、"函式宣告過程中,變數物件中如果已存在同名的屬性,則替換它的值"這句話如何理解?以下述程式碼為例:
    function foo(i){
        console.log(i); // function pointer
        var i = function (){
            
        }
    }
    foo(2);
    複製程式碼
    變數物件初始化第一步:函式引數
    複製程式碼
    executionContextObj = {
        'scopeChain':{...},
        'variableObject':{
            arguments:{
                0:2,
                length: 1,
            },
            i:2
        }
    }
    複製程式碼
    變數物件初始化第二步:函式宣告
       函式宣告過程中,變數物件中已存在同名的屬性i,將其值由"1"替換為新值"function"
    複製程式碼
    executionContextObj = {
        'scopeChain':{...},
        'variableObject':{
            arguments:{
                0:2,
                length: 1,
            },
            i: function (){
                
            }
        }
    }
    複製程式碼
    • 2、"變數宣告過程中,變數物件中如果已存在同名的屬性,則不進行任何操作"這句話如何理解?以下述程式碼為例:
  function foo(i){
      console.log(i); // function pointer
      var i = function (){
          
      },
      var i = 9;
  }
  foo(2);
複製程式碼
  變數物件初始化第一步:函式引數
複製程式碼
executionContextObj = {
    'scopeChain':{...},
    'variableObject':{
        arguments:{
            0:2,
            length: 1,
        },
        i:2
    }
}
複製程式碼
  變數物件初始化第二步:函式宣告
      函式宣告過程中,變數物件中已存在同名的屬性i,將其值由‘1’替換為新值‘function’
複製程式碼
executionContextObj = {
    'scopeChain':{...},
    'variableObject':{
        arguments:{
            0:2,
            length: 1,
        },
        i: function (){
            
        }
    }
}
複製程式碼
  變數物件初始化第三步:變數宣告
      變數宣告過程中,變數物件中已存在同名的屬性i,不進行任何操作。
複製程式碼
executionContextObj = {
    'scopeChain':{...},
    'variableObject':{
        arguments:{
            0:2,
            length: 1,
        },
        i: function (){
            
        }
    },
    'this':{...}
}
複製程式碼

相關文章