深入貫徹閉包思想,全面理解JS閉包形成過程

談起閉包,它可是JavaScript兩個核心技術之一(異步和閉包),在面試以及實際應用當中,我們都離不開它們,甚至可以說它們是衡量js工程師實力的一個重要指標。下面我們就羅列閉包的幾個常見問題,從回答問題的角度來理解和定義你們心中的閉包。


問題如下:


1.什麼是閉包?

2.閉包的原理可不可以說一下?

3.你是怎樣使用閉包的?


閉包的介紹


我們先看看幾本書中的大致介紹:


1.閉包是指有權訪問另一個函數作用域中的變量的函數

2.函數對象可以通過作用域關聯起來,函數體內的變量都可以保存在函數作用域內,這在計算機科學文獻中稱為“閉包”,所有的javascirpt函數都是閉包

3.閉包是基於詞法作用域書寫代碼時所產生的必然結果

4.函數可以通過作用域鏈相互關聯起來,函數內部的變量可以保存在其他函數作用域內,這種特性在計算機科學文獻中稱為閉包


可見,它們各有各自的定義,但要說明的意思大同小異。筆者在這之前對它是知其然而不知其所以然,最後用了一天的時間從詞法作用域到作用域鏈的概念再到閉包的形成做了一次總的梳理,發現做人好清晰了……


下面讓我們拋開這些抽象而又晦澀難懂的表述,從頭開始理解,內化最後總結出自己的一段關於閉包的句子。我想這對面試以及充實開發者自身的理論知識非常有幫助。


閉包的構成


詞法作用域


要理解詞法作用域,我們不得不說起JS的編譯階段,大家都知道JS是弱類型語言,所謂弱類型是指不用預定義變量的儲存類型,並不能完全概括JS或與其他語言的區別,在這裡我們引用黃皮書(《你不知道的javascript》)上的給出的解釋編譯語言


編譯語言


編譯語言在執行之前必須要經歷三個階段,這三個階段就像過濾器一樣,把我們寫的代碼轉換成語言內部特定的可執行代碼。就比如我們寫的代碼是var a = 1;,而JS引擎內部定義的格式是var,a,=,1 那在編譯階段就需要把它們進行轉換。


這只是一個比喻,而事實上這只是在編譯階段的第一個階段所做的事情。下面我們概括一下,三個階段分別做了些什麼。


1.分詞/詞法分析

(Tokenizing/Lexing)


這就是我們上面講的一樣,其實我們寫的代碼就是字符串,在編譯的第一個階段裡,把這些字符串轉成詞法單元(toekn),詞法單元我們可以想象成我們上面分解的表達式那樣。(注意這個步驟有兩種可能性,當前這屬於分詞,而詞法分析,會在下面和詞法作用域一起說。)


2.解析/語法分析

(Parsing)


在有了詞法單元之後,JS還需要繼續分解代碼中的語法以便為JS引擎減輕負擔(總不能在引擎運行的過程中讓它承受這麼多輪的轉換規則吧?) ,通過詞法單元生成了一個抽象語法樹(Abstract Syntax Tree),它的作用是為JS引擎構造出一份程序語法樹,我們簡稱為AST。


這時我們不禁聯想到Dom樹(扯得有點遠),沒錯它們都是樹,以var,a,=,1為例,它會以層為單元劃分他們,例如:頂層有一個stepA裡面包含著"v",stepA下面有一個stepB,stepB中含有"a",就這樣一層一層嵌套下去……


3.代碼生成

(raw code)


這個階段主要做的就是拿AST來生成一份JS語言內部認可的代碼(這是語言內部制定的,並不是二進制哦),在生成的過程中,編譯器還會詢問作用域的問題,還是以var a = 1;為例,編譯器首先會詢問作用域,當前有沒有變量a,如果有則忽略,否則在當前作用域下創建一個名叫a的變量。


詞法階段


哈哈,終於到了詞法階段,是不是看了上面的三大階段,甚是懵逼,沒想到js還會有這樣繁瑣的經歷?


其實,上面的概括只是所有編譯語言的最基本的流程,對於我們的JS而言,它在編譯階段做的事情可不僅僅是那些,它會提前為js引擎做一些性能優化等工作,總之,編譯器把所有髒活累活全乾遍了。


要說到詞法階段這個概念,我們還要結合上面未結的分詞/詞法分析階段來說……


詞法作用域是發生在編譯階段的第一個步驟當中,也就是分詞/詞法分析階段。它有兩種可能,分詞和詞法分析,分詞是

無狀態的,而詞法分析是有狀態的。


那我們如何判斷有無狀態呢?以var a = 1為例,如果詞法單元生成器在判斷a是否為一個獨立的詞法單元時,調用的是有狀態的解析規則(生成器不清楚它是否依賴於其他詞法單元,所以要進一步解析)。


反之,如果它不用生成器判斷,是一條不用被賦予語意的代碼(暫時可以理解為不涉及作用域的代碼,因為js內部定義什麼樣的規則我們並不清楚),那就被列入分詞中了。


這下我們知道,如果詞法單元生成器拿不準當前詞法單元是否為獨立的,就進入詞法分析,否則就進入分詞階段。


沒錯,這就是理解詞法作用域及其名稱來歷的基礎。


簡單的說,詞法作用域就是定義在詞法階段的作用域。詞法作用域就是你編寫代碼時,變量和塊級作用域寫在哪裡決定的。


當詞法解析器(這裡只當作是解析詞法的解析器,後續會有介紹)處理代碼時,會保持作用域不變(除動態作用域)。


在這一小節中,我們只需要瞭解:


1.詞法作用域是什麼?

2.詞法階段中 分詞/詞法分析的概念?

3.它們對詞法作用域的形成有哪些影響?


這節有兩個個忽略掉的知識點(詞法解析器,動態作用域),因主題限制沒有寫出來,以後有機會為大家介紹。下面開始作用域鏈。


作用域鏈


執行環境


執行環境定義了變量或函數有權訪問的其他數據。


環境棧可以暫時理解為一個數組(JS引擎的一個儲存棧)。


在web瀏覽器中,全局環境即window是最外層的執行環境,而每個函數也都有自己的執行環境,當調用一個函數的時候,函數會被推入到一個環境棧中,當它以及依賴成員都執行完畢之後,棧就將其環境彈出。


先看一個圖 !

深入貫徹閉包思想,全面理解JS閉包形成過程

環境棧也有人稱做它為函數調用棧(都是一回事,只不過後者的命名方式更傾向於函數),這裡我們統稱為棧。位於環境棧中最外層是window,它只有在關閉瀏覽器時才會從棧中銷燬。而每個函數都有自己的執行環境。


到這裡我們應該知道:


1.每個函數都有一個與之對應的執行環境。

2.當函數執行時,會把當前函數的環境押入環境棧中,把當前函數執行完畢,則摧毀這個環境。

3.window全局對象時棧中對外層的(相對於圖片來說,就是最下面的)。

4.函數調用棧與環境棧的區別。這兩者就好像是JS中原始類型和基礎類型|引用類型與對象類型與複合類型。汗!


變量對象與活動對象


執行環境,所謂環境我們不難聯想到房子這一概念。沒錯,它就像是一個大房子,它不是獨立的,它會為了完成更多的任務而攜帶或關聯其他的概念。


每個執行環境都有一個表示變量的對象——變量對象,這個對象裡儲存著在當前環境中所有的變量和函數


變量對象對於執行環境來說很重要,它在函數執行之前被創建。它包含著當前函數中所有的參數,變量,函數。這個創建變量對象的過程實際就是函數內數據(函數參數、內部變量、內部函數)初始化的過程。


在沒有執行當前環境之前,變量對象中的屬性都不能訪問!但是進入執行階段之後,變量對象轉變為了活動對象,裡面的屬性都能被訪問了,然後開始進行執行階段的操作。所以活動對象實際就是變量對象在真正執行時的另一種形式。

深入貫徹閉包思想,全面理解JS閉包形成過程

在fun函數的環境中,有三個變量對象(壓入環境棧之前),首先是arguments,變量n與函數toStr,壓入環境棧之後(在執行階段),他們都屬於fun的活動對象。活動對象在最開始時,只包含一個變量,即argumens對象。


到這裡我們應該知道:


1.每個執行環境有一個與之對應的變量對象

2.環境中定義的所有變量和函數都保存在這個對象裡。

3.對於函數,執行前的初始化階段叫變量對象,執行中就變成了活動對象


作用域鏈


當代碼在一個環境中執行時,會創建變量對象的一個作用域鏈。用數據格式表達作用域鏈的結構如下。


[{當前環境的變量對象},{外層變量對象},{外層的外層的變量對象}, {window全局變量對象}] 每個數組單元就是作用域鏈的一塊,這個塊就是我們的變量對象。


作用於鏈的前端,始終都是當前執行的代碼所在環境的變量對象。全局執行環境的變量對象也始終都是鏈的最後一個對象。

深入貫徹閉包思想,全面理解JS閉包形成過程

再來看上面這個簡單的例子,我們可以先思考一下,每個執行環境下的變量對象都是什麼? 這兩個函數它們的變量對象分別都是什麼?


我們以fun為例,當我們調用它時,會創建一個包含arguments,a,b的活動對象,對於函數而言,在執行的最開始階段它的活動對象裡只包含一個變量,即arguments(當執行流進入,再創建其他的活動對象)。


在活動對象中,它依然表示當前參數集合。對於函數的活動對象,我們可以想象成兩部分,一個是固定的arguments對象,另一部分是函數中的局部變量。而在此例中,a和b都被算入是局部變量中,即便a已經包含在了arguments中,但他還是屬於。


有沒有發現在環境棧中,所有的執行環境都可以組成相對應的作用域鏈。我們可以在環境棧中非常直觀的拼接成一個相對作用域鏈。

深入貫徹閉包思想,全面理解JS閉包形成過程

下面我們大致說下這段代碼的執行流程:


1.在創建foo的時候,作用域鏈已經預先包含了一個全局對象,並保存在內部屬性[[ Scope ]]當中。

2.執行foo函數,創建執行環境與活動對象後,取出函數的內部屬性[[Scope]]構建當前環境的作用域鏈(取出後,只有全局變量對象,然後此時追加了一個它自己的活動對象)。

3.執行過程中遇到了fun,從而繼續對fun使用上一步的操作。

4.fun執行結束,移出環境棧。foo因此也執行完畢,繼續移出。

5.javaScript監聽到foo沒有被任何變量所引用,開始實施垃圾回收機制,清空佔用內存。


作用域鏈其實就是引用了當前執行環境的變量對象的指針列表,它只是引用,但不是包含。因為它的形狀像鏈條,它的執行過程也非常符合,所以我們都稱之為作用域鏈,而當我們弄懂了這其中的奧秘,就可以拋開這種形式上的束縛,從原理上出發。


到這裡我們應該知道:


1.什麼是作用域鏈。

2.作用域鏈的形成流程。

3.內部屬性 [[Scope]] 的概念。


使用閉包


從頭到尾,我們把涉及到的技術點都過了一遍,寫的不太詳細也有些不準確,因為沒有經過事實的論證,我們只大概瞭解了這個過程概念。


涉及的理論充實了,那麼現在我們就要使用它了。先上幾個最簡單的計數器例子:

深入貫徹閉包思想,全面理解JS閉包形成過程

相信看到這裡,很多同學都預測出它們執行的結果。它們都有一個小特點,就是實現的過程都返回一個函數對象,返回的函數中帶有對外部變量的引用。


為什麼非要返回一個函數呢?

因為函數可以提供一個執行環境,在這個環境中引用其它環境的變量對象時,後者不會被js內部回收機制清除掉。


因而當你在當前執行環境中訪問它時,它還是在內存當中的。這裡千萬不要把環境棧和垃圾回收這兩個很重要的過程搞混了,環境棧通俗點就是調用棧,調用移入,調用後移出,垃圾回收則是監聽引用。


為什麼可以一直遞增呢?

上面已經說了,返回的匿名函數構成了一個執行環境,這個執行環境的作用域鏈下的變量對象並不是它自己的,而是其他環境中的。


正因為它引用了別人,js才不會對它進行垃圾回收。所以這個值一直存在,每次執行都會對他進行遞增。


性能會不會有損耗?

就拿這個功能來說,我們為了實現它使用了閉包,但是當我們使用結束之後呢?不要忘了還有一個變量對其他變量對象的引用。這個時候我們為了讓js可以正常回收它,可以手動賦值為null。


以第一個為例:

深入貫徹閉包思想,全面理解JS閉包形成過程

我們再來看上面的代碼,第一個是返回了一個函數,後兩個類似於方法,他們都能非常直接的表明閉包的實現,其實更值得我們注意的是閉包實現的多樣性。


閉包面試題

一、用屬性的存取器實現一個閉包計時器


見上例;


二、看代碼,猜輸出

深入貫徹閉包思想,全面理解JS閉包形成過程

這道題的難點除了閉包,還有遞歸等過程,筆者當時答這道題的時候也答錯了,真是噁心。下面我們來分析一下。


首先說閉包部分,fun返回了一個可用.操作符訪問的fun方法(這樣說比較好理解)。在返回的方法中它的活動對象可以分為 [arguments[m],m,n,fun]。在問題中,使用了變量引用(接收了返回的函數)了這些活動對象。


在返回的函數中,有一個來自外部的實參m,拿到實參後再次調用並返回fun函數。這次執行fun時附帶了兩個參數,第一個是剛才的外部實參(也就是調用時自己賦的),注意第二個是上一次的fun第一個參數。


第一個,把返回的fun賦給了變量a,然後再單獨調用返回的fun,在返回的fun函數中第二個參數n正好把我們上一次通過調用外層fun的參數又拿回來了,然而它並不是鏈式的,可見我們調用了四次。


但這四次,只有第一次調用外部的fun時傳進去的,後面通過a調用的內部fun並不會影響到o的輸出,所以仔細琢磨一下不難看出最後結果是undefine 0,0,0。


第二個是鏈式調用,乍一看,和第一個沒有區別啊,只不過第一個是多了一個a的中間變量,可千萬不要被眼前的所迷惑呀!!!

深入貫徹閉包思想,全面理解JS閉包形成過程

看上面的返回,第二的不同在於,第二次調用它再次接收了{fun:return fun}的返回值,然而在第三次調用時候它就是外部的fun函數了。理解了第一個和第二個我相信就知道了第三個。最後的結果就不說了,可以自己測一下。


三、看代碼,猜輸出

深入貫徹閉包思想,全面理解JS閉包形成過程

上例中兩段代碼,第一個我們在面試過程中一定碰到過,這是一個異步的問題,它不是一個閉包,但我們可以通過閉包的方式解決。


第二段代碼會輸出1- 5,因為每循環一次回調中都引用了參數i(也就是活動對象),而在上一個循環中,每個回調引用的都是一個變量i,其實我們還可以用其他更簡便的方法來解決。

深入貫徹閉包思想,全面理解JS閉包形成過程

let為我們創建局部作用域,它和我們剛才使用的閉包解決方案是一樣的,只不過這是js內部創建臨時變量,我們不用擔心它引用過多造成內存溢出問題。


總結


我們知道了:


本章涉及的範圍稍廣,主要是想讓大家更全面的認識閉包,那麼到現在你知道了什麼呢?我想每個人心中都有了答案。


1.什麼是閉包?


閉包是依據詞法作用域產生的必然結果。通過變相引用函數的活動對象導致其不能被回收,然而形成了依然可以用引用訪問其作用域鏈的結果。

深入貫徹閉包思想,全面理解JS閉包形成過程

有些說法把這種方式稱之為閉包,並說閉包可以避免全局汙染,首先大家在這裡應該有一個自己的答案,以上這個例子是一個閉包嗎?


避免全局汙染不假,但閉包談不上,它最多算是在全局執行環境之上新建了一個二級作用域,從而避免了在全局上定義其他變量。切記它不是真正意義的閉包。


2.閉包的原理可不可以說一下?


結合我們上面講過的,它的根源起始於詞法階段,在這個階段中形成了詞法作用域。最終根據調用環境產生的環境棧來形成了一個由變量對象組成的作用域鏈,當一個環境沒有被js正常垃圾回收時,我們依然可以通過引用來訪問它原始的作用域鏈。


3.你是怎樣使用閉包的?


使用閉包的場景有很多,筆者最近在看函數式編程,可以說在js中閉包其實就是函數式的一個重要基礎,舉個不完全函數的栗子。

深入貫徹閉包思想,全面理解JS閉包形成過程

上面這個栗子,就是保留對fun函數的活動對象(arguments[]),當然在我們日常開發中還有更復雜的情況,這需要很多函數塊,到那個時候,才能顯出我們閉包的真正威力。


文章到這裡大概講完了,都是我自己的薄見和書上的一些內容,希望能對大家有點影響吧,當然這是正面的……如果哪裡文中有描述不恰當或大家有更好的見解還望指出,謝謝。


題外話


讀一篇文章或者看幾頁書,也不過是幾分鐘的事情。但是要理解的話需要個人內化的過程,從輸入到理解到內化再到輸出,這是一個非常合理的知識體系。


我想不僅僅對於閉包,它對任何知識來說都是一樣的重要,當某些知識融入到我們身體時,需要把它輸出出去,告訴別人。這不僅僅是“奉獻”精神,也是自我提高的過程。


分享到:


相關文章: