前端高質量知識(四)-JS詳細圖解做用域鏈與閉包


攻克閉包難題


初學JavaScript的時候,我在學習閉包上,走了不少彎路。而此次從新回過頭來對基礎知識進行梳理,要講清楚閉包,也是一個很是大的挑戰。javascript

閉包有多重要?若是你是初入前端的朋友,我沒有辦法直觀的告訴你閉包在實際開發中的無處不在,可是我能夠告訴你,前端面試,必問閉包。面試官們經常用對閉包的瞭解程度來斷定面試者的基礎水平,保守估計,10個前端面試者,至少5個都死在閉包上。前端

但是爲何,閉包如此重要,仍是有那麼多人沒有搞清楚呢?是由於你們不肯意學習嗎?還真不是,而是咱們經過搜索找到的大部分講解閉包的中文文章,都沒有清晰明瞭的把閉包講解清楚。要麼淺嘗輒止,要麼高深莫測,要麼乾脆就直接亂說一通。包括我本身曾經也寫過一篇關於閉包的總結,回頭一看,不忍直視[捂臉]。java

所以本文的目的就在於,可以清晰明瞭得把閉包說清楚,讓讀者老爺們看了以後,就把閉包給完全學會了,而不是似懂非懂。面試

1、做用域與做用域鏈

在詳細講解做用域鏈以前,我默認你已經大概明白了JavaScript中的下面這些重要概念。這些概念將會很是有幫助。算法

  • 基礎數據類型與引用數據類型
  • 內存空間
  • 垃圾回收機制
  • 執行上下文
  • 變量對象與活動對象

若是你暫時尚未明白,能夠去看本系列的前三篇文章,本文文末有目錄連接。爲了講解閉包,我已經爲你們作好了基礎知識的鋪墊。哈哈,真是好大一齣戲。chrome

做用域編程

  • 在JavaScript中,咱們能夠將做用域定義爲一套規則,這套規則用來管理引擎如何在當前做用域以及嵌套的子做用域中根據標識符名稱進行變量查找。設計模式

    這裏的標識符,指的是變量名或者函數名數組

  • JavaScript中只有全局做用域與函數做用域(由於eval咱們平時開發中幾乎不會用到它,這裏不討論)。瀏覽器

  • 做用域與執行上下文是徹底不一樣的兩個概念。我知道不少人會混淆他們,可是必定要仔細區分。

    JavaScript代碼的整個執行過程,分爲兩個階段,代碼編譯階段與代碼執行階段。編譯階段由編譯器完成,將代碼翻譯成可執行代碼,這個階段做用域規則會肯定。執行階段由引擎完成,主要任務是執行可執行代碼,執行上下文在這個階段建立。


過程

做用域鏈

回顧一下上一篇文章咱們分析的執行上下文的生命週期,以下圖。


執行上下文生命週期

咱們發現,做用域鏈是在執行上下文的建立階段生成的。這個就奇怪了。上面咱們剛剛說做用域在編譯階段肯定規則,但是爲何做用域鏈卻在執行階段肯定呢?

之全部有這個疑問,是由於你們對做用域和做用域鏈有一個誤解。咱們上面說了,做用域是一套規則,那麼做用域鏈是什麼呢?是這套規則的具體實現。因此這就是做用域與做用域鏈的關係,相信你們都應該明白了吧。

咱們知道函數在調用激活時,會開始建立對應的執行上下文,在執行上下文生成的過程當中,變量對象,做用域鏈,以及this的值會分別被肯定。以前一篇文章咱們詳細說明了變量對象,而這裏,咱們將詳細說明做用域鏈。

做用域鏈,是由當前環境與上層環境的一系列變量對象組成,它保證了當前執行環境對符合訪問權限的變量和函數的有序訪問。

爲了幫助你們理解做用域鏈,我咱們先結合一個例子,以及相應的圖示來講明。

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)], // 做用域鏈
    this: {}
}

 

是的,你沒有看錯,咱們能夠直接用一個數組來表示做用域鏈,數組的第一項scopeChain[0]爲做用域鏈的最前端,而數組的最後一項,爲做用域鏈的最末端,全部的最末端都爲全局變量對象。

不少人會誤解爲當前做用域與上層做用域爲包含關係,但其實並非。以最前端爲起點,最末端爲終點的單方向通道我認爲是更加貼切的形容。如圖。


做用域鏈圖示

注意,由於變量對象在執行上下文進入執行階段時,就變成了活動對象,這一點在上一篇文章中已經講過,所以圖中使用了AO來表示。Active Object

是的,做用域鏈是由一系列變量對象組成,咱們能夠在這個單向通道中,查詢變量對象中的標識符,這樣就能夠訪問到上一層做用域中的變量了。

2、閉包

對於那些有一點 JavaScript 使用經驗但從未真正理解閉包概念的人來講,理解閉包能夠看做是某種意義上的重生,突破閉包的瓶頸可使你功力大增。

  • 閉包與做用域鏈息息相關;
  • 閉包是在函數執行過程當中被確認。

先直截了當的拋出閉包的定義:當函數能夠記住並訪問所在的做用域(全局做用域除外)時,就產生了閉包,即便函數是在當前做用域以外執行。

簡單來講,假設函數A在函數B的內部進行定義了,而且當函數A在執行時,訪問了函數B內部的變量對象,那麼B就是一個閉包。

很是抱歉以前對於閉包定義的描述有一些不許確,如今已經改過,但願收藏文章的同窗再看到的時候能看到吧,對不起你們了。

基礎進階(一)中,我總結了JavaScript的垃圾回收機制。JavaScript擁有自動的垃圾回收機制,關於垃圾回收機制,有一個重要的行爲,那就是,當一個值,在內存中失去引用時,垃圾回收機制會根據特殊的算法找到它,並將其回收,釋放內存。

而咱們知道,函數的執行上下文,在執行完畢以後,生命週期結束,那麼該函數的執行上下文就會失去引用。其佔用的內存空間很快就會被垃圾回收器釋放。但是閉包的存在,會阻止這一過程。

先來一個簡單的例子。

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() { 
        console.log(a);
    }
    fn = innnerFoo; // 將 innnerFoo的引用,賦值給全局變量中的fn
}
 
function bar() {
    fn(); // 此處的保留的innerFoo的引用
}
 
foo();
bar(); // 2

  

在上面的例子中,foo()執行完畢以後,按照常理,其執行環境生命週期會結束,所佔內存被垃圾收集器釋放。可是經過fn = innerFoo,函數innerFoo的引用被保留了下來,複製給了全局變量fn。這個行爲,致使了foo的變量對象,也被保留了下來。因而,函數fn在函數bar內部執行時,依然能夠訪問這個被保留下來的變量對象。因此此刻仍然可以訪問到變量a的值。

這樣,咱們就能夠稱foo爲閉包。

下圖展現了閉包fn的做用域鏈。


閉包fn的做用域鏈

咱們能夠在chrome瀏覽器的開發者工具中查看這段代碼運行時產生的函數調用棧與做用域鏈的生成狀況。以下圖。


從圖中能夠看出,chrome瀏覽器認爲閉包是foo,而不是一般咱們認爲的innerFoo

在上面的圖中,紅色箭頭所指的正是閉包。其中Call Stack爲當前的函數調用棧,Scope爲當前正在被執行的函數的做用域鏈,Local爲當前的局部變量。

因此,經過閉包,咱們能夠在其餘的執行上下文中,訪問到函數的內部變量。好比在上面的例子中,咱們在函數bar的執行環境中訪問到了函數foo的a變量。我的認爲,從應用層面,這是閉包最重要的特性。利用這個特性,咱們能夠實現不少有意思的東西。

不過讀者老爺們須要注意的是,雖然例子中的閉包被保存在了全局變量中,可是閉包的做用域鏈並不會發生任何改變。在閉包中,能訪問到的變量,仍然是做用域鏈上可以查詢到的變量。

對上面的例子稍做修改,若是咱們在函數bar中聲明一個變量c,並在閉包fn中試圖訪問該變量,運行結果會拋出錯誤。

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() { 
        console.log(c); // 在這裏,試圖訪問函數bar中的c變量,會拋出錯誤
        console.log(a);
    }
    fn = innnerFoo; // 將 innnerFoo的引用,賦值給全局變量中的fn
}
 
function bar() {
    var c = 100;
    fn(); // 此處的保留的innerFoo的引用
}
 
foo();
bar();

  

閉包的應用場景

接下來,咱們來總結下,閉包的經常使用場景。

  • 延遲函數setTimeout

咱們知道setTimeout的第一個參數是一個函數,第二個參數則是延遲的時間。在下面例子中,

function fn() {
    console.log('this is test.')
}
var timer =  setTimeout(fn, 1000);
console.log(timer);

  

執行上面的代碼,變量timer的值,會當即輸出出來,表示setTimeout這個函數自己已經執行完畢了。可是一秒鐘以後,fn纔會被執行。這是爲何?

按道理來講,既然fn被做爲參數傳入了setTimeout中,那麼fn將會被保存在setTimeout變量對象中,setTimeout執行完畢以後,它的變量對象也就不存在了。但是事實上並非這樣。至少在這一秒鐘的事件裏,它仍然是存在的。這正是由於閉包。

很顯然,這是在函數的內部實現中,setTimeout經過特殊的方式,保留了fn的引用,讓setTimeout的變量對象,並無在其執行完畢後被垃圾收集器回收。所以setTimeout執行結束後一秒,咱們任然可以執行fn函數。

  • 柯里化

在函數式編程中,利用閉包可以實現不少炫酷的功能,柯里化算是其中一種。關於柯里化,我會在之後詳解函數式編程的時候仔細總結。

  • 模塊

在我看來,模塊是閉包最強大的一個應用場景。若是你是初學者,對於模塊的瞭解能夠暫時不用放在心上,由於理解模塊須要更多的基礎知識。可是若是你已經有了不少JavaScript的使用經驗,在完全瞭解了閉包以後,不妨藉助本文介紹的做用域鏈與閉包的思路,從新理一理關於模塊的知識。這對於咱們理解各類各樣的設計模式具備莫大的幫助。

(function () {
    var a = 10;
    var b = 20;
 
    function add(num1, num2) {
        var num1 = !!num1 ? num1 : a;
        var num2 = !!num2 ? num2 : b;
 
        return num1 + num2;
    }
 
    window.add = add;
})();
 
add(10, 20);

  

在上面的例子中,我使用函數自執行的方式,建立了一個模塊。方法add被做爲一個閉包,對外暴露了一個公共方法。而變量a,b被做爲私有變量。在面向對象的開發中,咱們經常須要考慮是將變量做爲私有變量,仍是放在構造函數中的this中,所以理解閉包,以及原型鏈是一個很是重要的事情。模塊十分重要,所以我會在之後的文章專門介紹,這裏就暫時很少說啦。


此圖中能夠觀看到當代碼執行到add方法時的調用棧與做用域鏈,此刻的閉包爲外層的自執行函數

爲了驗證本身有沒有搞懂做用域鏈與閉包,這裏留下一個經典的思考題,經常也會在面試中被問到。

利用閉包,修改下面的代碼,讓循環輸出的結果依次爲1, 2, 3, 4, 5

for (var i=1; i<=5; i++) { 
    setTimeout( function timer() {
        console.log(i);
    }, i*1000 );
}

  說明:以上內容轉載自網絡

下面是本人解答的上題,歡迎指正

function add(){
	var i=0;
	return function(){
		i++;
		console.log(i);
	}
}
var result=add();
for(var i=0;i<5;i++){
	setTimeout(function(){result();},i*1000);
}
相關文章
相關標籤/搜索