天天看點

深入了解JavaScript執行上下文、函數堆棧、提升的概念

首先明确幾個概念:

-

EC

:函數執行環境(或執行上下文),Execution Context

-

ECS

:執行環境棧,Execution Context Stack

-

VO

:變量對象,Variable Object

-

AO

:活動對象,Active Object

-

scope chain

:作用域鍊

想當初自己看到這幾個概念的時候是一(m)臉(d)懵(z)逼(z),但是不得不說這幾個概念對以後深入學習JS有很大的幫助。來不及解釋了,趕緊上車~

EC(執行上下文)

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

那什麼是可執行代碼呢?

可執行代碼的類型

全局代碼(

Global code

  • 這種類型的代碼是在”程式”級處理的:例如加載外部的js檔案或者本地

    <script></script>

    标簽内的代碼。全局代碼不包括任何function體内的代碼。 這個是預設的代碼運作環境,一旦代碼被載入,引擎最先進入的就是這個環境。
函數代碼(

Function code

  • 任何一個函數體内的代碼,但是需要注意的是,具體的函數體内的代碼是不包括内部函數的代碼。
Eval代碼(

Eval code

  • eval内部的代碼

這裡僅僅引入EC這個概念,後面還有關于EC建立細節的介紹。

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執行上下文、函數堆棧、提升的概念

當浏覽器首次載入你的腳本,它将預設進入全局執行上下文。如果,你在你的全局代碼中調用一個函數,你程式的時序将進入被調用的函數,并建立一個新的執行上下文,并将新建立的上下文壓入執行棧的頂部。

如果你調用目前函數内部的其他函數,相同的事情會在此上演。代碼的執行流程進入内部函數,建立一個新的執行上下文并把它壓入執行棧的頂部。浏覽器總會執行位于棧頂的執行上下文,一旦目前上下文函數執行結束,它将被從棧頂彈出,并将上下文控制權交給目前的棧。這樣,堆棧中的上下文就會被依次執行并且彈出堆棧,直到回到全局的上下文。

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

深入了解JavaScript執行上下文、函數堆棧、提升的概念

VO(變量對象)/AO(活動對象)

這裡為什麼要用一個

/

呢?按照字面了解,AO其實就是被激活的VO,兩個其實是一個東西。下面引用知乎上的一段話,幫助了解一下。原文連結

變量對象

(Variable object)

是說JS的執行上下文中都有個對象用來存放執行上下文中可被通路但是不能被

delete

的函數标示符、形參、變量聲明等。它們會被挂在這個對象上,對象的屬性對應它們的名字對象屬性的值對應它們的值但這個對象是規範上或者說是引擎實作上的不可在JS環境中通路到活動對象

激活對象

(Activation object)

有了變量對象存每個上下文中的東西,但是它什麼時候能被通路到呢?就是每進入一個執行上下文時,這個執行上下文兒中的變量對象就被激活,也就是該上下文中的函數标示符、形參、變量聲明等就可以被通路到了

EC

建立的細節

1、建立階段【當函數被調用,但未執行任何其内部代碼之前】
  • 建立作用域鍊(Scope Chain)
  • 建立變量,函數和參數。
  • 求”this“的值
2、執行階段
  • 初始化變量的值和函數的引用,解釋/執行代碼。

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

ECObj: {
    scopeChain: { /* 變量對象(variableObject)+ 所有父級執行上下文的變量對象*/ }, 
    variableObject: { /*函數 arguments/參數,内部變量和函數聲明 */ }, 
    this: {} 
}
           

解釋器執行代碼的僞邏輯

1、查找調用函數的代碼。

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

  • 初始化作用域鍊
  • 建立變量對象:
    • 建立arguments對象,檢查上下文,初始化參數名稱和值并建立引用的複制。
    • 掃描上下文的函數聲明(而非函數表達式):
      • 為發現的每一個函數,在變量對象上建立一個屬性——确切的說是函數的名字——其有一個指向函數在記憶體中的引用。
      • 如果函數的名字已經存在,引用指針将被重寫。
    • 掃描上下文的變量聲明:
      • 為發現的每個變量聲明,在變量對象上建立一個屬性——就是變量的名字,并且将變量的值初始化為undefined
      • 如果變量的名字已經在變量對象裡存在,将不會進行任何操作并繼續掃描。
    • 求出上下文内部“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

繼續閱讀