1. 程式人生 > >JavaScript中的執行上下文、作用域鏈、變數物件

JavaScript中的執行上下文、作用域鏈、變數物件

主要是理清執行上下文、作用域鏈和變數物件的關係

1.執行上下文

簡而言之,執行上下文就是當前 JavaScript 程式碼被解析和執行時所在環境的抽象概念, JavaScript 中執行任何的程式碼都是在執行上下文中執行。

執行上下文型別:

  • 全域性執行上下文
  • 函式執行上下文
  • Eval函式執行上下文

執行棧:

執行棧,在其他程式語言中也被叫做呼叫棧,具有 LIFO(後進先出)結構,用於儲存在程式碼執行期間建立的所有執行上下文。

當 JavaScript 引擎首次讀取你的指令碼時,它會建立一個全域性執行上下文並將其推入當前的執行棧。每當發生一個函式呼叫

,引擎都會為該函式建立一個新的執行上下文並將其推到當前執行棧的頂端。

引擎會執行執行上下文在執行棧頂端的函式,當此函式執行完成後,其對應的執行上下文將會從執行棧中彈出,上下文控制權將移到當前執行棧的下一個執行上下文。

讓我們通過下面的程式碼示例來理解這一點:

let a = 'Hello World!';

function first() {  
  console.log('Inside first function');  
  second();  
  console.log('Again inside first function');  
}

function second() {  
  console.log('Inside second function');  
}

first();  
console.log('Inside Global Execution Context');

在這裡插入圖片描述

當上述程式碼在瀏覽器中載入時,JavaScript 引擎會建立一個全域性執行上下文並且將它推入當前的執行棧。當呼叫 first() 函式時,JavaScript 引擎為該函式建立了一個新的執行上下文並將其推到當前執行棧的頂端。

當在 first() 函式中呼叫 second() 函式時,Javascript 引擎為該函式建立了一個新的執行上下文並將其推到當前執行棧的頂端。當 second() 函式執行完成後,它的執行上下文從當前執行棧中彈出,上下文控制權將移到當前執行棧的下一個執行上下文,即 first() 函式的執行上下文。

當 first() 函式執行完成後,它的執行上下文從當前執行棧中彈出,上下文控制權將移到全域性執行上下文。一旦所有程式碼執行完畢,Javascript 引擎把全域性執行上下文從執行棧中移除。

2.執行上下文是如何被建立的(以及其中的作用域和變數物件)

到目前為止,我們已經看到了 JavaScript 引擎如何管理執行上下文,現在就讓我們來理解 JavaScript 引擎是如何建立執行上下文的。

執行上下文分兩個階段建立:1)建立階段; 2)執行階段

執行上下文建立過程:

1、在任意的 JavaScript 程式碼被執行前,執行上下文處於建立階段。在建立階段中總共發生了三件事情:

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

2、執行階段

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

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

在這裡插入圖片描述

我們以例項來講解函式執行上下文的VO/AO:

VO

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

此時的函式上下文:

ECObj = {
   scopChain: {...}, // 作用域鏈
   variableObject: {  //變數物件VO
   	arguments: {
         	0: 22,
         	length: 1
         },
         	i: 22,
         	c: pointer to function c()
         	a: undefined,
         	b: undefined
        },
        this: { ... } //this
}

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

  • 函式的形參(當進入函式執行上下文時) —— 變數物件的一個屬性,其屬性名就是形參的名字,其值就是實參的值;對於沒有傳遞的引數,其值為undefined
  • 函式宣告(FunctionDeclaration, FD) —— 變數物件的一個屬性,其屬性名和值都是函式物件創建出來的;如果變數物件已經包含了相同名字的屬性,則替換它的值
  • 變數宣告(var,VariableDeclaration) —— 變數物件的一個屬性,其屬性名即為變數名,其值為undefined;如果變數名和已經宣告的函式名或者函式的引數名相同,則不會影響已經存在的屬性。

總結:有順序,是函式形參、函式宣告和變數宣告,而且函式宣告相同會覆蓋,變數宣告相同會忽略。

AO

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

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

3.作用域鏈

在執行上下文的作用域中查詢變數的過程被稱為識別符號解析(indentifier resolution),這個過程的實現依賴於函式內部另一個同執行上下文相關聯的物件——作用域鏈。**作用域鏈是一個有序連結串列,其包含著用以告訴JavaScript解析器一個識別符號到底關聯著哪一個變數的物件。**而每一個執行上下文都有其自己的作用域鏈Scope。

所以,作用域鏈是執行上下文和變數物件之間橋樑。

作用域鏈Scope其實就是對執行上下文EC中的變數物件VO|AO有序訪問的連結串列。能按順序訪問到VO|AO,就能訪問到其中存放的變數和函式的定義。

作用域鏈,是由當前環境與上層環境的一系列變數物件組成,它保證了當前執行環境對符合訪問許可權的變數和函式的有序訪問。

作用域鏈:就是一個區域,包含了變數、常量、函式等定義資訊和賦值資訊,以及這個區域內程式碼書寫的結構資訊,作用域可以巢狀。

作用域其實由兩部分組成:
1.記錄作用域變數資訊(變數、常量和函式等統稱為變數)和程式碼結構資訊的東西,稱為Environment Record。
2.一個引用__outer__,這引用指向當前作用域的父作用域。全域性作用域的__outer__為null。

Scope定義如下:Scope = AO|VO + [[Scope]]

其中,AO始終在Scope的最前端,不然為啥叫活躍物件呢。即:Scope = [AO].concat([[Scope]]);

當查詢變數的時候,會先從當前上下文的變數物件中查詢,如果沒有找到,就會從父級(詞法層面上的父級)執行上下文的變數物件中查詢,一直找到全域性上下文的變數物件,也就是全域性物件。這樣由多個執行上下文的變數物件構成的連結串列就叫做作用域鏈。


這裡主要來講Scope和[[scope]]

Scope就是作用域鏈,作用域鏈Scope其實就是對執行上下文EC中的變數物件VO|AO有序訪問的連結串列。能按順序訪問到VO|AO,就能訪問到其中存放的變數和函式的定義。

Scope定義如下:Scope = AO|VO + [[Scope]]

其中,AO始終在Scope的最前端,不然為啥叫活躍物件呢。即:Scope = [AO].concat([[Scope]]);

因為[[scope]]在函式建立的時候,就儲存在函式中,所以作用域鏈在函式建立時就已經有了。

那麼[[scope]]是什麼呢?

[[Scope]]是一個包含了所有上層變數物件的分層鏈,它屬於當前函式上下文,並在函式建立的時候,儲存在函式中。

[[Scope]]是在函式建立的時候儲存起來的——靜態的(不變的),只有一次並且一直都存在——直到函式銷燬。 比方說,哪怕函式永遠都不能被呼叫到,[[Scope]]屬性也已經儲存在函式物件上了。

var x=10;
function f1(){
  var y=20;
  function f2(){
    return x+y;
  }
}

以上示例中,f2的[[scope]]屬性可以表示如下:

var x=10;
function f1(){
  var y=20;
  function f2(){
    return x+y;
  }
}

以上示例中,f2的[[scope]]屬性可以表示如下:

f2.[[scope]]=[
  f2OuterContext.VO
]

而f2的外部EC的所有上層變數物件包括了f1的活躍物件f1Context.AO,再往外層的EC,就是global物件了。
所以,具體我們可以表示如下:

f2.[[scope]]=[
  f1Context.AO,
  globalContext.VO
]

對於EC執行環境是函式來說,那麼它的Scope表示為:

functionContext.Scope=functionContext.AO+function.[[scope]]

注意,以上程式碼的表示,也體現了[[scope]]和Scope的差異,Scope是EC的屬性,而[[scope]]則是函式的靜態屬性。

(由於AO|VO在進入執行上下文和執行程式碼階段不同,所以,這裡及以後Scope的表示,我們都預設為是執行程式碼階段的Scope,而對於靜態屬性[[scope]]而言,則是在函式宣告時就建立了)

對於以上的程式碼EC,我們可以給出其Scope的表示:

exampelEC={
  Scope:[
    f2Context.AO+f2.[[scope]],
    f1.context.AO+f1.[[scope]],
    globalContext.VO
  ]
}

下面,讓我們以一個函式的建立和啟用兩個時期來講解作用域鏈是如何建立和變化的。

函式建立

函式的作用域在函式定義的時候就決定了。

這是因為函式有一個內部屬性 [[scope]],當函式建立的時候,就會儲存所有父變數物件到其中,你可以理解 [[scope]] 就是所有父變數物件的層級鏈,但是注意:[[scope]] 並不代表完整的作用域鏈!

 function foo() {
    function bar() {
        ...
    }
}

函式建立時,各自的[[scope]]為:

foo.[[scope]] = [
  globalContext.VO
];

bar.[[scope]] = [
    fooContext.AO,
    globalContext.VO
];

函式啟用

當函式啟用時,進入函式上下文,建立 VO/AO 後,就會將活動物件新增到作用鏈的前端。(VO/AO新增到作用前端)

這時候執行上下文的作用域鏈,我們命名為 Scope:

Scope = [AO].concat([[Scope]]);

至此,作用域鏈建立完畢。


我們再來看一個示例:

var a = 20;

function test() {
    var b = a + 10;

    function innerTest() {
        var c = 10;
        return b + c;
    }

    return innerTest();
}

test();

在上面的例子中,全域性,函式test,函式innerTest的執行上下文先後建立。我們設定他們的變數物件分別為VO(global),VO(test), VO(innerTest)。而innerTest的作用域鏈,則同時包含了這三個變數物件,所以innerTest的執行上下文可如下表示。

innerTestEC = {
    VO: {...},  // 變數物件
    scopeChain: [VO(innerTest), VO(test), VO(global)], // 作用域鏈
}

我們可以直接用一個數組來表示作用域鏈,陣列的第一項scopeChain[0]為作用域鏈的最前端,而陣列的最後一項,為作用域鏈的最末端,所有的最末端都為全域性變數物件。
很多人會誤解為當前作用域與上層作用域為包含關係,但其實並不是。以最前端為起點,最末端為終點的單方向通道我認為是更加貼切的形容。如圖。

在這裡插入圖片描述

注意,因為變數物件在執行上下文進入執行階段時,就變成了活動物件,這一點在上一篇文章中已經講過,因此圖中使用了AO來表示。Active Object

是的,作用域鏈是由一系列變數物件組成,我們可以在這個單向通道中,查詢變數物件中的識別符號,這樣就可以訪問到上一層作用域中的變量了。

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

AO其實就是被啟用的VO,兩個其實是一個東西。

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

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

5.程式碼示例捋清執行上下文、作用域鏈和變數物件

var scope = "global scope";
function checkscope(){
    var scope2 = 'local scope';
    return scope2;
}
checkscope();

執行過程如下:

1.checkscope 函式被建立,儲存作用域鏈到內部屬性[[scope]]【作用域鏈

checkscope.[[scope]] = [
    globalContext.VO
];

2.執行 checkscope 函式,建立 checkscope 函式執行上下文,checkscope 函式執行上下文被壓入執行上下文棧【執行上下文

ECStack = [
    checkscopeContext,
    globalContext
];

3.checkscope 函式並不立刻執行,開始做準備工作,第一步:複製函式[[scope]]屬性建立作用域鏈【作用域鏈

checkscopeContext = {
    Scope: checkscope.[[scope]],
}

4.第二步:用 arguments 建立活動物件,隨後初始化活動物件,加入形參、函式宣告、變數宣告【變數物件

checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    }
}

5.第三步:將活動物件壓入 checkscope 作用域鏈頂端

checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: [AO, [[Scope]]]
}

6.準備工作做完,開始執行函式,隨著函式的執行,修改 AO 的屬性值

checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: 'local scope'
    },
    Scope: [AO, [[Scope]]]
}

7.查詢到 scope2 的值,返回後函式執行完畢,函式上下文從執行上下文棧中彈出

ECStack = [
    globalContext
];

6.變數和函式提前

(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,這也是為什麼函式表示式不會被提升的原因。

7.總結

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

參考文章:

https://segmentfault.com/a/1190000009035308
https://juejin.im/entry/599e949251882524472239c4
https://juejin.im/entry/599e949251882524472239c4
https://juejin.im/post/5ac301d151882510fd3fcf3a
https://www.jianshu.com/p/a6d37c77e8db
https://www.jianshu.com/p/21a16d44f150
https://segmentfault.com/a/1190000009522006#articleHeader3
http://www.cnblogs.com/wangfupeng1988/tag/javascript/default.html?page=2
https://zhuanlan.zhihu.com/p/48590085
https://segmentfault.com/a/1190000009041008
https://segmentfault.com/a/1190000009035308
https://segmentfault.com/a/1190000000533094#articleHeader8
https://www.jianshu.com/p/3114a3e0a818
https://www.jianshu.com/p/05641baa5134
https://www.jianshu.com/p/765a6de0b649