寫在開頭
從我學習Javascript的第一天開始,就聽說理解閉包是一件極其重要的事。看了JS高級程序設計以后,大概了解了一些,但當朋友問我“你知道什么是閉包嗎”,我還是一頭霧水。所以今天就想結合例子來講講:到底如何理解閉包?
本文是基于stackoverflow上的一篇高票答案所寫的,在部分翻譯的基礎上加上了自己的理解,并且寫的盡量基礎,希望能讓大家對閉包有更好的認識。
概念
官方定義
- 閉包是引用了自由變量的函數。這個被引用的自由變量將和這個函數一同存在,即使已經離開了創造它的環境也不例外。 --維基百科
- 閉包是指那些能夠訪問自由變量的函數(變量在本地使用,但定義在一個封閉的作用域中)。 --MDN
理解性概念
- 當在一個函數內定義另外一個函數就會產生閉包,但當你使用構造函數來創建函數時,不會產生閉包。
- 閉包可以理解為函數的局部變量集合,只是這些局部變量在函數返回后會繼續存在。
- 閉包是一種特殊的對象,它由兩部分構成:1.函數 2.創建該函數時的環境(由閉包創建時在作用域中的任何局部變量組成)
預備知識
詞法作用域
在Javascript中,變量的作用域是由它在源代碼中所處位置決定的,并且嵌套的函數可以訪問到其外層作用域中聲明的變量。
舉個簡單的例子:
function init(){
var name="mario"; //name 是一個局部變量
function displayName(){ //這是一個內部函數,僅可以在init()之內使用
alert(name); //這里使用了在父函數(init)中聲明的變量
}
displayName();
}
init();//輸出 mario
頭等函數 (first-class function)
頭等函數是指在程序設計語言中,函數被當做頭等公民。這意味著函數可以作為別的函數的參數、函數的返回值,賦值給變量或者存儲在數據結構中。Javascript支持頭等函數。
用例子理解閉包
例1:最典型的閉包
function sayHello(sentence){
var text=sentence;
var say=function(){
alert(text);
}
return say;
}
var output=sayHello("Hello world");
output(); //輸出“Hello World”
上述代碼中,我們將"Hello world"當做參數傳到函數sayHello中,局部變量text
會被賦值為"Hello world",根據以往的習慣我們會認為這個text的值是無法被函數外部獲取到的,因為在一些編程語言如c語言中,函數中的局部變量僅在函數的執行期間可用。然而,結果顯示在函數結束后我們仍能輸出"Hello world",從這里我們可以看出:我們能從sayHello()函數外部獲取到局部變量text的值,它沒有隨著sayHello()的結束而被銷毀。形成這個結果的原因是output變成了一個閉包,它由say函數和閉包創建時所存在的text變量所組成。
例2:閉包中的局部變量是引用而非拷貝
function sayHello(sentence){
var text=sentence;
var textCopy=text;
var say=function(){
alert(text);
alert(textCopy);
}
text="I changed!";
return say;
}
var output=sayHello("Hello world");
output(); //輸出 “I changed”,"Hello world"
這里textCopy是text的一個拷貝,如果閉包中的局部變量也是拷貝的話,那么output()輸出時應該會連續輸出兩條"Hello world"。但由于局部變量實際上是引用,所以text改變了,輸出值也會改變,但textCopy因為是一個拷貝,它的值是不會受text的影響的。通俗的說,閉包中的text就是sayHello()中的text。
例3:不同函數綁定同一個閉包
var outputNum,addNum,setNum;
function originNum(){
var num=0;
outputNum=function(){
alert(num);
}
addNum=function(){
num++;
}
setNum=function(x){
num=x;
}
}
originNum();
addNum();
outputNum();//輸出1
setNum(5);
outputNum();//輸出5
var oldNum=outputNum(); //儲存結果
originNum(); //第二次調用主函數originNum()
outputNum();//輸出0
oldnum();//輸出5
這里將三個匿名函數傳遞給了outputNum,addNum,setNum這三個全局變量,這三個函數共享了同一個閉包(包含同一個局部變量num),所以任何對num的操作都在三個函數中的反映是一致的。
值得注意的是,當第二次調用originNum()時,生成了一個新的閉包,因為outputNum,addNum和setNum都是全局變量,所以當新生成的函數傳遞給他們后,原來的函數都被取代了,它們在開始共享新的閉包,所以num的值重新變為了初始值0。如果想要儲存之前的值,必須用別的變量來儲存。(JS中,當你在一個函數內部定義另一個函數,每次主函數被調用時,內部函數都會被重新生成。) 這一點會在例6中更深入討論。
例4:循環中的閉包
function buildList(list) {
var result = [];
for (var i = 0; i < list.length; i++) {
var item = 'item' + i;
console.log(item + ' ' + list[i]);//輸出 item0 a; item1 b; item2 c;
result.push( function() {console.log(item + ' ' + list[i])} );
}
console.log("i= "+i); //輸出3
return result;
}
function testList() {
var fnlist = buildList([" a"," b"," c"]);
for (var j = 0; j < fnlist.length; j++) {
fnlist[j]();
}
}
testList()//輸出三個“item2 undefined”
上面這段代碼初看有點難以理解,讓我們一步一步來解析。首先理解第一個函數buildList中的輸出信息應該是比較容易的,它在testList中被調用時,list中傳入三個字符串" a"," b"," c"(abc之前的空格只是為了輸出美觀),所以會依次輸出item0 a
; item1 b
; item2 c
;同時i輸出3是因為在離開for循環之前的最后一步還是會執行依次i++,這些都和閉包沒有關系,都是前提。真正涉及到閉包的代碼是fnlist[j]()
,隨著j的取值依次為0,1,2,會調用buildList中被push進result的函數function(){console.log("item"+i+list[i])}
,這三個函數分別是在i為0,1,2的時候被push進result里的,那么為什么當調用fnlist[0],fnlist[1]和fnlist[2]的時候輸出結果都為“item2 undefined”呢?其實究其根本,這個例子還是和例2例3遵循著相同的規律,首先,這還是一個不同函數綁定同一個閉包的例子,只不過例3沒有被放在一個for循環里,因為例3的每個函數做的事不同,而這里三個不同的匿名函數做了相同的事,同樣的,他們也綁定了同一個閉包,那么與例3相同,他們共享著閉包中的局部變量i
。其次,如例2中所提到的,閉包中的局部變量是引用而非拷貝。簡單的說,無論之前i或者item等于多少,這里我們獲取閉包中的局部變量時只能獲取到它最后被賦予的值,因為之前的值都被覆蓋了。所以當i在buildList中循環過后變成了3之后,fnList[j]調用三次函數時所獲取到的i都是i=3,因為i在進行完buildList()函數之后停留在了3,而list[3]是沒有被定義的,(我們只定義了list[0]=1;list[1]=2;list[2]=3)所以是undefined
。同理,item在經過buildList()之后停留在了"item2",所以這就是為什么最后會輸出3次"item2 undefined"。所以總結的話,例4還是運用了例2和例3的概念,但因為用了循環加上我們對循環中的匿名函數不是那么熟悉,所以會感覺有點復雜,這類題目也經常出現在前端的筆試題里,如果大家還是有不理解的地方希望能留言討論。
例5:每一次函數調用都會創建新的閉包
var outputNum,addNum,setNum;
function originNum(x){
var num=10;
addNum=function(){
num++;
}
setNum=function(x){
num=x;
}
return function(){
console.log(num+x);
}
}
fn1=originNum(3);
fn2=originNum(5);
fn1(); //輸出13
fn2(); //輸出15
addNum();
fn1(); //輸出13
fn2(); //輸出16
這個例子我在例3的基礎上細微的變化,但這里重點想表達的是每一次函數調用都會創建新的閉包,上述fn1和fn2為兩次調用,它們創建了2個閉包。所以fn1()和fn2()的輸出結果是不同的,因為這兩個function中的num是兩個不同閉包中的局部變量。但為什么例3也是同樣調用兩次originNum,在第二次調用時之前的num就被看似“覆蓋”了呢?實際上在第二次調用之后,第一次調用所創建的num依然還存在,只不過它“失寵”了,全局函數addNum中的num不再用它而是用新生成的num了,所以當我們在這里調用addNum()以后,fn1()中的num不再增加,但依然存在,仍舊可以輸出13,fn2()中的num作為目前的“寵兒”,被加上了1。而之所以會有“失寵”這個概念,只是因為addNum作為全局變量,只能保存一個函數,把新的函數傳遞給它必定就意味著會取代舊的函數。例5和例3本質上是一樣的,只是想表達的側重點不同。
例6 (可以跳過)
function sayAlice() {
var say = function() { console.log(alice); }
var alice = 'Hello Alice';
return say;
}
sayAlice()();
原文中舉這個例子想表達的是閉包會包含所有在函數結束之前被聲明的局部變量。這里alice即使被聲明在匿名函數之后,仍然可以被獲取到。但我認為形成這個的主要原因是JS存在變量提升的特性,所以這個例子和例子最典型的閉包例子其實一模一樣。
總結
引用知乎“寸志”的回答,Javascript閉包的本質源于兩點,詞法作用域和函數當做值傳遞。我們上述的每一個例子中,都可以看到函數被當做值返回或者傳遞,我們可以將這個返回的函數看作一個通道,這個通道因為可以訪問這個函數詞法作用域中的變量(即我們經常提到的局部變量),所以將函數所需要的數據結構保存了下來。(所以局部變量不會隨著創建它的函數結束而被銷毀)所以,閉包的形成很簡單,在執行過程完畢后,返回函數或者將函數得以保留下來,即形成閉包。
個人感覺閉包真的是一個很抽象的概念,看一些官方的解釋經常能看的一頭霧水,所以個人認為借助例子來理解是一種很好的學習方法。上文中的解釋有些并不嚴謹,但本意是希望能用一種比較容易讓人理解的方式來傳達閉包的特性。如果有我沒講明白或者有理解錯誤的地方,歡迎留言討論。有英文閱讀能力的同學,可以閱讀一下stackoverflow的原文,雖然我覺得它的例子舉的不算太好,但是說的很詳細。知乎“寸志”的回答讓我在寫完這么長篇例子后腦子有點混亂的情況下一下子清晰了起來,它的回答可以算是對以上6個例子很好的總結,也很容易理解,推薦去原文看一下。