前端入門18-JavaScript進階之作用域鏈

請叫我大蘇發表於2018-12-06

宣告

本系列文章內容全部梳理自以下幾個來源:

作為一個前端小白,入門跟著這幾個來源學習,感謝作者的分享,在其基礎上,通過自己的理解,梳理出的知識點,或許有遺漏,或許有些理解是錯誤的,如有發現,歡迎指點下。

PS:梳理的內容以《JavaScript權威指南》這本書中的內容為主,因此接下去跟 JavaScript 語法相關的系列文章基本只介紹 ES5 標準規範的內容、ES6 等這系列梳理完再單獨來講講。

正文-作用域鏈

作用域一節中,我們介紹了變數的作用域分兩種:全域性和函式內,且函式內部可以訪問外部函式和全域性的變數。

我們也介紹了,每個函式被呼叫時,會建立一個函式執行上下文 EC,EC 裡有個變數物件 VO 屬性,函式內部操作的區域性變數就是來源於 VO,但 VO 只儲存當前上下文的變數,那麼函式內部又是如何可以訪問到外部函式的變數以及全域性變數的呢?

本篇就是來講講作用域鏈的原理,理清楚這些理所當然的基礎知識的底層原理。

先來看個例子,再看些理論,最後結合理論再回過頭分析例子。

var num = 0;
var sum = -1;
function a() {
    console.log(num);  //1. 輸出什麼
    var b = function () {
        console.log(num++);
    }
    var num = 1;
    b();  //2. 輸出什麼
    console.log(sum); //3. 輸出什麼
    return b;
}

var c = function(num) {
    var d = a();
    d();  //4. 輸出什麼
}

c(10);

當執行了最後一行程式碼時,會有四次輸出,每次都會輸出什麼,可以先想想,然後再繼續看下去,對比下你的答案是否正確。

理論

作用域鏈的原理還是跟執行上下文 EC 有關,執行上下文 EC 有個作用域鏈屬性(Scope chain),作用域鏈是個連結串列結構,連結串列中每個節點是一個 VO,在函式內部巢狀定義新函式就會多產生一個節點,節點越多,函式巢狀定義越深。

由於作用域鏈本質上類似於 VO,也是執行上下文的一個屬性,那麼,它的建立時機自然跟 EC 是一樣的,即:全域性程式碼執行時的解析階段,或者函式程式碼執行時的解析階段。

每呼叫一次函式執行函式體時,js 直譯器會經過兩個階段:解析階段和執行階段;

呼叫函式進入解析階段時主要負責下面的工作:

  1. 建立函式上下文
  2. 建立變數物件
  3. 建立作用域鏈

建立變數物件的過程在作用域一節中講過了,主要就是解析函式體中的宣告語句,建立一個活動物件 AO,並將函式的形參列表、區域性變數、arguments、this、函式物件自身引用新增為活動物件 AO 的屬性,以便函式體程式碼對這些變數的使用。

而建立作用域鏈的過程,主要做了兩件事:

  1. 將當前函式執行上下文的 VO 放到連結串列頭部
  2. 將函式的內部屬性 [[Scope]] 儲存的 VO 連結串列拼接到 VO 後面

ps:[[]] 表示 js 直譯器為物件建立的內部屬性,我們訪問不了,也操作不了。

兩個步驟建立了當前函式的作用域鏈,而當函式體的程式碼操作變數時,優先到作用域鏈的表頭指向的 VO 尋找,找不到時,才到作用域鏈的每個節點的 VO 中尋找。

那麼,函式的內部屬性 [[Scope]] 儲存的 VO 連結串列是哪裡賦值的?

這部分工作也是在解析階段進行的,只不過是外層函式被呼叫時的解析階段。解析階段會去解析當前上下文的程式碼,如果碰到是變數宣告語句,那麼將該變數新增到上下文的 VO 物件中,如果碰到的是函式宣告語句,那麼會將當前上下文的作用域鏈物件引用賦值給函式的內部屬性 [[Scope]]。但如果碰到是函式表示式,那 [[Scope]] 的賦值操作需要等到執行階段。

所以,函式的內部屬性 [[Scope]] 儲存著外層函式的作用域鏈,那麼當每次呼叫函式時,建立函式執行上下文的作用域鏈屬性時,直接拼接外層函式的作用域鏈和當前函式的 VO,就可以達到以函式內部變數優先,依照巢狀層次尋找外層函式變數的規則。

這也是為什麼,明明函式的作用域鏈是當函式呼叫時才建立,但卻依賴於函式定義的位置的原因。因為函式呼叫時,建立的只是當前函式執行上下文的 VO。而函式即使沒被呼叫,只要它的外層函式被呼叫,那麼外層函式建立執行上下文的階段就會順便將其作用域鏈賦值給在它內部定義的函式。

分析

var num = 0;
var sum = -1;
function a() {
    console.log(num);  //1. 輸出:undefined 
    var b = function () {
        console.log(num++);
    }
    var num = 1;
    b();  //2. 輸出:1 
    console.log(sum); //3.輸出:-1 
    return b;
}

var c = function(num) {
    var d = a();
    d();  //4. 輸出:2
}

c(10);

1.當第一次執行全域性程式碼時,首先建立全域性執行上下文EC

前端入門18-JavaScript進階之作用域鏈

所以,當進入執行階段,開始執行全域性程式碼時,全域性變數已經全部新增到全域性 EC 的 VO 裡的,這也就是變數的提前宣告行為,而且對於全域性 EC 來說,它的作用域鏈就是它的 VO,同時,因為解析過程中遇到了函式宣告語句,所以在解析階段就建立了函式 a 物件(a:<function> 表示 a 是一個函式物件),也為函式 a 的內部屬性 [[Scope]] 賦值了全域性 EC 的作用域物件。

2.全域性程式碼執行到 var c = function(num) 語句時

前端入門18-JavaScript進階之作用域鏈

相應的全域性變數在執行階段進行了賦值操作,那麼,賦值操作實際操作的變數就是對全域性 EC 的 VO 裡的相對應變數的操作。

3.當全域性程式碼執行到 c(10),呼叫了函式 c 時

前端入門18-JavaScript進階之作用域鏈

也就是說,在 c 函式內部程式碼執行之前,就為 c 函式的執行建立了 c 函式執行上下文 EC,這個過程中,會將形參變數,函式體宣告的變數都新增到 AO 中(在函式執行上下文中,VO 的具體表現為 AO),同時建立 arguments 物件,確定函式內 this 的指向,由於這裡的普通函式呼叫,所以 this 為全域性物件。

最後,會建立作用域鏈,賦值邏輯用虛擬碼表示:

Scope chain = c函式EC.VO -> c函式內部屬性[[Scope]]

           = c函式EC.VO -> 全域性EC.VO

圖中用陣列形式來表示作用域鏈,實際資料結構並非陣列,所以,對於函式 c 內部程式碼來說,變數的來源依照優先順序在作用域鏈中尋找。

4.當函式 c 內部執行到 var d = a(); 呼叫了 a 函式時

前端入門18-JavaScript進階之作用域鏈

同樣,呼叫 a 函式時,也會為函式 a 的執行建立一個函式執行上下文,a 函式跟 c 函式一樣定義在全域性程式碼中,所以在全域性 EC 的建立過程中,已經為 a 函式的內部屬性 [[Scope]] 賦值了全域性 EC.VO,所以 a 函式 EC 的作用域鏈同樣是:a函式EC.VO -> 全域性EC.VO。

也就是作用域鏈跟函式在哪被呼叫無關,只與函式被定義的地方有關。

5.執行 a 函式內部程式碼

接下去開始執行 a 函式內部程式碼,所以第一行執行 console.log(num) 時,需要訪問到 num 變數,去作用域鏈中依次尋找,首先在 a函式EC.VO 中找到 num:undefined,所以直接使用這個變數,輸出就是 undefined。

6.執行 var b = function()

接下去執行了 var b = function (),建立了一個函式物件賦值給 b,同時對 b 函式的內部屬性 [[Scope]] 賦值為當前執行上下文的作用域鏈,所以 b 函式的內部屬性 [[Scope]]值為:a函式EC.VO -> 全域性EC.VO

7.接下去執行到 b(),呼叫了b函式,所以此時

前端入門18-JavaScript進階之作用域鏈

同樣,也為 b 函式的執行建立了函式執行上下文,而作用域鏈的取值為當前上下文的 VO 拼接上當前函式的內部屬性 [[Scope]] 值,這個值在第 6 步中計算出來。所以,最終 b 函式 EC 的作用域:

b函式EC.VO -> a函式EC.VO -> 全域性EC.VO

8.接下去開始執行函式b的內部程式碼:console.log(num++);

由於使用到 num 變數,開始從作用域鏈中尋找,首先在 b函式EC.VO 中尋找,沒找到;接著到下個作用域節點 a函式EC.VO 中尋找,發現存在 num 這個變數,所以 b 函式內使用的 num 變數是來自於 a 函式內部,而這個變數的取值在上述介紹的第 7 步時已經被賦值為 1 了,所以這裡輸出1。

同時,它還對 num 進行累加1操作,所以當這行程式碼執行結束,a 函式 EC.VO 中的 num 變數已經被賦值為 2 了。

9.b 函式執行結束,將 b 函式 EC 移出 ECS 棧,繼續執行棧頂a函式的程式碼:console.log(sum);

前端入門18-JavaScript進階之作用域鏈

所以這裡需要使用 sum 變數,同樣去作用域鏈中尋找,首先在 a函式EC.VO 中並沒有找到,繼續去 全域性EC.VO 中尋找,發現 sum 變數取值為 -1,所以這裡輸出-1.

10.a 函式也執行結束,將 a 函式 EC 移出 ECS 棧,繼續執行 c 函式內的程式碼:d()

由於 a 函式將函式 b 作為返回值,所以 d() 實際上是呼叫的 b 函式。此時:

前端入門18-JavaScript進階之作用域鏈

這裡又為 d 函式建立了執行上下文,所以到執行階段執行程式碼:console.log(num++); 用到的 num 變數沿著作用域鏈尋找,最後發現是在 a函式EC.VO 中找到,且此時 num 的值為第 8 步結束後的值 2,這裡就輸出 2.

到這裡你可能會疑惑,此時 ECS 棧內,a函式EC 不是被移出掉了嗎,為何 d 函式建立 EC 的作用域鏈中還包括了 a函式EC

這裡就涉及到閉包的概念了,留待下節閉包講解。

總結

如果要從原理角度理解:

  • 變數的作用域機制依賴於執行上下文,全域性程式碼對應全域性執行上下文,函式程式碼對應函式執行上下文
  • 每呼叫一次函式,會建立一次函式執行上下文,這過程中,會解析函式程式碼,建立活動物件 AO,將函式內宣告的變數、形參、arguments、this、函式自身引用都新增到AO中
  • 函式內對各變數的操作實際上是對上個步驟新增到 AO 物件內的這些屬性的操作
  • 建立執行上下文階段中,還會建立上下文的另一個屬性:作用域鏈。對於函式執行上下文,其值為當前上下文的 VO 拼接上當前函式的內部屬性 [[Scope]],對於全域性執行上下文,其值為上下文的 VO。
  • 函式內部屬性 [[Scope]] 儲存著它外層函式的作用域鏈,是在外層函式建立函式物件時,從外層函式的執行上下文的作用域鏈複製過來的值。
  • 總之,JavaScript 中的變數之所以可以在定義後被使用,是因為定義的這些變數都被新增到當前執行上下文 EC 的變數物件 VO 中了,而之所以有全域性和函式內兩種作用域,是因為當前執行上下文 EC 的作用域鏈屬性的支援。也可以說一切都依賴於執行上下文機制。

那麼,如果想通俗的理解:

  • 函式內操作的變數,如果在其內部沒定義,那麼在其外層函式內尋找,如果還沒有找到,繼續往外層的外層函式內尋找,直到外層是全域性物件為止。
  • 這裡的外層函式,指的是針對於函式宣告位置的外層函式,而不是函式呼叫位置的外層函式。作用域鏈只與函式宣告的位置有關係。

大家好,我是 dasu,歡迎關注我的公眾號(dasuAndroidTv),公眾號中有我的聯絡方式,歡迎有事沒事來嘮嗑一下,如果你覺得本篇內容有幫助到你,可以轉載但記得要關注,要標明原文哦,謝謝支援~
dasuAndroidTv2.png

相關文章