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

聲明

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

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

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

正文-作用域鏈

作用域一節(jié)中,我們介紹了變量的作用域分兩種:全局和函數(shù)內,且函數(shù)內部可以訪問外部函數(shù)和全局的變量。

我們也介紹了,每個函數(shù)被調用時,會創(chuàng)建一個函數(shù)執(zhí)行上下文 EC,EC 里有個變量對象 VO 屬性,函數(shù)內部操作的局部變量就是來源于 VO,但 VO 只保存當前上下文的變量,那么函數(shù)內部又是如何可以訪問到外部函數(shù)的變量以及全局變量的呢?

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

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

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);

當執(zhí)行了最后一行代碼時,會有四次輸出,每次都會輸出什么,可以先想想,然后再繼續(xù)看下去,對比下你的答案是否正確。

理論

作用域鏈的原理還是跟執(zhí)行上下文 EC 有關,執(zhí)行上下文 EC 有個作用域鏈屬性(Scope chain),作用域鏈是個鏈表結構,鏈表中每個節(jié)點是一個 VO,在函數(shù)內部嵌套定義新函數(shù)就會多產生一個節(jié)點,節(jié)點越多,函數(shù)嵌套定義越深。

由于作用域鏈本質上類似于 VO,也是執(zhí)行上下文的一個屬性,那么,它的創(chuàng)建時機自然跟 EC 是一樣的,即:全局代碼執(zhí)行時的解析階段,或者函數(shù)代碼執(zhí)行時的解析階段。

每調用一次函數(shù)執(zhí)行函數(shù)體時,js 解釋器會經過兩個階段:解析階段和執(zhí)行階段;

調用函數(shù)進入解析階段時主要負責下面的工作:

  1. 創(chuàng)建函數(shù)上下文
  2. 創(chuàng)建變量對象
  3. 創(chuàng)建作用域鏈

創(chuàng)建變量對象的過程在作用域一節(jié)中講過了,主要就是解析函數(shù)體中的聲明語句,創(chuàng)建一個活動對象 AO,并將函數(shù)的形參列表、局部變量、arguments、this、函數(shù)對象自身引用添加為活動對象 AO 的屬性,以便函數(shù)體代碼對這些變量的使用。

而創(chuàng)建作用域鏈的過程,主要做了兩件事:

  1. 將當前函數(shù)執(zhí)行上下文的 VO 放到鏈表頭部
  2. 將函數(shù)的內部屬性 [[Scope]] 存儲的 VO 鏈表拼接到 VO 后面

ps:[[]] 表示 js 解釋器為對象創(chuàng)建的內部屬性,我們訪問不了,也操作不了。

兩個步驟創(chuàng)建了當前函數(shù)的作用域鏈,而當函數(shù)體的代碼操作變量時,優(yōu)先到作用域鏈的表頭指向的 VO 尋找,找不到時,才到作用域鏈的每個節(jié)點的 VO 中尋找。

那么,函數(shù)的內部屬性 [[Scope]] 存儲的 VO 鏈表是哪里賦值的?

這部分工作也是在解析階段進行的,只不過是外層函數(shù)被調用時的解析階段。解析階段會去解析當前上下文的代碼,如果碰到是變量聲明語句,那么將該變量添加到上下文的 VO 對象中,如果碰到的是函數(shù)聲明語句,那么會將當前上下文的作用域鏈對象引用賦值給函數(shù)的內部屬性 [[Scope]]。但如果碰到是函數(shù)表達式,那 [[Scope]] 的賦值操作需要等到執(zhí)行階段。

所以,函數(shù)的內部屬性 [[Scope]] 存儲著外層函數(shù)的作用域鏈,那么當每次調用函數(shù)時,創(chuàng)建函數(shù)執(zhí)行上下文的作用域鏈屬性時,直接拼接外層函數(shù)的作用域鏈和當前函數(shù)的 VO,就可以達到以函數(shù)內部變量優(yōu)先,依照嵌套層次尋找外層函數(shù)變量的規(guī)則。

這也是為什么,明明函數(shù)的作用域鏈是當函數(shù)調用時才創(chuàng)建,但卻依賴于函數(shù)定義的位置的原因。因為函數(shù)調用時,創(chuàng)建的只是當前函數(shù)執(zhí)行上下文的 VO。而函數(shù)即使沒被調用,只要它的外層函數(shù)被調用,那么外層函數(shù)創(chuàng)建執(zhí)行上下文的階段就會順便將其作用域鏈賦值給在它內部定義的函數(shù)。

分析

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. 當?shù)谝淮螆?zhí)行全局代碼時,首先創(chuàng)建全局執(zhí)行上下文EC:

所以,當進入執(zhí)行階段,開始執(zhí)行全局代碼時,全局變量已經全部添加到全局 EC 的 VO 里的,這也就是變量的提前聲明行為,而且對于全局 EC 來說,它的作用域鏈就是它的 VO,同時,因為解析過程中遇到了函數(shù)聲明語句,所以在解析階段就創(chuàng)建了函數(shù) a 對象(a:<function> 表示 a 是一個函數(shù)對象),也為函數(shù) a 的內部屬性 [[Scope]] 賦值了全局 EC 的作用域對象。

  1. 全局代碼執(zhí)行到 var c = function(num) 語句時:

相應的全局變量在執(zhí)行階段進行了賦值操作,那么,賦值操作實際操作的變量就是對全局 EC 的 VO 里的相對應變量的操作。

  1. 當全局代碼執(zhí)行到 c(10),調用了函數(shù) c 時:

也就是說,在 c 函數(shù)內部代碼執(zhí)行之前,就為 c 函數(shù)的執(zhí)行創(chuàng)建了 c 函數(shù)執(zhí)行上下文 EC,這個過程中,會將形參變量,函數(shù)體聲明的變量都添加到 AO 中(在函數(shù)執(zhí)行上下文中,VO 的具體表現(xiàn)為 AO),同時創(chuàng)建 arguments 對象,確定函數(shù)內 this 的指向,由于這里的普通函數(shù)調用,所以 this 為全局對象。

最后,會創(chuàng)建作用域鏈,賦值邏輯用偽代碼表示:

Scope chain = c函數(shù)EC.VO -> c函數(shù)內部屬性[[Scope]]

           = c函數(shù)EC.VO -> 全局EC.VO

圖中用數(shù)組形式來表示作用域鏈,實際數(shù)據(jù)結構并非數(shù)組,所以,對于函數(shù) c 內部代碼來說,變量的來源依照優(yōu)先級在作用域鏈中尋找。

  1. 當函數(shù) c 內部執(zhí)行到 var d = a(); 調用了 a 函數(shù)時:

同樣,調用 a 函數(shù)時,也會為函數(shù) a 的執(zhí)行創(chuàng)建一個函數(shù)執(zhí)行上下文,a 函數(shù)跟 c 函數(shù)一樣定義在全局代碼中,所以在全局 EC 的創(chuàng)建過程中,已經為 a 函數(shù)的內部屬性 [[Scope]] 賦值了全局 EC.VO,所以 a 函數(shù) EC 的作用域鏈同樣是:a函數(shù)EC.VO -> 全局EC.VO。

也就是作用域鏈跟函數(shù)在哪被調用無關,只與函數(shù)被定義的地方有關。

  1. 執(zhí)行 a 函數(shù)內部代碼

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

  1. 執(zhí)行 var b = function()

接下去執(zhí)行了 var b = function (),創(chuàng)建了一個函數(shù)對象賦值給 b,同時對 b 函數(shù)的內部屬性 [[Scope]] 賦值為當前執(zhí)行上下文的作用域鏈,所以 b 函數(shù)的內部屬性 [[Scope]]值為:a函數(shù)EC.VO -> 全局EC.VO

  1. 接下去執(zhí)行到 b(),調用了b函數(shù),所以此時:

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

b函數(shù)EC.VO -> a函數(shù)EC.VO -> 全局EC.VO

  1. 接下去開始執(zhí)行函數(shù)b的內部代碼:console.log(num++);

由于使用到 num 變量,開始從作用域鏈中尋找,首先在 b函數(shù)EC.VO 中尋找,沒找到;接著到下個作用域節(jié)點 a函數(shù)EC.VO 中尋找,發(fā)現(xiàn)存在 num 這個變量,所以 b 函數(shù)內使用的 num 變量是來自于 a 函數(shù)內部,而這個變量的取值在上述介紹的第 7 步時已經被賦值為 1 了,所以這里輸出1。

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

  1. b 函數(shù)執(zhí)行結束,將 b 函數(shù) EC 移出 ECS 棧,繼續(xù)執(zhí)行棧頂a函數(shù)的代碼:console.log(sum);

所以這里需要使用 sum 變量,同樣去作用域鏈中尋找,首先在 a函數(shù)EC.VO 中并沒有找到,繼續(xù)去 全局EC.VO 中尋找,發(fā)現(xiàn) sum 變量取值為 -1,所以這里輸出-1.

  1. a 函數(shù)也執(zhí)行結束,將 a 函數(shù) EC 移出 ECS 棧,繼續(xù)執(zhí)行 c 函數(shù)內的代碼:d()

由于 a 函數(shù)將函數(shù) b 作為返回值,所以 d() 實際上是調用的 b 函數(shù)。此時:

這里又為 d 函數(shù)創(chuàng)建了執(zhí)行上下文,所以到執(zhí)行階段執(zhí)行代碼:console.log(num++); 用到的 num 變量沿著作用域鏈尋找,最后發(fā)現(xiàn)是在 a函數(shù)EC.VO 中找到,且此時 num 的值為第 8 步結束后的值 2,這里就輸出 2.

到這里你可能會疑惑,此時 ECS 棧內,a函數(shù)EC 不是被移出掉了嗎,為何 d 函數(shù)創(chuàng)建 EC 的作用域鏈中還包括了 a函數(shù)EC

這里就涉及到閉包的概念了,留待下節(jié)閉包講解。

總結

如果要從原理角度理解:

  • 變量的作用域機制依賴于執(zhí)行上下文,全局代碼對應全局執(zhí)行上下文,函數(shù)代碼對應函數(shù)執(zhí)行上下文
  • 每調用一次函數(shù),會創(chuàng)建一次函數(shù)執(zhí)行上下文,這過程中,會解析函數(shù)代碼,創(chuàng)建活動對象 AO,將函數(shù)內聲明的變量、形參、arguments、this、函數(shù)自身引用都添加到AO中
  • 函數(shù)內對各變量的操作實際上是對上個步驟添加到 AO 對象內的這些屬性的操作
  • 創(chuàng)建執(zhí)行上下文階段中,還會創(chuàng)建上下文的另一個屬性:作用域鏈。對于函數(shù)執(zhí)行上下文,其值為當前上下文的 VO 拼接上當前函數(shù)的內部屬性 [[Scope]],對于全局執(zhí)行上下文,其值為上下文的 VO。
  • 函數(shù)內部屬性 [[Scope]] 存儲著它外層函數(shù)的作用域鏈,是在外層函數(shù)創(chuàng)建函數(shù)對象時,從外層函數(shù)的執(zhí)行上下文的作用域鏈復制過來的值。
  • 總之,JavaScript 中的變量之所以可以在定義后被使用,是因為定義的這些變量都被添加到當前執(zhí)行上下文 EC 的變量對象 VO 中了,而之所以有全局和函數(shù)內兩種作用域,是因為當前執(zhí)行上下文 EC 的作用域鏈屬性的支持。也可以說一切都依賴于執(zhí)行上下文機制。

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

  • 函數(shù)內操作的變量,如果在其內部沒定義,那么在其外層函數(shù)內尋找,如果還沒有找到,繼續(xù)往外層的外層函數(shù)內尋找,直到外層是全局對象為止。
  • 這里的外層函數(shù),指的是針對于函數(shù)聲明位置的外層函數(shù),而不是函數(shù)調用位置的外層函數(shù)。作用域鏈只與函數(shù)聲明的位置有關系。

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


dasuAndroidTv2.png
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容