掌握JavaScript中讓人迷惑的閉包
目錄
目錄JavaScript中閉包的定義
維基百科中關於閉包的定義
閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函式閉包(function closures)。
是在支援 頭等函式 的程式語言中,實現 詞法繫結
的一種技術。閉包在實現上是一個結構體,它儲存了一個函式(通常是其入口地址)和一個關聯的環境(相當於一個符號查詢表)。
環境裡是若干對符號和值的對應關係,它既要包括 約束變數(該函式內部繫結的符號),也要包括 自由變數(在函式外部定義但在函式內被引用)。
閉包跟函式最大的不同在於,當捕捉閉包的時候,它的 自由變數 會在捕捉時被確定,這樣即便脫離了捕捉時的上下文,它也能照常執行。
MDN中關於閉包的定義
一個函式和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函式被引用包圍),這樣的組合就是 閉包(closure)。也就是說,閉包讓你可以在一個內層函式中訪問到其外層函式的作用域。在 JavaScript 中,每當建立一個函式,閉包就會在函式建立的同時被創建出來。
看完上邊維基百科和 MDN 對閉包的解釋。是不是一臉懵逼?什麼是詞法繫結?頭等函式?自由變數又指的是什麼?
但我們從以上兩個官方的解釋來看,能統一得出一點結論:
閉包是一個結構體,其實現是函式。
想要理解閉包,首先需要知道兩個前提知識點:1. 函式的執行過程,2. 記憶體管理
JS中的一等公民——函式
何為一等公民?c語言中的抽象資料模型 類(class)、Java8之前的物件(Object)、這些都是在各自語言中的一等公民。它們都有一個特性:使用靈活,能夠在程式中無限使用的物件,作為函式時的引數傳遞,以及作為函式的返回值。
在 JavaScript 中,函式是非常重要的,並且是一等公民:
- 函式可以作為另外一個函式的引數。
- 函式頁可以作為另外一個函式的返回值。
JS中函式的使用是非常靈活的,具體我們用幾個案列來解釋:
- 將函式作為另外一個函式的引數。
function foo(baz){
baz()
}
function bar(
console.log('bar')
)
foo(bar)
- 函式作為函式的返回值,js 允許函式內部再定義函式。
function foo() {
function bar() {
console.log('bar')
}
return bar
}
var fn = foo()
fn()
- 陣列中的高階函式
/*
高階函式:一個函式如果接收另一個函式作為引數,或者該函式會返回另外一個函式作為返回值的函式,那麼這個函式就稱之為是一個高階函式。
*/
var nums = [10, 7, 9, 100, 11]
var newNums = nums.filter((item) => {
return item % 2 === 0 // 偶數
})
console.log(newNums) // [10, 100]
console.log(nums) // [10, 5, 11, 100, 55]
有了以上對函式的理解。我們再來看看 js中的記憶體管理:垃圾回收機制。
JS的記憶體管理
記憶體結構
JavaScript會在 定義變數時 為我們分配記憶體。
但是記憶體的分配方式是一樣的嗎?
JS 對於基本資料型別記憶體的分配會在執行時,直接在棧空間進行分配;
JS 對於複雜資料型別記憶體的分配會在堆記憶體中開闢一塊空間,並將這塊空間的指標返回值變數引用;
例如以下程式碼在記憶體中的結構:
var name = "mjy"
var age = 18
var obj = {
name: 'mjy',
job: 'coder'
}
function foo() {
console.log('hello')
}
記憶體管理:垃圾回收機制
因為記憶體大小的有限,所以當記憶體不再被需要的時候,我們就要對其進行記憶體釋放。以便騰出更多的空間。
在手動管理記憶體的語言中,如 c:通過 malloc 來進行記憶體的申請,通過 free 進行記憶體的釋放。
但是這種方式十分的低效麻煩,而且一不小心使用完後就會忘記釋放。
所以大部分現代程式語言都有自己的垃圾回收機制:
垃圾回收的英文是:Garbage Collection,簡稱 GC。
對於不再使用的物件,我們都稱之為垃圾。它需要被回收掉。
在 JavaScript 引擎中,有著自己的垃圾回收演算法:
常見GC演算法
1. 引用計數法:
當一個物件有一個引用指向它時,那麼這個物件的引用就 +1,當一個物件的引用為 0 時,這個物件就可以被銷燬掉,也就是可以被GC回收掉。
這個演算法有一個很大的弊端:迴圈引用,導致無法被正常回收。
2. 標記清除法(運用最廣):
這個演算法是設定一個根物件(root object),垃圾回收器會定期從這個根物件開始,找所有從根物件開始有引用到的物件,對於那些沒有引用到的物件,就認為是不可達(不可用)的物件。GC就會對其進行回收。
這個演算法可以很好的解決迴圈引用產生的問題:
在有了以上兩個知識點(函式執行過程、記憶體管理)的瞭解後,我們就可以正式得來了解閉包了
JavaScript中函式的執行過程
要理解閉包,首先就要理解JavaScript中的函式,以及JavaScript函式的執行過程。可以看我之前寫過的這篇文章 理解JavaScript函式執行過程,作用域鏈。
我們先來看這樣的一段程式碼,在 js 引擎中的執行過程。
function foo() {
var name = "mjy"
var age = 18
}
function test() {
console.log('test')
}
foo()
test()
函式解析時
在 js 引擎解析程式碼時,GO 物件被建立。程式碼依次解析,當解析到 foo、test 函式時,會在堆記憶體中為其開闢一塊記憶體空間,記憶體空間中存放該函式的父級作用域(parentScope)和函式執行體。並將開闢的記憶體空間地址以址返回的形式儲存在 GO 物件的 foo 與 test 中。
函式執行時
開始執行程式碼,函式執行上下文入棧,並生成函式AO物件,依次執行函式中的程式碼。
函式物件銷燬
當foo函式執行完畢,函式執行上下文出棧,foo函式的物件AO銷燬。
閉包的產生
在理解了上面函式的執行過程後,我們來看下面這樣一個函式。
function foo() {
var name = "mjy"
var age = 18
function bar() {
console.log(name)
console.log(age)
}
return bar
}
var fn = foo()
fn()
當foo函式執行時,foo函式建立 AO 物件。foo中程式碼進行解析(當函式確定要被執行時,foo中的程式碼才會進行解析),foo函式體程式碼解析到bar函式,並在記憶體中為bar開闢記憶體空間。
解析完畢,執行 return bar
程式碼,將bar函式的記憶體空間地址,以址返回的形式賦值到變數fn中,fn = foo()
。
當foo函式執行完畢時,本該銷燬的fooAO物件並不會被消耗(也就是不會被GC回收掉)。因為此時 foo 函式的 AO 物件,在根物件GO中是可達的,有變數在指向它(不清楚這一步可以回頭看:GC演算法--標記清除法)。
這也是閉包會產生記憶體洩漏的原因。
foo函式執行完畢出棧後,test函式入棧。開始執行生成test函式AO物件,並解析後依次執行bar函式體中的程式碼:console.log(name)
。
bar沿著函式作用域鏈查詢要列印的值 name
首先會在自身AO物件中查詢。如果未查詢到,將會沿著父級作用域繼續查詢,在父級作用域foo函式的AO中物件查詢到 name
屬性,將其列印。
理解總結
以上就是一整個閉包的形成過程了。我們現在再回過頭來看 維基百科、MDN中對閉包的定義:
閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函式閉包>>。(function closures)。
是在支援 頭等函式 的程式語言中,實現 詞法繫結 的一種技術。
閉包在實現上是一個結構體,它儲存了一個函式(通常是其入口地址)和一個關聯的環境(相當於一個符號查詢表)。
環境裡是若干對符號和值的對應關係,它既要包括 約束變數(該函式內部繫結的符號),也要包括 自由變數(在函式外部定義但在函式內被引用)。
閉包跟函式最大的不同在於,當捕捉閉包的時候,它的 自由變數 會在捕捉時被確定,這樣即便脫離了捕捉時的上下文,它也能照常執行。
一個函式和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函式被引用包圍),這樣的組合就是 閉包(closure)。也就是說,閉包讓你可以在一個內層函式中訪問到其外層函式的作用域。在 JavaScript 中,每當建立一個函式,閉包就會在函式建立的同時被創建出來。
現在看來是不是就很清晰了。
在理解了閉包後,你會發現 維基百科 和 MDN 中對閉包的解釋定義是十分準確的(官方喜歡用名詞來解釋名詞,因為這樣才能更準確得表達)。
這裡摘抄coderwhy老師對閉包的理解:
- 一個普通的函式function,如果它可以訪問外層作用域的自由變數。那麼這個函式就是一個閉包。
- 從廣義的角度來說:JavaScript 中的函式都是閉包。
- 從狹義的角度來說:JavaScript 中的一個函式,如果訪問了外層作用域的變數,那麼它就是一個閉包。