理解JavaScript 閉包

java人生發表於2018-09-14

我從沒理解過 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。

結論

我牢牢記住閉包的方法是通過 揹包的比喻 。當一個函式被建立、傳遞或者從另一個函式中返回時,它就揹著一個揹包。揹包裡是函式宣告時的作用域裡的所有變數。 如Java工程化、高效能及分散式、高效能、深入淺出。高架構。效能調優、Spring,MyBatis,Netty原始碼分析和大資料等多個知識點。如果你想拿高薪的,想學習的,想就業前景好的,想跟別人競爭能取得優勢的,想進阿里面試但擔心面試不過的,你都可以來,q群號為:856443934

相關文章