初學JavaScript的時候,我在學習閉包上,走了很多彎路。而這次重新回過頭來對基礎知識進行梳理,要講清楚閉包,也是一個非常大的挑戰。
閉包有多重要?如果你是初入前端的朋友,我沒有辦法直觀的告訴你閉包在實際開發中的無處不在,但是我可以告訴你,前端面試,必問閉包。面試官們常常用對閉包的瞭解程度來判定面試者的基礎水平,保守估計,10個前端面試者,至少5個都死在閉包上。
可是為什麼,閉包如此重要,還是有那麼多人沒有搞清楚呢?是因為大家不願意學習嗎?還真不是,而是我們通過搜尋找到的大部分講解閉包的中文文章,都沒有清晰明瞭的把閉包講解清楚。要麼淺嘗輒止,要麼高深莫測,要麼乾脆就直接亂說一通。包括我自己曾經也寫過一篇關於閉包的總結,回頭一看,不忍直視[捂臉]。
因此本文的目的就在於,能夠清晰明瞭得把閉包說清楚,讓讀者老爺們看了之後,就把閉包給徹底學會了,而不是似懂非懂。
一、作用域與作用域鏈
在詳細講解作用域鏈之前,我預設你已經大概明白了JavaScript中的下面這些重要概念。這些概念將會非常有幫助。
- 基礎資料型別與引用資料型別
- 記憶體空間
- 垃圾回收機制
- 執行上下文
- 變數物件與活動物件
如果你暫時還沒有明白,可以去看本系列的前三篇文章,本文文末有目錄連結。為了講解閉包,我已經為大家做好了基礎知識的鋪墊。哈哈,真是好大一齣戲。
作用域
- 在JavaScript中,我們可以將作用域定義為一套規則,這套規則用來管理引擎如何在當前作用域以及巢狀的子作用域中根據識別符號名稱進行變數查詢。
這裡的識別符號,指的是變數名或者函式名
- JavaScript中只有全域性作用域與函式作用域(因為eval我們平時開發中幾乎不會用到它,這裡不討論)。
- 作用域與執行上下文是完全不同的兩個概念。我知道很多人會混淆他們,但是一定要仔細區分。
JavaScript程式碼的整個執行過程,分為兩個階段,程式碼編譯階段與程式碼執行階段。編譯階段由編譯器完成,將程式碼翻譯成可執行程式碼,這個階段作用域規則會確定。執行階段由引擎完成,主要任務是執行可執行程式碼,執行上下文在這個階段建立。
作用域鏈
回顧一下上一篇文章我們分析的執行上下文的生命週期,如下圖。
我們發現,作用域鏈是在執行上下文的建立階段生成的。這個就奇怪了。上面我們剛剛說作用域在編譯階段確定規則,可是為什麼作用域鏈卻在執行階段確定呢?
之所有有這個疑問,是因為大家對作用域和作用域鏈有一個誤解。我們上面說了,作用域是一套規則,那麼作用域鏈是什麼呢?是這套規則的具體實現。所以這就是作用域與作用域鏈的關係,相信大家都應該明白了吧。
我們知道函式在呼叫啟用時,會開始建立對應的執行上下文,在執行上下文生成的過程中,變數物件,作用域鏈,以及this的值會分別被確定。之前一篇文章我們詳細說明了變數物件,而這裡,我們將詳細說明作用域鏈。
作用域鏈,是由當前環境與上層環境的一系列變數物件組成,它保證了當前執行環境對符合訪問許可權的變數和函式的有序訪問。
為了幫助大家理解作用域鏈,我我們先結合一個例子,以及相應的圖示來說明。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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的執行上下文可如下表示。
1 2 3 4 5 |
innerTestEC = { VO: {...}, // 變數物件 scopeChain: [VO(innerTest), VO(test), VO(global)], // 作用域鏈 this: {} } |
是的,你沒有看錯,我們可以直接用一個陣列來表示作用域鏈,陣列的第一項scopeChain[0]為作用域鏈的最前端,而陣列的最後一項,為作用域鏈的最末端,所有的最末端都為全域性變數物件。
很多人會誤解為當前作用域與上層作用域為包含關係,但其實並不是。以最前端為起點,最末端為終點的單方向通道我認為是更加貼切的形容。如圖。
注意,因為變數物件在執行上下文進入執行階段時,就變成了活動物件,這一點在上一篇文章中已經講過,因此圖中使用了AO來表示。Active Object
是的,作用域鏈是由一系列變數物件組成,我們可以在這個單向通道中,查詢變數物件中的識別符號,這樣就可以訪問到上一層作用域中的變數了。
二、閉包
對於那些有一點 JavaScript 使用經驗但從未真正理解閉包概念的人來說,理解閉包可以看作是某種意義上的重生,突破閉包的瓶頸可以使你功力大增。
- 閉包與作用域鏈息息相關;
- 閉包是在函式執行過程中被確認。
先直截了當的丟擲閉包的定義:當函式可以記住並訪問所在的作用域(全域性作用域除外)時,就產生了閉包,即使函式是在當前作用域之外執行。
簡單來說,假設函式A在函式B的內部進行定義了,並且當函式A在執行時,訪問了函式B內部的變數物件,那麼B就是一個閉包。
非常抱歉之前對於閉包定義的描述有一些不準確,現在已經改過,希望收藏文章的同學再看到的時候能看到吧,對不起大家了。
在基礎進階(一)中,我總結了JavaScript的垃圾回收機制。JavaScript擁有自動的垃圾回收機制,關於垃圾回收機制,有一個重要的行為,那就是,當一個值,在記憶體中失去引用時,垃圾回收機制會根據特殊的演算法找到它,並將其回收,釋放記憶體。
而我們知道,函式的執行上下文,在執行完畢之後,生命週期結束,那麼該函式的執行上下文就會失去引用。其佔用的記憶體空間很快就會被垃圾回收器釋放。可是閉包的存在,會阻止這一過程。
先來一個簡單的例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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的作用域鏈。
我們可以在chrome瀏覽器的開發者工具中檢視這段程式碼執行時產生的函式呼叫棧與作用域鏈的生成情況。如下圖。
在上面的圖中,紅色箭頭所指的正是閉包。其中Call Stack為當前的函式呼叫棧,Scope為當前正在被執行的函式的作用域鏈,Local為當前的區域性變數。
所以,通過閉包,我們可以在其他的執行上下文中,訪問到函式的內部變數。比如在上面的例子中,我們在函式bar的執行環境中訪問到了函式foo的a變數。個人認為,從應用層面,這是閉包最重要的特性。利用這個特性,我們可以實現很多有意思的東西。
不過讀者老爺們需要注意的是,雖然例子中的閉包被儲存在了全域性變數中,但是閉包的作用域鏈並不會發生任何改變。在閉包中,能訪問到的變數,仍然是作用域鏈上能夠查詢到的變數。
對上面的例子稍作修改,如果我們在函式bar中宣告一個變數c,並在閉包fn中試圖訪問該變數,執行結果會丟擲錯誤。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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的第一個引數是一個函式,第二個引數則是延遲的時間。在下面例子中,
1 2 3 4 5 |
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的使用經驗,在徹底瞭解了閉包之後,不妨藉助本文介紹的作用域鏈與閉包的思路,重新理一理關於模組的知識。這對於我們理解各種各樣的設計模式具有莫大的幫助。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
(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中,因此理解閉包,以及原型鏈是一個非常重要的事情。模組十分重要,因此我會在以後的文章專門介紹,這裡就暫時不多說啦。
為了驗證自己有沒有搞懂作用域鏈與閉包,這裡留下一個經典的思考題,常常也會在面試中被問到。
利用閉包,修改下面的程式碼,讓迴圈輸出的結果依次為1, 2, 3, 4, 5
1 2 3 4 5 |
for (var i=1; i<=5; i++) { setTimeout( function timer() { console.log(i); }, i*1000 ); } |
關於作用域鏈的與閉包我就總結完了,雖然我自認為我是說得非常清晰了,但是我知道理解閉包並不是一件簡單的事情,所以如果你有什麼問題,可以在評論中問我。你也可以帶著從別的地方沒有看懂的例子在評論中留言。大家一起學習進步。