前端基礎進階(四):詳細圖解作用域鏈與閉包

作者:波同學

www.jianshu.com/p/21a16d44f150

如有好文章投稿,請點擊 → 這裡了解詳情

攻克閉包難題

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

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

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

因此本文的目的就在於,能夠清晰明了得把閉包說清楚,讓讀者老爺們看了之後,就把閉包給徹底學會了,而不是似懂非懂。

一、作用域與作用域鏈

在詳細講解作用域鏈之前,我默認你已經大概明白了JavaScript中的下面這些重要概念。這些概念將會非常有幫助。

  • 基礎數據類型與引用數據類型

  • 內存空間

  • 垃圾回收機制

  • 執行上下文

  • 變數對象與活動對象

  • 如果你暫時還沒有明白,可以去看本系列的前三篇文章,本文文末有目錄鏈接。為了講解閉包,我已經為大家做好了基礎知識的鋪墊。哈哈,真是好大一齣戲。

    作用域

  • 在JavaScript中,我們可以將作用域定義為一套規則,這套規則用來管理引擎如何在當前作用域以及嵌套的子作用域中根據標識符名稱進行變數查找。

    這裡的標識符,指的是變數名或者函數名

  • JavaScript中只有全局作用域與函數作用域(因為eval我們平時開發中幾乎不會用到它,這裡不討論)。

  • 作用域與執行上下文是完全不同的兩個概念。我知道很多人會混淆他們,但是一定要仔細區分。

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

  • 作用域鏈

    回顧一下上一篇文章我們分析的執行上下文的生命周期,如下圖。

    執行上下文生命周期

    我們發現,作用域鏈是在執行上下文的創建階段生成的。這個就奇怪了。上面我們剛剛說作用域在編譯階段確定規則,可是為什麼作用域鏈卻在執行階段確定呢?

    之所有有這個疑問,是因為大家對作用域和作用域鏈有一個誤解。我們上面說了,作用域是一套規則,那麼作用域鏈是什麼呢?是這套規則的具體實現。所以這就是作用域與作用域鏈的關係,相信大家都應該明白了吧。

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

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

    為了幫助大家理解作用域鏈,我我們先結合一個例子,以及相應的圖示來說明。

    vara=20;

    functiontest(){

    varb=a+10;

    functioninnerTest(){

    varc=10;

    returnb+c;

    }

    returninnerTest();

    }

    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

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

    二、閉包

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

  • 閉包與作用域鏈息息相關;

  • 閉包是在函數執行過程中被確認。

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

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

    非常抱歉之前對於閉包定義的描述有一些不準確,現在已經改過,希望收藏文章的同學再看到的時候能看到吧,對不起大家了。

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

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

    先來一個簡單的例子。

    varfn=null;

    functionfoo(){

    vara=2;

    functioninnnerFoo(){

    console.log(a);

    }

    fn=innnerFoo;// 將 innnerFoo的引用,賦值給全局變數中的fn

    }

    functionbar(){

    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中試圖訪問該變數,運行結果會拋出錯誤。

    varfn=null;

    functionfoo(){

    vara=2;

    functioninnnerFoo(){

    console.log(c);// 在這裡,試圖訪問函數bar中的c變數,會拋出錯誤

    console.log(a);

    }

    fn=innnerFoo;// 將 innnerFoo的引用,賦值給全局變數中的fn

    }

    functionbar(){

    varc=100;

    fn();// 此處的保留的innerFoo的引用

    }

    foo();

    bar();

    閉包的應用場景

    接下來,我們來總結下,閉包的常用場景。

  • 延遲函數setTimeout

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

    functionfn(){

    console.log("this is test.")

    }

    vartimer=setTimeout(fn,1000);

    console.log(timer);

    執行上面的代碼,變數timer的值,會立即輸出出來,表示setTimeout這個函數本身已經執行完畢了。但是一秒鐘之後,fn才會被執行。這是為什麼?

    按道理來說,既然fn被作為參數傳入了setTimeout中,那麼fn將會被保存在setTimeout變數對象中,setTimeout執行完畢之後,它的變數對象也就不存在了。可是事實上並不是這樣。至少在這一秒鐘的事件里,它仍然是存在的。這正是因為閉包。

    很顯然,這是在函數的內部實現中,setTimeout通過特殊的方式,保留了fn的引用,讓setTimeout的變數對象,並沒有在其執行完畢後被垃圾收集器回收。因此setTimeout執行結束後一秒,我們任然能夠執行fn函數。

  • 柯里化

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

  • 模塊

  • 在我看來,模塊是閉包最強大的一個應用場景。如果你是初學者,對於模塊的了解可以暫時不用放在心上,因為理解模塊需要更多的基礎知識。但是如果你已經有了很多JavaScript的使用經驗,在徹底了解了閉包之後,不妨藉助本文介紹的作用域鏈與閉包的思路,重新理一理關於模塊的知識。這對於我們理解各種各樣的設計模式具有莫大的幫助。

    (function(){

    vara=10;

    varb=20;

    functionadd(num1,num2){

    varnum1= !!num1?num1:a;

    varnum2= !!num2?num2:b;

    returnnum1+num2;

    }

    window.add=add;

    })();

    add(10,20);

    在上面的例子中,我使用函數自執行的方式,創建了一個模塊。方法add被作為一個閉包,對外暴露了一個公共方法。而變數a,b被作為私有變數。在面向對象的開發中,我們常常需要考慮是將變數作為私有變數,還是放在構造函數中的this中,因此理解閉包,以及原型鏈是一個非常重要的事情。模塊十分重要,因此我會在以後的文章專門介紹,這裡就暫時不多說啦。

    此圖中可以觀看到當代碼執行到add方法時的調用棧與作用域鏈,此刻的閉包為外層的自執行函數

    為了驗證自己有沒有搞懂作用域鏈與閉包,這裡留下一個經典的思考題,常常也會在面試中被問到。

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

    for(vari=1;i<>5;i++){

    setTimeout(functiontimer(){

    console.log(i);

    },i*1000);

    }

    關於作用域鏈的與閉包我就總結完了,雖然我自認為我是說得非常清晰了,但是我知道理解閉包並不是一件簡單的事情,所以如果你有什麼問題,可以在評論中問我。你也可以帶著從別的地方沒有看懂的例子在評論中留言。大家一起學習進步。

    看完本文有收穫?請轉發分享給更多人

    關注「前端大全」,提升前端技能


    推薦閱讀:

    Python中的閉包是什麼?
    柯里化的前生今世(六):詞法作用域和閉包
    閉包的應用Ajax

    TAG:作用 | 閉包 | 圖解 | 基礎 |