1. 程式人生 > >理解JavaScript 閉包

理解JavaScript 閉包

知識 重新 整體 應該 表達式 上下 發現 同時 位置

我從沒理解過 JavaScript 閉包
直到有人這樣跟我解釋……

正如標題所說,JavaScript 閉包對我來說一直是個迷。我 看過 很多 文章,在工作中用過閉包,甚至有時候我都沒有意識到我在使用閉包。

最近參加一個交流會,有人用某種方式向我解釋了閉包,點醒了我。這篇文章我也將用這種方式來解釋閉包。這裏要稱贊一下 CodeSmith 的優秀人才和他們的《JavaScript The Hard Parts》系列。

開始之前

在理解閉包之前,一些重要的概念需要理解。其中一個就是 執行上下文(execution context)。

這篇文章 對執行上下文有很好的介紹。引用一下這篇文章:

JavaScript 代碼在執行時,它的執行環境非常重要,它會被處理成下面的某一種情況:

全局代碼(Global code) —— 代碼開始執行時的默認環境。

函數代碼(Function code) —— 當執行到函數體時。

(…)

(…), 我們把術語 執行上下文(execution context) 稱為當前執行代碼所處的 環境或者作用域。

換句話說,當我們開始執行程序時,首先處於全局上下文中。在全局上下文中聲明的變量,稱為全局變量。當程序調用函數時,會發生什麽?發生下面這幾步:

JavaScript 創建一個新的執行上下文 —— 局部執行上下文。
這個局部執行上下文有屬於它的變量集,這些變量是這個執行上下文的局部變量。
這個新的執行上下文被壓入執行棧中。將執行棧當成是用來跟蹤程序執行位置的一種機制。

函數什麽時候執行完?當遇到 return 語句或者結束括號 } 時。函數結束時,發生下面情況:

局部執行上下文從執行棧彈出。
函數把返回值返回到調用上下文。調用上下文是指調用該函數的的執行上下文,它可以是全局執行上下文也可以是另外一個局部執行上下文。這裏的返回值怎麽處理取決於調用執行上下文。返回值可是 object, array, function, boolean 等任何類型。如果函數沒有 return 語句,那麽返回值是 undefined。
局部執行上下文被銷毀。這點很重要 —— 被銷毀。所有在局部執行上下文中聲明的變量都被清除。這些變量不再可用。這也是為什麽稱它們為局部變量。
一個非常簡單的例子

在開始學習閉包之前,我們先來看下下面這段代碼。它看起來很簡單,所有的讀者應該都能清楚的知道它的作用。

1
2
3
4
5
6
7
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 引擎的真正工作原理,我們來詳細解釋一下。

在代碼第一行,我們在全局執行上下文聲明了一個新的變量 a,並賦值為 3。
接下來比較棘手了。第 2 到第 5 行屬於一個整體。這裏發生了什麽呢?我們在全局執行上下文聲明了一個變量,命名為 addTwo。然後我們怎麽對它賦值的?通過函數定義。所有在兩個括號 {} 之間的內容都被賦給 addTwo。函數裏的代碼不計算、不執行,只是保存在變量,留著後面使用。
現在我們到了第 6 行。看似很簡單,其實這裏有很多需要解讀。首先我們在全局執行上下文聲明了一個變量,標記為 b。當變量剛聲明時,它的默認值是 undefined。
接著,還是在第 6 行,我們看到有個賦值運算符。我們準備給變量 b 賦新值。接著看到一個將要被調用的函數。當你看到變量後面跟著圓括號 (...) ,那就是函數調用的標識。提前說下後面的情況:每個函數都有返回值(一個值、一個對象或者是 undefined)。函數的返回值將被賦值給變量 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。首先在局部執行上下文中尋找。找到變量 x 的值為 3。第二個操作數是數值 2,加法的結果(5)賦值給變量 ret。
第 4 行。我們返回變量 ret 的值。在局部執行上下文中又進行查找 ret。ret 的值為 5。所以該函數返回數值 5,函數結束。
第 4-5 行。函數結束。局部執行上下文被銷毀。變量 x 和 ret 被清除,不再存在。調用棧彈出該上下文,返回值返回給調用上下文。在這個例子中,調用上下文是全局執行上下文,因為函數 addTwo 是在全局執行上下文中調用的。
現在回到我們在第 4 步遺留的內容。返回值(數值 5)復制給變量 b。在這個小程序中,我們還在第 6 行。
下面我不再詳細說明了。在第 7 行,變量 b 的值在 console 中打印出來。在我們的例子裏將打印出數值 5。
對一個簡單的程序,這真是個冗長的解釋!而且我們甚至還沒涉及到閉包。我保證一定會講解閉包的。但是我們還是需求繞一兩次。

詞法作用域 (Lexical scope)

我們需要理解詞法作用域的一些知識點。看看下面的例子:

1
2
3
4
5
6
7
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 的變量,所以我們檢查調用上下文中。這裏調用上下文是全局執行上下文。我們在全局執行上下文中找到它,它在第 1 步中被定義,值為數值 2。
依舊是第 3 行。兩個操作數相乘然後賦值給變量 ret。6 * 2 = 12。ret 現在值為 12。
返回變量 ret。局部執行上下文以及相應的變量 ret 和 n 一起被銷毀。變量 val1 作為全局執行上下文的一部分沒有被銷毀。
回到第 6 行。在調用上下文中,變量 multiplied 被賦值為數值 12。
最後在第 7 行,我們在 console 中顯示變量 multiplied 的值。
在這個例子中,我們需要記住,函數可以訪問到它調用上下文中定義的變量。這種現象正式學名是 詞法作用域。

(譯者註:覺得這裏對詞法作用域的解釋限於此例,並不完全準確。詞法作用域,函數的作用域是在函數定義的時候決定的,而不是調用時)。

返回值是函數的函數

在第一個例子裏函數 addTwo 返回的是個數值。記得之前提過函數可以返回任何類型。我們來看個函數返回函數的例子,這個是理解閉包的關鍵點。下面是我們要分析的例子。

1
2
3
4
5
6
7
8
9
10
11
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 步創建。ok,現在調用它。
調用函數,我們現在處於第 2 行。新的局部執行上下文被創建。我們可以在新的執行上下文中創建局部變量。JavaScript 引擎把新的上下文壓入調用棧。該函數沒有參數,我們直接進入函數體。
還是在 3-6 行。我們聲明了個新函數。我們在局部執行上下文中創建了新的變量 addNumbers,這點很重要,addNumbers 只在局部執行上下文中出現。我們使用局部變量 addNumbers 保存了函數定義。
現在到了第 7 行。我們返回變量 addNumbers 的值。JavaScript 引擎找到 addNumbers 這個變量,它是個函數定義。這沒問題,函數可以返回任意類型,包括函數定義。所以我們返回了 addNumbers 這個函數定義。括號中的所有內容——第 4-5 行組成了函數定義。我們也從調用棧中移除了該局部執行上下文。
局部執行上下文在返回時銷毀了。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。
在 console 中打印 sum 的值。
正如所預期的,console 打印出 15,但是這個過程我們真的經歷了很多困難。我想在這裏說明幾點。首先,函數定義可以保存在變量中,函數定義在執行前對程序是不可見的;第二點,每次函數調用,都會創建一個局部執行上下文(臨時的),局部執行上下文在函數結束後消失,函數在遇到 return 語句或者右括號 } 時結束。

最後,閉包

看看下面的代碼,會發生什麽。

1
2
3
4
5
6
7
8
9
10
11
12
13
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。銷毀局部執行上下文和變量 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 函數記住了 counter 的值。這是怎麽實現的呢?

是不是因為 counter 是屬於全局執行上下文?試試 console.log(counter),你會得到 undefined。所以它並不是。

或許,是因為當你調用 increment 時,它以某種方式返回創建它的函數(createCounter)的地方?這是怎麽回事呢?變量 increment 包含函數定義,而不是它從哪裏創建。所以並不是這個原因。

所以這裏肯定存在另一種機制。它就是閉包。我們終於講到它了,一直缺失的部分。

下面是它的工作原理。只要你聲明一個新的函數並賦值給一個變量,你就保存了這個函數定義,也就形成了閉包。閉包包含函數創建時的作用域裏的所有變量。這類似於一個背包。函數定義帶著一個背包,包裏保存了所有在函數定義創建時作用域裏的變量。

所以我們上面的解釋全錯了。我們重新來一遍,這次是正確的。

1
2
3
4
5
6
7
8
9
10
11
12
13
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。銷毀局部執行上下文和變量 counter。
回到第 10 行,返回值(1)賦給 c1。
第 11 行,重復第 10-14 的步驟。這次,當我們查看閉包時,我們看到變量 counter 的值為 1。它是在第 12 步(程序第 4 行)設置的。通過 increment 函數,它的值增加並保存為 2。 最後 c2 也賦值為 2。
第 12 行,重復第 10-14 的步驟,最後 c3 也賦值為 3。
第 13 行,我們打印出變量 c1、c2 和 c3 的值。
現在我們理解它的原理了。需要記住的關鍵點是,但函數聲明時,它包含函數定義和一個閉包。閉包是函數創建時作用域內所有變量的集合。

你可能會問,是不是所有函數都有閉包,即使是在全局作用域下創建的函數?答案是肯定的。全局作用域下創建的函數也生成閉包。但是既然函數是在全局作用域下創建的,他們可以訪問全局作用域下的所有變量。所以這和閉包的概念不相關。

當函數的返回值是一個函數時,閉包的概念就變得更加相關了。返回的函數可以訪問不在全局作用域裏的變量,但它們只存在於閉包裏。

並不簡單的閉包

有時候,你可能都沒有註意到閉包的生成。你可能在偏函數應用看到過例子,像下面這段代碼:

1
2
3
4
5
let c = 4
const addX = x => n => n + x
const addThree = addX(3)
let d = addThree(c)
console.log(‘example partial application‘, d)
如果箭頭函數讓你難以理解,下面是等價的代碼:

1
2
3
4
5
6
7
8
9
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 是通過參數傳入,所以函數可以返回相加的和。

這個例子 console 會打印出數值 7。

結論

我牢牢記住閉包的方法是通過 背包的比喻 。當一個函數被創建、傳遞或者從另一個函數中返回時,它就背著一個背包。背包裏是函數聲明時的作用域裏的所有變量。

理解JavaScript 閉包