1. 程式人生 > 其它 >JavaScript的執行過程(深入執行上下文、GO、AO、VO和VE等概念)

JavaScript的執行過程(深入執行上下文、GO、AO、VO和VE等概念)

JavaScript的執行過程

前言

編寫一段JavaScript程式碼,它是如何執行的呢?簡單來說,JS引擎在執行JavaScript程式碼的過程中需要先解析再執行。那麼在解析階段JS引擎又會進行哪些操作,接下來就一起來了解一下JavaScript在執行過程中的詳細過程,包括執行上下文、GO、AO、VO和VE等概念的理解。

1.初始化全域性物件

首先,JS引擎會在執行程式碼之前,也就是解析程式碼時,會在我們的堆記憶體建立一個全域性物件:Global Object(簡稱GO),觀察以下程式碼,在全域性中定義了幾個變數:

示例程式碼:

var name = 'curry'
var message = 'I am a coder'
var num = 30

JS引擎內部在解析以上程式碼時,會建立一個全域性物件(虛擬碼如下):

  • 所有的作用域(scope)都可以訪問該全域性物件;
  • 物件裡面會包含一些全域性的方法和類,像Math、Date、String、Array、setTimeout等等;
  • 其中有一個window屬性是指向該全域性物件自身的;
  • 該物件中會收集我們上面全域性定義的變數,並設定成undefined;
  • 全域性物件是非常重要的,我們平時之所以能夠使用這些全域性方法和類,都是在這個全域性物件中獲取的;
var GlobalObject = {
  Math: '類',
  Date: '類',
  String: '類',
  setTimeout: '函式',
  setInterval: '函式',
  window: GlobalObject,
  ...
  name: undefined,
  message: undefined,
  num: undefined
}

2.執行上下文棧(呼叫棧)

瞭解了什麼是全域性物件後,下面就來聊聊程式碼具體執行的地方。JS引擎為了執行程式碼,引擎內部會有一個執行上下文棧(Execution Context Stack,簡稱ECS),它是用來執行程式碼的呼叫棧

(1)ECS如何執行?先執行誰呢?

  • 無疑是先執行我們的全域性程式碼塊;
  • 在執行前全域性程式碼會構建一個全域性執行上下文(Global Execution Context,簡稱GEC)
  • 一開始GEC就會被放入到ECS中執行;

(2)那麼全域性執行上下文(GEC)包含那些內容呢?

  • 第一部分:執行程式碼前。
    • 在轉成抽象語法樹之前,會將全域性定義的變數、函式等加入到Global Object中,也就是上面初始化全域性物件的過程;
    • 但是並不會真正賦值(表現為undefined),所以這個過程也稱之為變數的作用域提升(hoisting)
  • 第二部分:程式碼執行。
    • 對變數進行賦值,或者執行其它函式等;

下面就通過一幅圖,來看看GEC被放入ECS後的表現形式:

3.呼叫棧呼叫GEC的過程

接下來,將全域性程式碼複雜化一點,再來看看呼叫棧呼叫全域性執行上下文(GEC)的過程。

例項程式碼:

var name = 'curry'

console.log(message)

var message = 'I am a coder'

function foo() {
  var name = 'foo'
  console.log(name)
}

var num1 = 30
var num2 = 20

var result = num1 + num2

foo()

呼叫棧呼叫過程:

  • 1.初始化全域性物件。

    • 這裡需要注意的是函式存放的是地址,會指向函式物件,與普通變數有所不同;
    • 從上往下解析JS程式碼,當解析到foo函式時,因為foo不是普通變數,並不會賦為undefined,JS引擎會在堆記憶體中開闢一塊空間存放foo函式,在全域性物件中引用其地址;
    • 這個開闢的函式儲存空間最主要存放了該函式的父級作用域和函式的執行體程式碼塊
  • 2.構建一個全域性執行上下文(GEC),程式碼執行前將VO的記憶體地址指向GlobalObject(GO)。

  • 3.將全域性執行上下文(GEC)放入執行上下文棧(ECS)中。

  • 4.從上往下開始執行全域性程式碼,依次對GO物件中的全域性變數進行賦值。

    • 當執行var name = 'curry'時,就從VO(對應的就是GO)中找到name屬性賦值為curry;
    • 接下來執行console.log(message),就從VO中找到message,注意此時的message還為undefined,因為message真正賦值在下一行程式碼,所以就直接列印undefined(也就是我們經常說的變數作用域提升);
    • 後面就依次進行賦值,執行到var result = num1 + num2,也是從VO中找到num1和num2兩個屬性的值進行相加,然後賦值給result,result最終就為50;
    • 最後執行到foo(),也就是需要去執行foo函數了,這裡的操作是比較特殊的,涉及到函式執行上下文,下面來詳細瞭解;

4.函式執行上下文

在執行全域性程式碼遇到函式如何執行呢?

  • 在執行的過程中遇到函式,就會根據函式體建立一個函式執行上下文(Functional Execution Context,簡稱FEC),並且加入到執行上下文棧(ECS)中。
  • 函式執行上下文(FEC)包含三部分內容:
    • AO:在解析函式時,會建立一個Activation Objec(AO)
    • 作用域鏈:由函式VO和父級VO組成,查詢是一層層往外層查詢;
    • this指向:this繫結的值,在函式執行時確定;
  • 其實全域性執行上下文(GEC)也有自己的作用域鏈和this指向,只是它對應的作用域鏈就是自己本身,而this指向為window。

繼續來看上面的程式碼執行,當執行到foo()時:

  • 先找到foo函式的儲存地址,然後解析foo函式,生成函式的AO;
  • 根據AO生成函式執行上下文(FEC),並將其放入執行上下文棧(ECS)中;
  • 開始執行foo函式內程式碼,依次找到AO中的屬性並賦值,當執行console.log(name)時,就會去foo的VO(對應的就是foo函式的AO)中找到name屬性值並列印;

5.變數環境和記錄

上文中提到了很多次VO,那麼VO到底是什麼呢?下面從ECMA新舊版本規範中來談談VO。

在早期ECMA的版本規範中:每一個執行上下文會被關聯到一個變數環境(Variable Object,簡稱VO),在原始碼中的變數和函式宣告會被作為屬性新增到VO中。對應函式來說,引數也會被新增到VO中。

  • 也就是上面所建立的GO或者AO都會被關聯到變數環境(VO)上,可以通過VO查詢到需要的屬性;
  • 規定了VO為Object型別,上文所提到的GO和AO都是Object型別;

在最新ECMA的版本規範中:每一個執行上下文會關聯到一個變數環境(Variable Environment,簡稱VE),在執行程式碼中變數和函式的宣告會作為環境記錄(Environment Record)新增到變數環境中。對於函式來說,引數也會被作為環境記錄新增到變數環境中。

  • 也就是相比於早期的版本規範,對於變數環境,已經去除了VO這個概念,提出了一個新的概念VE;
  • 沒有規定VE必須為Object,不同的JS引擎可以使用不同的型別,作為一條環境記錄新增進去即可;
  • 雖然新版本規範將變數環境改成了VE,但是JavaScript的執行過程還是不變的,只是關聯的變數環境不同,將VE看成VO即可;

6.全域性程式碼執行過程(函式巢狀)

瞭解了上面相關的概念和呼叫流程之後,就來看一下存在函式巢狀呼叫的程式碼是如何執行的,以及執行過程中的一些細節,以下面程式碼為例:

var message = 'global'

function foo(m) {
  var message = 'foo'
  console.log(m)

  function bar() {
    console.log(message)
  }

  bar()
}

foo(30)
  • 初始化全域性物件(GO),執行全域性程式碼前建立GEC,並將GO關聯到VO,然後將GEC加入ECS中:

    • foo函式儲存空間中指定的父級作用域為全域性物件;
  • 開始執行全域性程式碼,從上往下依次給全域性屬性賦值:

    • 給message屬性賦值為global;
  • 執行到foo函式呼叫,準備執行foo函式前,建立foo函式的AO:

    • bar函式儲存空間中指定父級作用域為foo函式的AO;
  • 建立foo函式的FEC,並加入到ECS中,然後開始執行foo函式體內的程式碼:

    • 根據foo函式呼叫的傳參,給形參m賦值為30,接著給message屬性賦值為foo;
    • 所以,m列印結果為30;
  • 執行到bar函式呼叫,準備執行bar函式前,建立bar函式的AO:

    • bar函式中沒有定義屬性和宣告函式,以空物件表示;
  • 建立bar函式的FEC,並加入到ECS中,然後開始執行bar函式體內的程式碼:

    • 執行console.log(message),會先去bar函式自己的VO中找message,沒有找到就往上層作用域的VO中找;
    • 這裡bar函式的父級作用域為foo函式,所以找到foo函式VO中的message為foo,列印結果為foo
  • 全域性中所有程式碼執行完成,bar函式執行上下文出棧,foo函式AO物件失去了引用,進行銷燬。

  • 接著foo函式執行上下文出棧,foo函式AO物件失去了引用,進行銷燬,同樣,foo函式AO物件銷燬後,bar函式的儲存空間也失去引用,進行銷燬。

總結:

  • 函式在執行前就已經確定了其父級作用域,與函式在哪執行沒有關係,以函式宣告的位置為主;

  • 執行程式碼查詢變數屬性時,會沿著作用域鏈一層層往上查詢(沿著VO往上找),如果一直找到全域性物件中還沒有該變數屬性,就會報錯未定義;

  • 上文中提到了很多概念名詞,下面來總結一下:

    名詞 解釋
    ECS 執行上下文棧(Execution Context Stack),也可稱為呼叫棧,以棧的形式呼叫建立的執行上下文
    GEC 全域性執行上下文(Global Execution Context),在執行全域性程式碼前建立
    FEC 函式執行上下文(Functional Execution Context),在執行函式前建立
    VO Variable Object,早期ECMA規範中的變數環境,對應Object
    VE Variable Environment,最新ECMA規範中的變數環境,對應環境記錄
    GO 全域性物件(Global Object),解析全域性程式碼時建立,GEC中關聯的VO就是GO
    AO 函式物件(Activation Object),解析函式體程式碼時建立,FEC中關聯的VO就是AO