深入理解JavaScript執行上下文、函式堆疊、提升的概念

就那發表於2018-03-20

本文內容主要轉載自以下兩位作者的文章,如有侵權請聯絡我刪除: https://feclub.cn/post/content/ec_ecs_hosting http://blog.csdn.net/hi_kevin/article/details/37761919

首先明確幾個概念:

EC函式執行環境(或執行上下文),Execution Context
ECS執行環境棧,Execution Context Stack
VO變數物件,Variable Object
AO活動物件,Active Object
scope chain作用域鏈

EC(執行上下文)

每次當控制器轉到ECMAScript可執行程式碼的時候,就會進入到一個執行上下文。

那什麼是可執行程式碼呢?

可執行程式碼的型別

1、全域性程式碼(Global code)

這種型別的程式碼是在"程式"級處理的:例如載入外部的js檔案或者本地""標籤內的程式碼。全域性程式碼不包括任何function體內的程式碼。 這個是預設的程式碼執行環境,一旦程式碼被載入,引擎最先進入的就是這個環境。

2、函式程式碼(Function code)

任何一個函式體內的程式碼,但是需要注意的是,具體的函式體內的程式碼是不包括內部函式的程式碼。

3、Eval程式碼(Eval code)

eval內部的程式碼

ECS(執行環境棧)

我們用MDN上的一個例子來引入函式執行棧的概念

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

// 輸出:

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

這裡先不關心執行結果。磨刀不誤砍柴功,先了解一下函式執行上下文堆疊的概念。相信弄明白了下面的概念,一切也就水落石出了。

我們都知道,瀏覽器中的JS直譯器被實現為單執行緒,這也就意味著同一時間只能發生一件事情,其他的行為或事件將會被放在叫做執行棧裡面排隊。下面的圖是單執行緒棧的抽象檢視:

深入理解JavaScript執行上下文、函式堆疊、提升的概念
當瀏覽器首次載入你的指令碼,它將預設進入全域性執行上下文。如果,你在你的全域性程式碼中呼叫一個函式,你程式的時序將進入被呼叫的函式,並建立一個新的執行上下文,並將新建立的上下文壓入執行棧的頂部。

如果你呼叫當前函式內部的其他函式,相同的事情會在此上演。程式碼的執行流程進入內部函式,建立一個新的執行上下文並把它壓入執行棧的頂部。瀏覽器總會執行位於棧頂的執行上下文,一旦當前上下文函式執行結束,它將被從棧頂彈出,並將上下文控制權交給當前的棧。這樣,堆疊中的上下文就會被依次執行並且彈出堆疊,直到回到全域性的上下文。請看下面一個例子:

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

上述goo被宣告後,通過()運算子強制直接執行了。函式程式碼就是呼叫了其自身3次,每次是區域性變數i增加1。每次goo函式被自身呼叫時,就會有一個新的執行上下文被建立。每當一個上下文執行完畢,該上下文就被彈出堆疊,回到上一個上下文,直到再次回到全域性上下文。整個過程抽象如下圖:

深入理解JavaScript執行上下文、函式堆疊、提升的概念

由此可見 ,對於執行上下文這個抽象的概念,可以歸納為以下幾點:

1、單執行緒 2、同步執行 3、唯一的一個全域性上下文 4、函式的執行上下文的個數沒有限制 5、每次某個函式被呼叫,就會有個新的執行上下文為其建立,即使是呼叫的自身函式,也是如此

看到這裡,想必大家都已經深諳上述例子輸出結果的原因了,這裡我大概繪了一個流程圖來幫助理解foo:

深入理解JavaScript執行上下文、函式堆疊、提升的概念

VO(變數物件)/AO(活動物件)

這裡為什麼要用一個/呢?按照字面理解,AO其實就是被啟用的VO,兩個其實是一個東西。下面引用知乎上的一段話,幫助理解一下。原文連結

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

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

EC建立的細節

1、建立階段【當函式被呼叫,但未執行任何其內部程式碼之前】

1、 建立作用域鏈(Scope Chain) 2、 建立變數,函式和引數。 3、 求”this“的值

2、執行階段

初始化變數的值和函式的引用,解釋/執行程式碼。


我們可以將每個執行上下文抽象為一個物件,這個物件具有三個屬性

ECObj: {
    scopeChain: { /* 變數物件(variableObject)+ 所有父級執行上下文的變數物件*/ }, 
    variableObject: { /*函式 arguments/引數,內部變數和函式宣告 */ }, 
    this: {} 
}
複製程式碼

直譯器執行程式碼的偽邏輯

1、查詢呼叫函式的程式碼。

2、執行程式碼之前,先進入建立上下文階段:

第一步:初始化作用域鏈
第二步:建立變數物件:
    a.建立arguments物件,檢查上下文,初始化引數名稱和值並建立引用的複製。
    b.掃描上下文的函式宣告(而非函式表示式):
        1、為發現的每一個函式,在變數物件上建立一個屬性,確切的說是函式的名字,其有一個指向函式在記憶體中的引用。
        2、如果函式的名字已經存在,引用指標將被重寫。
    c.掃描上下文的變數宣告:
        1、為發現的每個變數宣告,在變數物件上建立一個屬性,就是變數的名字,並且將變數的值初始化為undefined
        2、如果變數的名字已經在變數物件裡存在,將不會進行任何操作並繼續掃描。
第三步:求出上下文內部this的值。
複製程式碼

3、啟用/程式碼執行階段:

在當前上下文上執行/解釋函式程式碼,並隨著程式碼一行行執行指派變數的值。

VO --- 對應上述第二個階段

function foo(i){ var a = 'hello' var b = function(){} function c(){} } foo(22)

//當我們呼叫foo(22)時,整個建立階段是下面這樣的: ECObj = { scopChain: {...}, variableObject: { arguments: { 0: 22, length: 1 }, i: 22, c: pointer to function c() a: undefined, b: undefined }, this: { ... } }

正如我們看到的,在上下文建立階段,VO的初始化過程如下(該過程是有先後順序的:函式的形參==>>函式宣告==>>變數宣告):

  • 函式的形參(當進入函式執行上下文時) —— 變數物件的一個屬性,其屬性名就是形參的名字,其值就是實參的值;對於沒有傳遞的引數,其值為undefined

  • 函式宣告(FunctionDeclaration, FD) —— 變數物件的一個屬性,其屬性名和值都是函式物件建立出來的;如果變數物件已經包含了相同名字的屬性,則替換它的值

  • 變數宣告(var,VariableDeclaration) —— 變數物件的一個屬性,其屬性名即為變數名,其值為undefined;如果變數名和已經宣告的函式名或者函式的引數名相同,則不會影響已經存在的屬性。

對於函式的形參沒有什麼可說的,主要看一下函式的宣告以及變數的宣告兩個部分: 1、如何理解函式宣告過程中如果變數物件已經包含了相同名字的屬性,則替換它的值這句話? 看如下這段程式碼:

function foo1(a){
    console.log(a)
    function a(){} 
}
foo1(20)//'function a(){}'
複製程式碼

根據上面的介紹,我們知道VO建立過程中,函式形參的優先順序是高於函式的宣告的,結果是函式體內部宣告的function a(){}覆蓋了函式形參a的宣告,因此最後輸出a是一個function。 2、如何理解變數宣告過程中如果變數名和已經宣告的函式名或者函式的引數名相同,則不會影響已經存在的屬性這句話?

//情景一:與引數名相同
function foo2(a){
    console.log(a)
    var a = 10
}
foo2(20) //'20'

//情景二:與函式名相同
function foo2(){
    console.log(a)
    var a = 10
    function a(){}
}
foo2() //'function a(){}'
複製程式碼

下面是幾個比較有趣的例子,當做加餐小菜,大家細細品味。這裡給出一句話當做參考:

函式宣告比變數優先順序要高,並且定義過程不會被變數覆蓋,除非是賦值

function foo3(a){
    var a = 10
    function a(){}
    console.log(a)
}
foo3(20) //'10'

function foo3(a){
    var a 
    function a(){}
    console.log(a)
}
foo3(20) //'function a(){}'
複製程式碼

AO --- 對應第三個階段

正如我們看到的,建立的過程僅負責處理定義屬性的名字,而並不為他們指派具體的值,當然還有對形參/實參的處理。一旦建立階段完成,執行流進入函式並且啟用/程式碼執行階段,看下函式執行完成後的樣子:

ECObj = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: 'hello',
        b: pointer to function privateB()
    },
    this: { ... }
}
複製程式碼

提升(Hoisting)

對於下面的程式碼,相信很多人都能一眼看出輸出結果,但是卻很少有人能給出為什麼會產生這種輸出結果的解釋。

(function() {
    console.log(typeof foo); // 函式指標
    console.log(typeof bar); // undefined

    var foo = 'hello',
        bar = function() {
            return 'world';
        };

    function foo() {
        return 'hello';
    }
}());
複製程式碼

1、為什麼我們能在foo宣告之前訪問它? 回想在VO的建立階段,我們知道函式在該階段就已經被建立在變數物件中。所以在函式開始執行之前,foo已經被定義了。 2、foo被宣告瞭兩次,為什麼foo顯示為函式而不是undefined或字串? 我們知道,在建立階段,函式宣告是優先於變數被建立的。而且在變數的建立過程中,如果發現VO中已經存在相同名稱的屬性,則不會影響已經存在的屬性。因此,對foo()函式的引用首先被建立在活動物件裡,並且當我們解釋到var foo時,我們看見foo屬性名已經存在,所以程式碼什麼都不做並繼續執行。 3、為什麼bar的值是undefined? bar採用的是函式表示式的方式來定義的,所以bar實際上是一個變數,但變數的值是函式,並且我們知道變數在建立階段被建立但他們被初始化為undefined,這也是為什麼函式表示式不會被提升的原因。

總結:

1、EC分為兩個階段,建立執行上下文和執行程式碼。 2、每個EC可以抽象為一個物件,這個物件具有三個屬性,分別為:作用域鏈Scope,VO|AO(AO,VO只能有一個)以及this。 3、函式EC中的AO在進入函式EC時,確定了Arguments物件的屬性;在執行函式EC時,其它變數屬性具體化。 4、EC建立的過程是由先後順序的:引數宣告 >函式宣告 >變數宣告。

參考

javascript 執行環境,變數物件,作用域鏈

What is the Execution Context & Stack in JavaScript?

函式MDN

相關文章