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

聲明

本系列文章內容所有梳理自如下幾個來源:javascript

做爲一個前端小白,入門跟着這幾個來源學習,感謝做者的分享,在其基礎上,經過本身的理解,梳理出的知識點,或許有遺漏,或許有些理解是錯誤的,若有發現,歡迎指點下。前端

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

正文-做用域鏈

做用域一節中,咱們介紹了變量的做用域分兩種:全局和函數內,且函數內部能夠訪問外部函數和全局的變量。git

咱們也介紹了,每一個函數被調用時,會建立一個函數執行上下文 EC,EC 裏有個變量對象 VO 屬性,函數內部操做的局部變量就是來源於 VO,但 VO 只保存當前上下文的變量,那麼函數內部又是如何能夠訪問到外部函數的變量以及全局變量的呢?github

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

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

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

因此,當進入執行階段,開始執行全局代碼時,全局變量已經所有添加到全局 EC 的 VO 裏的,這也就是變量的提早聲明行爲,並且對於全局 EC 來講,它的做用域鏈就是它的 VO,同時,由於解析過程當中遇到了函數聲明語句,因此在解析階段就建立了函數 a 對象(a:<function> 表示 a 是一個函數對象),也爲函數 a 的內部屬性 [[Scope]] 賦值了全局 EC 的做用域對象。

2.全局代碼執行到 var c = function(num) 語句時

相應的全局變量在執行階段進行了賦值操做,那麼,賦值操做實際操做的變量就是對全局 EC 的 VO 裏的相對應變量的操做。

3.當全局代碼執行到 c(10),調用了函數 c 時

也就是說,在 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 函數時

一樣,調用 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函數,因此此時

一樣,也爲 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);

因此這裏須要使用 sum 變量,一樣去做用域鏈中尋找,首先在 a函數EC.VO 中並無找到,繼續去 全局EC.VO 中尋找,發現 sum 變量取值爲 -1,因此這裏輸出-1.

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

因爲 a 函數將函數 b 做爲返回值,因此 d() 其實是調用的 b 函數。此時:

這裏又爲 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

相關文章
相關標籤/搜索