JavaScript閉包
1.函式在JavaScript中的地位
在介紹閉包之前,可以先聊聊函式在JavaScript中的地位,因為閉包的存在是與函式息息相關的。
- JavaScript之所以可以稱之為支援頭等函式的程式語言,是因為JavaScript中函式是一等公民;
- 函式不僅在JavaScript中扮演著重要的角色,而且可以使用的非常靈活;
- 函式不僅可以作為另一個函式的引數,也可以作為另一個函式的返回值;
- 這樣使用的函式也稱之為高階函式,像JS的陣列中就實現了許多高階函式(map、filter、reduce等);
2.JavaScript中閉包的定義
閉包的概念出現於60年代,最早實現閉包的程式是Scheme,那麼就可以理解為什麼JavaScript中有閉包了,因為JavaScript中大量的設計是源自於Scheme的。而在不同的地方對JavaScript閉包的定義是不一樣的,但是整體核心還是一致的,只是用不同的話來描述JavaScript閉包,以下是摘抄自三個地方的定義。
維基百科中對閉包的定義:
- 閉包(Closure),又稱詞法閉包(Lexical Closure)或函式閉包(function closures),是在支援頭等函式的程式語言中,實現詞法繫結的一種技術;
- 閉包在實現上是一個結構體,它儲存了一個函式和一個關聯的環境(相當於一個符號查詢表);
- 閉包跟函式最大的區別在於,當捕捉閉包的時候,它的自由變數會在捕捉時被確定,這樣即使脫離了捕捉時的上下文,它也能照常執行;
MDN中對閉包定義:
- 一個函式和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函式被引用包圍),這樣的組合就是閉包(closure);
- 也就是說,閉包讓你可以在一個內層函式中訪問到其外層函式的作用域;
- 在JavaScript中,每當建立一個函式,閉包就會在函式建立的同時被建立出來;
《JavaScript高階程式設計》中對閉包的定義:
- 閉包指的是那些引用了另一個函式作用域中變數的函式,通常是在巢狀函式中實現的;
對閉包定義的總結:
- 以上對閉包的三種定義,都提到了函式、環境、作用域和變數,總結為就是:一個函式,如果它可以訪問外層作用域的自由變數,那麼這個函式就是一個閉包;
- 閉包由兩部分組成:內層函式+可以訪問的外層自由變數;
- 廣義角度:JavaScript中的函式都是閉包(都可以形成閉包);
- 狹義角度:JavaScript中的一個函式,如果訪問了外層作用域的變數,那麼它是一個閉包;
3.閉包是如何形成的?
看了一大堆閉包的定義,那麼到底什麼情況下就形成了閉包呢?
(1)產生閉包的條件:簡單來說,滿足以下幾個條件就可以說產生了閉包。
- 函式巢狀;
- 內層函式引用了外層函式作用域中的變數;
- 外層函式執行;
(2)常見的閉包。
-
將一個函式作為另一個函式返回值,例如:
function foo() { var name = 'foo' return function bar() { console.log(name) } } var fn = foo() fn()
-
將一個函式作為實參傳遞另一個函式,例如:
function showDelay(msg) { setTimeout(function() { console.log(msg) }) } showDelay('我形成了閉包')
4.閉包的訪問和執行過程
下面介紹閉包在訪問和執行過程中的記憶體表現,進一步深入對閉包的瞭解,以如下程式碼為例:
示例程式碼:
function foo() {
var name = 'foo'
return function bar() {
console.log(name)
}
}
var fn = foo()
fn()
-
首先,在執行全域性程式碼之前,會在記憶體中建立一個全域性物件(GO),將全域性執行上下文壓入棧中,這時的fn還未被賦值;
-
當執行到
var fn = foo()
時,在呼叫foo之前建立foo的活動物件(AO),建立foo函式執行上下文,並將其壓入棧中,接著執行foo函式,執行完成後fn指向bar函式記憶體地址; -
foo函式執行完成後,foo函式執行上下文會彈出棧,而按道理foo的活動物件(AO)是需要被銷燬的,那到底有沒有銷燬,我們接著看;
-
接著執行
fn()
,因為fn是指向bar函式的,執行之前會先建立bar的活動物件(AO),然後執行console.log(name)
,而name會先去自己的AO中查詢,發現沒有找到就會去到上層作用域(父級作用域)中查詢,最終找到foo
並列印,這裡bar函式的上層作用域就是foo函式的作用域對應foo的活動物件(AO); -
bar函式執行完成後,bar函式的執行上下文彈出棧,對應bar的活躍物件(AO)被銷燬,而foo的活躍物件(AO)還一直存留在記憶體中;
-
但是bar函式的父級作用域是在什麼時候確定的呢?
- 在編譯bar函式時就已經確定了bar函式的父級作用域——foo的活動物件(AO)早在編譯時就加入到了bar函式的作用域鏈中;
- 為什麼執行完foo函式後還可以訪問其name變數,就可以回答上面的問題了,foo的活動物件(AO)是沒有被銷燬的;
- 因為bar函式的作用域鏈中依然對foo的活動物件(AO)有引用,導致其不能正常銷燬;
根據上面閉包的訪問和執行過程結合閉包的定義做一個總結:
-
在維基百科中定義的“閉包在實現上是一個結構體,它儲存了一個函式和一個關聯的環境(相當於一個符號查詢表)”,以及MDN中定義的“一個函式和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函式被引用包圍),這樣的組合就是閉包(closure)”,其函式和關聯的環境、函式和對其周圍狀態的引用,對應的就是上面的bar函式和上層作用域中的name;
-
而對於維基百科中提到的“閉包跟函式最大的區別在於,當捕捉閉包的時候,它的自由變數會在捕捉時被確定,這樣即使脫離了捕捉時的上下文,它也能照常執行”,也就是在捕捉bar函式時,同時捕捉到對name這個自由變數的引用,執行完foo函式後,將bar函式賦值給fn,最後執行fn時,也是能正常訪問到name的;
-
瞭解了其訪問執行過程後,可以發現本應該被銷燬的foo的活躍物件(AO),在程式碼執行完後最終沒能被銷燬,而這樣的情況稱之為記憶體洩露,下面就來談談閉包的記憶體洩露;
-
如果對上面的執行過程不清楚,可以先看看這篇文章:JavaScript的執行過程(深入執行上下文、GO、AO、VO和VE等概念)
5.閉包的記憶體洩露
閉包會保留它們包含函式的作用域,所以比其它函式更佔用記憶體,而過渡使用閉包可能導致記憶體過度佔用,也就是記憶體洩露,而除了記憶體洩露這個概念還有一個記憶體溢位的概念,下面就先了解一下這兩者的區別和關係:
- 記憶體溢位:程式執行出現錯誤,當程式執行需要的記憶體超過了剩餘的記憶體時,就會丟擲記憶體溢位的錯誤(比如,死迴圈)。
- 記憶體洩露:佔用的記憶體沒有及時釋放,記憶體洩露積累過多就容易導致記憶體溢位(比如,意外的全域性變數、沒有及時清理計時器或回撥、閉包)
那具體怎麼解決閉包產生的記憶體洩露呢?
- 針對於上面的程式碼,可以在最後執行
fn = null
; - 因為將fn設定為null時,就不再對bar函式有引用,bar函式失去了全部的引用就會被銷燬,對應foo的活動物件(AO)也就失去了引用,在下一次的垃圾回收(GC)檢測中,就會被銷燬掉;
6.使用瀏覽器檢視閉包
閉包其實是可以在瀏覽器中觀察到的,在檢視閉包之前先來討論一個問題,外層函式的活躍物件(AO)不會被銷燬,是不是裡面所有的屬性都不會被銷燬呢?
如果將上面的程式碼改成下面這樣,多增加兩個變數age和message,但是bar函式中並沒有對age和message有引用:
function foo() {
var name = 'foo'
var age = 18
var message = 'hello bibao'
return function bar() {
console.log(name)
}
}
var fn = foo()
fn()
-
形成閉包後,name是一定不會被銷燬的,這個上面已經驗證過了;
-
具體age和message有沒有被銷燬,可以在程式碼中打上斷點,在Chrome瀏覽器檢視對應的閉包;
-
觀察上面的結果是沒有age和message屬性的,這個就涉及到JS引擎的實現了,像V8引擎就對其進行了優化,對於閉包內層函式沒有使用到的自由變數,是不會被儲存的,這樣就大大提升了記憶體的使用率;