JavaScript閉包的那些事
- 2022 年 2 月 9 日
- 筆記
- javascript, js相關, 前端
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引擎就對其進行了優化,對於閉包內層函數沒有使用到的自由變數,是不會被保存的,這樣就大大提升了記憶體的使用率;