譯者:前端小智
你知道的越多,你不知道的越多
點贊再看,養成習慣
本文 GitHub:github.com/qq449245884… 上已經收錄,更多往期高贊文章的分類,也整理了很多我的文件,和教程資料。歡迎Star和完善,大家面試可以參照考點複習,希望我們一起有點東西。
正如標題所述,JavaScript閉包對我來說一直有點神祕,看過很多閉包的文章,在工作使用過閉包,有時甚至在專案中使用閉包,但我確實是這是在使用閉包的知識。
最近看到的一些文章,終於,有人用於一種讓我明白方式對閉包進行了解釋,我將在本文中嘗試使用這種方法來解釋閉包。
準備
在理解閉包之前,有個重要的概念需要先了解一下,就是 js 執行上下文。
這篇文章是執行上下文 很不錯的入門教程,文章中提到:
當程式碼在JavaScript中執行時,執行程式碼的環境非常重要,並將概括為以下幾點:
全域性作用域——第一次執行程式碼的預設環境。
函式作用域——當執行流進入函式體時。
(…) —— 我們當作 執行上下文 是當前程式碼執行的一個環境與作用域。
換句話說,當我們啟動程式時,我們從全域性執行上下文中開始。一些變數是在全域性執行上下文中宣告的。我們稱之為全域性變數。當程式呼叫一個函式時,會發生什麼?
以下幾個步驟:
- JavaScript建立一個新的執行上下文,我們叫作本地執行上下文。
- 這個本地執行上下文將有它自己的一組變數,這些變數將是這個執行上下文的本地變數。
- 新的執行上下文被推到到執行堆疊中。可以將執行堆疊看作是一種儲存程式在其執行中的位置的容器。
函式什麼時候結束?當它遇到一個return
語句或一個結束括號}
。
當一個函式結束時,會發生以下情況:
-
這個本地執行上下文從執行堆疊中彈出。
-
函式將返回值返回撥用上下文。呼叫上下文是呼叫這個本地的執行上下文,它可以是全域性執行上下文,也可以是另外一個本地的執行上下文。這取決於呼叫執行上下文來處理此時的返回值,返回的值可以是一個物件、一個陣列、一個函式、一個布林值等等,如果函式沒有
return
語句,則返回undefined
。 -
這個本地執行上下文被銷燬,銷燬是很重要,這個本地執行上下文中宣告的所有變數都將被刪除,不在有變數,這個就是為什麼 稱為本地執行上下文中自有的變數。
基礎的例子
在討論閉包之前,讓我們看一下下面的程式碼:
1: let a = 3
2: function addTwo(x) {
3: let ret = x + 2
4: return ret
5: }
6: let b = addTwo(a)
7: console.log(b)
複製程式碼
為了理解JavaScript引擎是如何工作的,讓我們詳細分析一下:
-
在第
1
行,我們在全域性執行上下文中宣告瞭一個新變數a
,並將賦值為3
。 -
接下來就變得棘手了,第
2
行到第5
行實際上是在一起的。這裡發生了什麼? 我們在全域性執行上下文中宣告瞭一個名為addTwo
的新變數,我們給它分配了什麼?一個函式定義。兩個括號{}
之間的任何內容都被分配給addTwo
,函式內部的程式碼沒有被求值,沒有被執行,只是儲存在一個變數中以備將來使用。 -
現在我們在第
6
行。它看起來很簡單,但是這裡有很多東西需要拆開分析。首先,我們在全域性執行上下文中宣告一個新變數,並將其標記為b
,變數一經宣告,其值即為undefined
。 -
接下來,仍然在第
6
行,我們看到一個賦值操作符。我們準備給變數b
賦一個新值,接下來我們看到一個函式被呼叫。當看到一個變數後面跟著一個圓括號(…)
時,這就是呼叫函式的訊號,接著,每個函式都返回一些東西(值、物件或 undefined),無論從函式返回什麼,都將賦值給變數b
。 -
但是首先我們需要呼叫
addTwo
的函式。JavaScript將在其全域性執行上下文記憶體中查詢名為addTwo
的變數。噢,它找到了一個,它是在步驟2(或第2 - 5行)中定義的。變數add2
包含一個函式定義。注意,變數a
作為引數傳遞給函式。JavaScript在全域性執行上下文記憶體中搜尋變數a
,找到它,發現它的值是3
,並將數字3
作為引數傳遞給函式,準備好執行函式。 -
現在執行上下文將切換,建立了一個新的本地執行上下文,我們將其命名為“addTwo執行上下文”,執行上下文被推送到呼叫堆疊上。在
addTwo
執行上下文中,我們要做的第一件事是什麼? -
你可能會說,“在
addTwo
執行上下文中宣告瞭一個新的變數ret
”,這是不對的。正確的答案是,我們需要先看函式的引數。在addTwo
執行上下文中宣告一個新的變數``x```,因為值3
是作為引數傳遞的,所以變數x
被賦值為3。 -
下一步是:在
addTwo
執行上下文中宣告一個新的變數ret
。它的值被設定為undefined
(第三行)。 -
仍然是第3行,需要執行一個相加操作。首先我們需要
x
的值,JavaScript會尋找一個變數x
,它會首先在addTwo
執行上下文中尋找,找到了一個值為3
。第二個運算元是數字2
。兩個相加結果為5
就被分配給變數ret
。 -
第
4
行,我們返回變數ret
的內容,在addTwo執行上下文中查詢,找到值為5
,返回,函式結束。 -
第
4-5
行,函式結束。addTwo執行上下文被銷燬,變數x
和ret
被釋放,它們已經不存在了。addTwo 執行上下文從呼叫堆疊中彈出,返回值返回給呼叫上下文,在這種情況下,呼叫上下文是全域性執行上下文,因為函式addTwo
是從全域性執行上下文呼叫的。 -
現在我們繼續第
4
步的內容,返回值5被分配給變數b
,程式仍然在第6
行。 -
在第7行,
b
的值 5 被列印到控制檯了。
對於一個非常簡單的程式,這是一個非常冗長的解釋,我們甚至還沒有涉及閉包。但肯定會涉及的,不過首先我們得繞一兩個彎。
詞法作用域(Lexical scope)
我們需要理解詞法作用域的一些知識。請看下面的例子:
1: let val1 = 2
2: function multiplyThis(n) {
3: let ret = n * val1
4: return ret
5: }
6: let multiplied = multiplyThis(6)
7: console.log('example of scope:', multiplied)
複製程式碼
這裡想說明,我們在函式執行上下文中有變數,在全域性執行上下文中有變數。JavaScript的一個複雜之處在於它如何查詢變數,如果在函式執行上下文中找不到變數,它將在呼叫上下文中尋找它,如果在它的呼叫上下文中沒有找到,就一直往上一級,直到它在全域性執行上下文中查詢為止。(如果最後找不到,它就是 undefined
)。
下面列出向個步驟來解釋一下(如果你已經熟悉了,請跳過):
-
在全域性執行上下文中宣告一個新的變數
val1
,並將其賦值為2
。 -
第
2-5
行,宣告一個新的變數multiplyThis
,並給它分配一個函式定義。 -
第
6
行,宣告一個在全域性執行上下文multiplied
新變數。 -
從全域性執行上下文記憶體中查詢變數
multiplyThis
,並將其作為函式執行,傳遞數字6
作為引數。 -
新函式呼叫(建立新執行上下文),建立一個新的
multiplyThis
函式執行上下文。 -
在
multiplyThis
執行上下文中,宣告一個變數n
並將其賦值為6
。 -
第
3
行。在multiplyThis
執行上下文中,宣告一個變數ret
。 -
繼續第
3
行。對兩個運算元n
和val1
進行乘法運算.在multiplyThis
執行上下文中查詢變數n
。我們在步驟6中宣告瞭它,它的內容是數字6
。在multiplyThis
執行上下文中查詢變數val1
。multiplyThis
執行上下文沒有一個標記為val1
的變數。我們向呼叫上下文查詢,呼叫上下文是全域性執行上下文,在全域性執行上下文中尋找val1
。哦,是的、在那兒,它在步驟1中定義,數值是2
。 -
繼續第
3
行。將兩個運算元相乘並將其賦值給ret
變數,6 * 2 = 12,ret 現在值為12
。 -
返回
ret
變數,銷燬multiplyThis
執行上下文及其變數ret
和n
。變數val1
沒有被銷燬,因為它是全域性執行上下文的一部分。 -
回到第
6
行。在呼叫上下文中,數字12
賦值給multiplied
的變數。 -
最後在第
7
行,我們在控制檯中列印multiplied
變數的值
在這個例子中,我們需要記住一個函式可以訪問在它的呼叫上下文中定義的變數,這個就是詞法作用域(Lexical scope)。
返回函式的函式
在第一個例子中,函式addTwo
返回一個數字。請記住,函式可以返回任何東西。讓我們看一個返回函式的函式示例,因為這對於理解閉包非常重要。看粟子:
1: let val = 7
2: function createAdder() {
3: function addNumbers(a, b) {
4: let ret = a + b
5: return ret
6: }
7: return addNumbers
8: }
9: let adder = createAdder()
10: let sum = adder(val, 8)
11: console.log('example of function returning a function: ', sum)
複製程式碼
讓我們回到分步分解:
-
第
1
行。我們在全域性執行上下文中宣告一個變數val
並賦值為7
。 -
第
2-8
行。我們在全域性執行上下文中宣告瞭一個名為createAdder
的變數,併為其分配了一個函式定義。第3-7
行描述了上述函式定義,和以前一樣,在這一點上,我們沒有直接討論這個函式。我們只是將函式定義儲存到那個變數(createAdder
)中。 -
第
9
行。我們在全域性執行上下文中宣告瞭一個名為adder
的新變數,暫時,值為undefined
。 -
第
9
行。我們看到括號()
,我們需要執行或呼叫一個函式,查詢全域性執行上下文的記憶體並查詢名為createAdder
的變數,它是在步驟2
中建立的。好吧,我們呼叫它。 -
呼叫函式時,執行到第
2
行。建立一個新的createAdder
執行上下文。我們可以在createAdder
的執行上下文中建立自有變數。js 引擎將createAdder
的上下文新增到呼叫堆疊。這個函式沒有引數,讓我們直接跳到它的主體部分. -
第
3-6
行。我們有一個新的函式宣告,我們在createAdder
執行上下文中建立一個變數addNumbers
。這很重要,addnumber
只存在於createAdder
執行上下文中。我們將函式定義儲存在名為 ``addNumbers``` 的自有變數中。 -
第
7
行,我們返回變數addNumbers
的內容。js引擎查詢一個名為addNumbers
的變數並找到它,這是一個函式定義。好的,函式可以返回任何東西,包括函式定義。我們返addNumbers
的定義。第4
行和第5
行括號之間的內容構成該函式定義。 -
返回時,
createAdder
執行上下文將被銷燬。addNumbers
變數不再存在。但addNumbers
函式定義仍然存在,因為它返回並賦值給了adder
變數。 -
第
10
行。我們在全域性執行上下文中定義了一個新的變數sum
,先賦值為undefined
; -
接下來我們需要執行一個函式。哪個函式? 是名為
adder
變數中定義的函式。我們在全域性執行上下文中查詢它,果然找到了它,這個函式有兩個引數。 -
讓我們查詢這兩個引數,第一個是我們在步驟1中定義的變數
val
,它表示數字7
,第二個是數字8
。 -
現在我們要執行這個函式,函式定義概述在第
3-5
行,因為這個函式是匿名,為了方便理解,我們暫且叫它adder
吧。這時建立一個adder
函式執行上下文,在adder
執行上下文中建立了兩個新變數a
和b
。它們分別被賦值為7
和8
,因為這些是我們在上一步傳遞給函式的引數。 -
第
4
行。在adder
執行上下文中宣告瞭一個名為ret
的新變數, -
第
4
行。將變數a
的內容和變數b
的內容相加得15
並賦給ret
變數。 -
ret
變數從該函式返回。這個匿名函式執行上下文被銷燬,從呼叫堆疊中刪除,變數a
、b
和ret
不再存在。 -
返回值被分配給我們在步驟9中定義的
sum
變數。 -
我們將
sum
的值列印到控制檯。 -
如預期,控制檯將列印
15
。我們在這裡確實經歷了很多困難,我想在這裡說明幾點。首先,函式定義可以儲存在變數中,函式定義在程式呼叫之前是不可見的。其次,每次呼叫函式時,都會(臨時)建立一個本地執行上下文。當函式完成時,執行上下文將消失。函式在遇到return
或右括號}
時執行完成。
最後,一個閉包
看看下面的程式碼,並試著弄清楚會發生什麼。
1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
複製程式碼
現在,我們已經從前兩個示例中掌握了它的訣竅,讓我們按照預期的方式快速執行它:
-
第
1-8
行。我們在全域性執行上下文中建立了一個新的變數createCounter
,並賦值了一個的函式定義。 -
第
9
行。我們在全域性執行上下文中宣告瞭一個名為increment
的新變數。 -
第
9
行。我們需要呼叫createCounter
函式並將其返回值賦給increment
變數。 -
第
1-8
行。呼叫函式,建立新的本地執行上下文。 -
第
2
行。在本地執行上下文中,宣告一個名為counter
的新變數並賦值為0
; -
第
3-6
行。宣告一個名為myFunction
的新變數,變數在本地執行上下文中宣告,變數的內容是為第4
行和第5行所定義。 -
第7行。返回
myFunction
變數的內容,刪除本地執行上下文。變數myFunction
和counter
不再存在。此時控制權回到了呼叫上下文。 -
第
9
行。在呼叫上下文(全域性執行上下文)中,createCounter
返回的值賦給了increment
,變數increment
現在包含一個函式定義內容為createCounter
返回的函式。它不再標記為myFunction````,但它的定義是相同的。在全域性上下文中,它是的標記為
labeledincrement```。 -
第
10
行。宣告一個新變數c1
。 -
繼續第
10
行。查詢increment
變數,它是一個函式並呼叫它。它包含前面返回的函式定義,如第4-5
行所定義的。 -
建立一個新的執行上下文。沒有引數,開始執行函式。
-
第
4
行。counter=counter + 1
。在本地執行上下文中查詢counter
變數。我們只是建立了那個上下文,從來沒有宣告任何區域性變數。讓我們看看全域性執行上下文。這裡也沒有counter
變數。Javascript會將其計算為counter = undefined + 1,宣告一個標記為counter
的新區域性變數,並將其賦值為number 1,因為undefined被當作值為 0。 -
第
5
行。我們變數counter
的值1
,我們銷燬本地執行上下文和counter
變數。 -
回到第
10
行。返回值1
被賦給c1
。 -
第
11
行。重複步驟10-14
,c2
也被賦值為1
。 -
第
12
行。重複步驟10-14
,c3
也被賦值為1
。 -
第
13
行。我們列印變數c1 c2
和c3
的內容。
你自己試試,看看會發生什麼。你會將注意到,它並不像從我上面的解釋中所期望的那樣記錄1,1,1
。而是記錄1,2,3
。這個是為什麼?
不知怎麼滴,increment
函式記住了那個cunter
的值。這是怎麼回事?
counter
是全域性執行上下文的一部分嗎?嘗試 console.log(counter)
,得到undefined
的結果,顯然不是這樣的。
也許,當你呼叫increment
時,它會以某種方式返回它建立的函式(createCounter)?這怎麼可能呢?變數increment
包含函式定義,而不是函式的來源,顯然也不是這樣的。
所以一定有另一種機制。閉包,我們終於找到了,丟失的那塊。
它是這樣工作的,無論何時宣告新函式並將其賦值給變數,都要儲存函式定義和閉包。閉包包含在函式建立時作用域中的所有變數,它類似於揹包。函式定義附帶一個小揹包,它的包中儲存了函式定義建立時作用域中的所有變數。
所以我們上面的解釋都是錯的,讓我們再試一次,但是這次是正確的。
1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
複製程式碼
-
同上,第
1-8
行。我們在全域性執行上下文中建立了一個新的變數createCounter
,它得到了指定的函式定義。 -
同上,第
9
行。我們在全域性執行上下文中宣告瞭一個名為increment
的新變數。 -
同上,第
9
行。我們需要呼叫createCounter
函式並將其返回值賦給increment
變數。 -
同上,第
1-8
行。呼叫函式,建立新的本地執行上下文。 -
同上,第
2
行。在本地執行上下文中,宣告一個名為counter
的新變數並賦值為0
。 -
第
3-6
行。宣告一個名為myFunction
的新變數,變數在本地執行上下文中宣告,變數的內容是另一個函式定義。如第4
行和第5
行所定義,現在我們還建立了一個閉包,並將其作為函式定義的一部分。閉包包含作用域中的變數,在本例中是變數counter
(值為0
)。 -
第
7
行。返回myFunction
變數的內容,刪除本地執行上下文。myFunction
和counter
不再存在。控制權交給了呼叫上下文,我們返回函式定義和它的閉包,閉包中包含了建立它時在作用域內的變數。 -
第
9
行。在呼叫上下文(全域性執行上下文)中,createCounter
返回的值被指定為increment
,變數increment
現在包含一個函式定義(和閉包),由createCounter返回的函式定義,它不再標記為myFunction
,但它的定義是相同的,在全域性上下文中,稱為increment
。 -
第
10
行。宣告一個新變數c1
。 -
繼續第
10
行。查詢變數increment
,它是一個函式,呼叫它。它包含前面返回的函式定義,如第4-5
行所定義的。(它還有一個帶有變數的閉包)。 -
建立一個新的執行上下文,沒有引數,開始執行函式。
-
第
4
行。counter = counter + 1
,尋找變數counter
,在查詢本地或全域性執行上下文之前,讓我們檢查一下閉包,瞧,閉包包含一個名為counter
的變數,其值為0
。在第4
行表示式之後,它的值被設定為1
。它再次被儲存在閉包裡,閉包現在包含值為1
的變數counter
。 -
第
5
行。我們返回counter的值
,銷燬本地執行上下文。 -
回到第
10
行。返回值1
被賦給變數c1
。 -
第
11
行。我們重複步驟10-14
。這一次,在閉包中此時變數counter
的值是1。它在第12
行設定的,它的值被遞增並以2
的形式儲存在遞增函式的閉包中,c2
被賦值為2
。 -
第
12
行。重複步驟10-14
行,c3
被賦值為3。 -
第13行。我們列印變數
c1 c2
和c3
的值。
你可能會問,是否有任何函式具有閉包,甚至是在全域性範圍內建立的函式?答案是肯定的。在全域性作用域中建立的函式建立閉包,但是由於這些函式是在全域性作用域中建立的,所以它們可以訪問全域性作用域中的所有變數,閉包的概念並不重要。
當函式返回函式時,閉包的概念就變得更加重要了。返回的函式可以訪問不屬於全域性作用域的變數,但它們僅存在於其閉包中。
閉包不是那麼簡單
有時候閉包在你甚至沒有注意到它的時候就會出現,你可能已經看到了我們稱為部分應用程式的示例,如下面的程式碼所示:
let c = 4
const addX = x => n => n + x
const addThree = addX(3)
let d = addThree(c)
console.log('example partial application', d)
複製程式碼
如果箭頭函式讓你感到困惑,下面是同樣效果:
let c = 4
function addX(x) {
return function(n) {
return n + x
}
}
const addThree = addX(3)
let d = addThree(c)
console.log('example partial application', d)
複製程式碼
我們宣告一個能用加法函式addX
,它接受一個引數x
並返回另一個函式。返回的函式還接受一個引數並將其新增到變數x
中。
變數x
是閉包的一部分,當變數addThree
在本地上下文中宣告時,它被分配一個函式定義和一個閉包,閉包包含變數x
。
所以當addThree
被呼叫並執行時,它可以從閉包中訪問變數x
以及為引數傳遞變數n
並返回兩者的和 7
。
總結
我將永遠記住閉包的方法是通過揹包的類比。當一個函式被建立並傳遞或從另一個函式返回時,它會攜帶一個揹包。揹包中是函式宣告時作用域內的所有變數。
程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug。
你的點贊是我持續分享好東西的動力,歡迎點贊!
交流(歡迎加入群,群工作日都會發紅包,互動討論技術)
乾貨系列文章彙總如下,覺得不錯點個Star,歡迎 加群 互相學習。
我是小智,公眾號「大遷世界」作者,對前端技術保持學習愛好者。我會經常分享自己所學所看的乾貨,在進階的路上,共勉!
關注公眾號,後臺回覆福利,即可看到福利,你懂的。