原來JavaScript的閉包是這麼回事
正如標題所述,JavaScript閉包對我來說一直是個謎。我閱讀過很多篇相關文章,我在工作中也使用了閉包,有時候我自己使用了閉包卻不自知。最近我參加了一個講座,在那兒終於有人給我解釋清楚了。本文中我也將嘗試用他們的方法來解釋閉包。
在開始之前
在理解閉包之前,需要先理解一些概念,執行上下文 就是其中的一個。
有篇文章很好地解釋了執行上下文,以下內容引用自這篇文章:
在執行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分配一個新值。接下來,我們看到一個被呼叫的函式。當你看到一個變數後面跟著圓括號(...)時,就表示在呼叫一個函式。從函式返回的任何內容都將被分配給變數b。
- 但首先我們需要呼叫被標記為addTwo的函式。JavaScript將在其全域性執行上下文記憶體中查詢名為addTwo的變數。它找到了,也就是在步驟2(或第2-5行)中定義的那個。變數addTwo包含了一個函式定義。請注意,變數a被作為引數傳遞給該函式。JavaScript在其全域性執行上下文記憶體中搜索變數a,找到它,發現它的值為3,然後將數值3作為引數傳遞給該函式。準備執行該函式。
- 現在切換執行上下文,建立了一個新的本地執行上下文,我們將其命名為“addTwo執行上下文”。執行上下文被推送到呼叫棧。我們在本地執行上下文中做的第一件事是什麼?
- 你可能會想說,“在本地執行上下文中聲明瞭一個新的變數ret”。但其實不是這樣的,我們首先需要檢視函式的引數。在本地執行上下文中聲明瞭一個新變數x,又因為3被作為引數傳遞進來,所以變數x被賦值為3。
- 下一步:在本地執行上下文中宣告新的變數ret,其值設定為undefined。(第3行)
- 仍然是第3行,需要執行一個加法運算。首先,我們需要x的值,JavaScript會嘗試查詢變數x,它首先檢視本地執行上下文。它找到了,值為3。第二個運算元是數值2,加法的結果(5)被賦給變數ret。
- 第4行,我們返回變數ret的內容。在本地執行上下文中進行另一個查詢。ret包含值5。函式返回數值5,函式結束執行。
- 第4-5行,函式結束執行,本地執行上下文被銷燬,變數x和ret被清除,它們不再存在。上下文彈出呼叫棧,返回值被返回到呼叫上下文。在這種情況下,呼叫上下文就是全域性執行上下文,因為函式addTwo是從全域性執行上下文中呼叫的。
- 現在我們從在步驟4中暫停的位置繼續。返回值(數值5)被分配給變數b。
- 在第7行,變數b的內容會在控制檯中打印出來。在這個例子中,數值為5。
對於一個非常簡單的程式來說,這樣的解釋顯得太過冗長,但我們甚至都還沒有提到閉包。我保證會說到那裡,但首先我們需要說一些其他的。
詞法作用域
我們需要了解詞法作用域的某些方面,看下面的例子。
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作為引數傳遞進去。
- 新函式呼叫就是新的執行上下文。建立一個新的本地執行上下文。
- 在本地執行上下文中,宣告變數n,並賦值為6。
- 第3行,在本地執行上下文中宣告變數ret。
- 第3行,用兩個運算元執行乘法運算:變數n和val1的內容。在本地執行上下文中查詢變數n,我們在步驟6中聲明瞭它,它的內容是數值6。在本地執行上下文中查詢變數val1,本地執行上下文中沒有標記為val1的變數。我們從呼叫上下文中查詢,呼叫上下文也就是全域性執行上下文。讓我們在全域性執行上下文中查詢val1。是的,它就在那裡。它在步驟1中定義,值為數值2。
- 第3行,將兩個運算元相乘,並將結果指定給變數ret。6 * 2 = 12。ret現在是12。
- 返回ret變數,本地執行上下文及其變數ret和n被銷燬。變數val1不會被銷燬,因為它是全域性執行上下文的一部分。
- 回到第6行,在呼叫上下文中將數值12分配給變數multiplied。
- 第7行,在控制檯中顯示變數multiplied的值。
所以在這個例子中,我們需要記住一個函式可以訪問在其呼叫上下文中定義的變數,這種現象的正式名稱是詞法作用域。
返回函式的函式
在第一個示例中,函式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行,呼叫一個函式,建立了一個新的本地執行上下文。我們可以在新的執行上下文中建立區域性變數,引擎將新上下文新增到呼叫棧。這個函式沒有引數,所以直接進入它的函式體。
- 3-6行,我們有一個新的函式宣告,我們在本地執行上下文中建立變數addNumbers,這很重要。addNumbers僅在本地執行上下文中存在,我們將函式定義儲存在名為addNumbers的區域性變數中。
- 第7行,我們返回變數addNumbers的內容。引擎查詢名為addNumbers的變數,這是一個函式定義。一個函式可以返回任何東西,包括函式定義,所以我們返回addNumbers的定義。第4行和第5行的括號之間的任何內容構成了函式定義,我們還從呼叫棧中刪除了本地執行上下文。
- 在return語句之後,本地執行上下文被銷燬,addNumbers變數也不復存在,但函式定義仍然存在,它從函式返回並賦給變數adder,這是我們在第3步中建立的變數。
- 第10行,我們在全域性執行上下文中定義了一個新的變數sum,臨時賦值undefined。
- 接下來我們需要執行一個函式,哪個函式?在名為adder的變數中定義的函式。我們在全域性執行環境中查詢它,這是一個帶兩個引數的函式。
- 我們先拿到兩個引數,這樣就可以呼叫函式並將正確的引數傳給它。第一個是變數val,在步驟1中定義的,它代表數值7,第二個是數值8。
- 現在我們要執行這個函式,函式體是在第3-5行定義的。建立一個新的本地執行上下文。在本地上下文中,建立了兩個新變數:a和b。它們分別被賦值為7和8,因為它們是在上一步傳給函式的引數。
- 第4行,在本地執行上下文中宣告一個新變數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,但定義沒有變化。在全域性上下文中,它被標記為increment。
- 第10行,宣告一個新變數(c1)。
- 繼續第10行,查詢變數increment,它是一個函式,然後呼叫它。它包含了之前返回的函式定義,也就是第4-5行所定義的內容。
- 建立新的執行上下文,沒有引數,開始執行這個函式。
- 第4行,counter = counter + 1,在本地執行上下文中查詢變數counter。我們只是建立了上下文,並沒有宣告任何區域性變數。在全域性執行上下文中,沒有標記為counter的變數。Javascript將會執行counter = undefined + 1,宣告一個標記為counter的新區域性變數,併為其指定數值1,因為undefined其實被視為0。
- 第5行,我們返回counter的值,也就是數值1。我們銷燬本地執行上下文和變數conter。
- 第10行,返回值(1)被分配給c1。
- 第11行,我們重複步驟10-14,c2也被賦值為1。
- 第12行,我們重複步驟10-14,c3也被賦值為1。
- 第13行,我們記錄變數c1、c2和c3的值。
親自嘗試一下,看看會發生什麼。你會注意到它並不像你預想地那樣輸出1、1和1,而是輸出了1、2和3。為什麼會這樣?
increment函式會記住counter的值,為什麼會這樣?
counter是全域性執行上下文的一部分嗎?試試console.log(counter),你將得到undefined,所以它不是。
所以,肯定存在另一種被我們忽略的機制——也就是閉包。
下面是它的工作原理。每當宣告一個新函式並將其賦值給變數時,實際上是儲存了函式定義和閉包。閉包包含了建立函式時宣告的所有變數,就像一個揹包一樣——函式定義附帶一個小揹包。這個揹包儲存了建立函式時宣告的所有變數。
所以我們上面的解釋都是錯誤的,讓我們再試一次,但這次是正確的。
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的值或數值1,銷燬本地執行上下文。
- 返回第10行,返回值(1)被分配給c1。
- 第11行,我們重複步驟10-14。這次,我們可以看到變數counter的值為1,這個值是在第4行程式碼中設定的。它的值加1,並在increment函式的閉包中存為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。
結論
我通過揹包類比 的方式記住了閉包。當建立和傳遞一個函式或將其從另一個函式返回時,這個函式就帶有一個揹包,揹包中包含了所有在建立函式時宣告的變數。
英文原文:ofollow,noindex" target="_blank">https://medium.com/dailyjs/i-never-understood-javascript-closures-9663703368e8
感謝覃雲對本文的策劃和無明的審校。