我歷來都不理解閉包

原文地址: medium.com/dailyjs/i-n…
譯文地址:github.com/xiao-T/note…
本文版權歸原做者全部,翻譯僅用於學習。javascript


正如標題所述,JavaScript 閉包對我來講一直是一個謎。爲此,我讀過不少[文章]((en.wikipedia.org/wiki/Closur…),工做中我也用過閉包,有些時候,我甚至都不知道使用了閉包。java

最近,我和一些人討論了一下,他們真正的點醒了我。在這篇文章中,我將會嘗試解釋一下閉包。首先,我要感謝一下 CodeSmith 和他們的JavaScript 的課程git

前言

爲了理解閉包,有些概念很是重要。其中之一就是執行上下文github

這篇文章很好的解釋了什麼是執行上下文。如下是引用:數組

當執行 JavaScript 代碼時,執行環境很是重要,而且會按照如下狀況計算:閉包

全局代碼 — 當代碼第一次執行時,默認的執行環境app

函數代碼 — 在函數體內執行的代碼函數

執行上下文其實就是當前代碼執行的環境/做用域。學習

換句話說,程序開始時,是在全局的執行上下文中。有些變量是在全局上下文中被聲明定義的。咱們稱之爲全局變量。當程序在函數中執行,會發生什麼呢?會有如下幾步:ui

  1. JavaScript 會建立一個本地的全新上下文
  2. 本地的上下文會有它內部的變量,這些變量屬於當前的本地執行上下文
  3. 新的執行上下文將會被壓入執行棧中。執行棧就是爲了跟蹤程序在哪執行的機制。

函數什麼時候結束?當遇到 return 語句或者遇到閉合的大括號 }。當函數結束時,會依次發生如下狀況:

  1. 本地執行上下文會從執行棧中被彈出
  2. 調用上下文將會獲得函數的返回值。調用上下文就是函數被調用時的執行上下文,它但是全局執行上下文或者另一個本地執行上下文。此時,調用上下文會處理函數的返回值。返回值能夠是對象、數組、函數、布爾值、任何值。若是,函數沒有 return 語句,默認,將會返回 undefined
  3. 接下來,本地執行上下文將會被銷燬。這個很重要。銷燬表明着全部在其內部聲明的變量都會被抹除。它們再也不可用。這也是爲何把它們稱爲本地變量。

一個簡單的演示

解釋閉包以前,咱們先看一下如下的代碼片斷。它看起很是簡單直接,任何人都知道會發生什麼。

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. 接下來會變得棘手。第二行到第五行是一個總體。發生了什麼呢?在全局執行上下文中咱們聲明瞭一個名爲 addTwo 的新變量。咱們給它分配什麼呢?函數定義。兩個大括號 { } 任何代碼都會分配給 addTwo。函數內部的代碼,這時並不會被計算,也不會執行,只是存儲在一個變量中以便未來使用。

  3. 如今,咱們來到了第六行。看起很是簡單,可是,這裏包含了不少東西。首先,在全局執行上下文中,咱們聲明瞭一個新的變量 b。變量聲明的同時,也會賦值 undefined

  4. 接下來,仍然是第六行,咱們看到有一個賦值操做符。這時,咱們才真正賦值給變量 b 。接下來,咱們看到函數被調用了。當你看到一個變量後面跟着一個圓扣號 (),那表明着函數調用執行。如前所述,每一個函數都會返回一些值(值、對象或者 undefined)。無論,函數返回什麼都會賦值給變量 b

  5. 可是,首先咱們須要調用函數 addTwo。JavaScript 將會在全局執行上下文中查找一個名爲 addTwo 的變量。是的,它找到了,在第二步定義的(第二行到第五行)。你看,變量 addTwo 是一個函數。注意,變量 a 做爲一個參數傳遞給了函數。JavaScript 會在全局執行上下文中搜索變量 a 並找到它,發現它的值是 3,而後,數字 3 就傳遞給了函數。準備開始執行函數。

  6. 如今,執行上下文發生了改變。一個新的本地執行上下文被建立,咱們叫它 「addTwo 執行上下文」。這個執行上下文被壓入到調用棧。在本地執行上下文中咱們首先要作什麼呢?

  7. 你可能會說:「一個新的變量 ret本地執行上下文中被聲明瞭」。這不對,正確的答案是,首先,咱們須要看一下函數的參數。在本地執行上下文中聲明瞭一個新的變量 x 。而後,因爲數字 3 作爲參數傳遞給了函數,那麼,變量 x 就的值就變成了 3

  8. 下一步:本地執行上下文聲明瞭新的變量 ret。它的值是 undefined(第三行)

  9. 仍舊是第三行,須要執行加法。首先,咱們須要用到 x 的值。JavaScript 將會查找變量 x。首先,它會在本地執行上下文中查找。並且,找到了,它的值是 3。第二個操做數是數字 2。二者相加以後的結果(5)將會賦值給變量 ret

  10. 第四行。咱們會返回變量 ret。另外,根據本地執行上下文的內容得知 ret 的值是 5。函數將會返回數字 5。這時,函數結束。

  11. 函數在第四到第五行結束。本地執行上下文也隨之被銷燬。變量 xret 同時被抹除。它們將會消失。調用棧也會彈出響應的上下文,返回值將會返回到調用上下文。在這個案例中,調用棧就是全局執行上下文,這是由於,函數 addTwo 是在全局執行上下文中被調用的。

  12. 如今,咱們從新回到第四步。返回值(數字5)賦值給了變量 b。咱們仍舊在程序的第六行。

  13. 我不用詳解介紹,在第七行,變量 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)。根據這個規則,上面的示例就很清晰了。若是,你清楚做用域是如何工做的,你能夠跳過這部分。

  1. 在全局執行上下文中聲明一個變量 val1,而後,給它賦值數字 2

  2. 第 2 - 5 行。聲明瞭一個新變量 multiplyThis,而後,定義了一個函數

  3. 第 6 行。在全局執行上下文聲明瞭一個變量 multiplied

  4. 在全局執行上下文中找到變量 multiplyThis,並作爲一個函數執行。而後,把數字 6 作爲參數傳遞給函數

  5. 函數被調用 = 新的執行上下文。建立新的本地執行上下文

  6. 在本地執行上下文中,聲明瞭變量 n 並賦值了數字 6

  7. 第 3 行。聲明瞭變量 ret

  8. 第 3 行。變量 nvall 兩個數的相乘。在本地執行上下文中查找變量 n。咱們在第 6 行聲明瞭這個變量。它的值是數字 6。本地上下文中沒有找到變量 vall。須要檢測調用上下文。由於,調用上下文是全局上下文。咱們須要在全局上下文中查找 vall。很好,找到了。它在第 1 行被定義的。它的值是數字 2

  9. 第 3 行。兩個數相差,而後賦值給變量 ret。6 * 2 = 12。ret 的值是 12

  10. 返回 ret 的值。隨之本地上下文也被銷燬,同時銷燬的還有變量 retn。變量 vall 並不會被銷燬,由於它是全局上下文的一部分。

  11. 回到第 6 行。在調用上下文中,數字 12 被複制給變量 multiplied

  12. 最後的第 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. 第 1 行。咱們在全局上下文中聲明瞭變量 val,而且賦值數字 7 給變量

  2. 第 2 - 8 行。在全局上下文中咱們聲明瞭一個名爲 createAdder 的函數。第 3 - 7 行就是函數的具體定義。和以前同樣,這個時候,咱們並不會執行函數。咱們只是把函數賦值給一個變量(createAdder

  3. 第 9 行。在全局上下文中,咱們聲明瞭新的變量 adder。同時,它的值是 undefined

  4. 仍是第 9 行。咱們看到了一個圓括號();表明着咱們須要調用函數。咱們在全局上下文中搜索找了到名爲 createAdder 的變量。它是在第 2 步建立的。好,咱們來調用它。

  5. 調用函數。如今,咱們回到第 2 行。一個新的上下文被建立。在新的上下文中咱們建立了本地變量。同時,引擎也會把新的上下文壓入到調用棧。這個函數沒有參數,咱們直接看它的內部。

  6. 第 3 - 6 行。咱們又聲明瞭一個新的函數。在本地上下文中咱們建立變量 addNumber。這個很重要。addNumber 只在本地上下文中有效。在本地上下文中咱們定義了一個函數並命名爲 addNumber

  7. 如今,我來到第 7 行。咱們返回了變量 addNumber。引擎會查找變量 addNumber,固然也會找到它。它是一個函數。好,函數能夠返回任何東西,包括函數。所以,咱們返回了 addNumbers 的函數體。在第 4 - 5 行就是函數的具體定義。同時,咱們也把本地上下文從調用棧中移除。

  8. return 以後,本地上下文也隨之銷燬。變量 addNumbers 也不存在了。可是,函數的定義仍然存在,它經過 retrun 語句,並賦值給了變量 adder;這個變量,咱們是在第 3 步建立的。

  9. 來到第 10 行。在全局上下文中,咱們定義了新的變量 sum。並分配了一個臨時的值 undefined

  10. 接下來,咱們須要執行函數。哪個函數呢?就是名爲 adder 的函數。咱們在全局上下文中查找它,能夠保證必定能找到它。這個函數須要兩個參數

  11. 咱們獲得了兩個參數,並把它們傳遞了函數。第一個是變量 val,咱們在第 1 步定義的,它的值是數字 7,第二個參數是數字 8

  12. 如今,咱們來調用函數。這個函數是在第 3 - 5 行被定義的。一個新的本地上下文被建立。在這個上下文中有兩個新的變量:ab。它們的值分別是 78,這就是咱們在上一步傳遞給函數的。

  13. 第 4 行。名爲 ret 的變量被聲明。它只存在本地上下文中。

  14. 第 4 行。咱們把變量 ab 相加。相加後的結果(15)賦值給了變量 ret

  15. 變量 ret 經過函數返回。隨之,與之相關的本地上下文被銷燬,也從調用棧中被移除,變量 abret 也不存在了

  16. 返回的值被賦值給咱們在第 9 步定義的變量 sum

  17. 最後,咱們在控制檯輸出了 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. 第 1 - 8 行。咱們在全局上下文中建立了新的函數變量 createCounter

  2. 第 9 行。咱們在全局上下文中聲明瞭變量 increment

  3. 仍是第 9 行。咱們須要調用函數 createCounter,並把結果賦值給你變量 increment

  4. 第 1 - 8 行。調用函數期間,會建立新的本地上下文。

  5. 第 2 行。在本地上下文中聲明瞭變量 counter。默認值是數字 0

  6. 第 3 - 6 行。聲明瞭名爲 myFunction 的變量。這個變量是在本地上下文中聲明的。這個變量也是一個函數。第4 - 5 行就是相應的函數體。

  7. 第 7 行。直接放回了函數 myFunction。本地上下文銷燬。myFunctioncounter 也伴隨被銷燬。從新回調了調用上下文。

  8. 第 9 行。在調用上下文中,也就是全局上下文,createCounter 返回的值賦值給了變量 increment。此時的變量就是一個函數。這個函數是由 createCounter 返回的。雖然,不是 myFunction,可是,函數體內容是一致的。在全局上下文中,它就是 imcrement

  9. 第 10 行。聲明新變量(c1

  10. 第 10 行。調用了函數 increment。這個函數是早期在第 4 - 5 行中定義的

  11. 建立新的上下文。只是執行函數,並無參數。

  12. 第 4 行。counter = counter + 1。在本地上下文中查找變量 counter。咱們只會建立上下文,絕對不會聲明任何本地變量。咱們看一下全局上下文。並無變量 counter。所以,剛纔的表達式等同於 counter = undefined + 1,聲明一個本地變量 counter,並給它賦值數字 1,由於,undefined 有點相似 0

  13. 第 5 行。咱們返回了 counter 的值,也就是數字 1。同時,銷燬本地上下文和變量 counter

  14. 回到第 10 行。返回的值(1)賦值給了 c1

  15. 第 11 行。重複第 10 - 14 步,c2 也獲得數字 1

  16. 第 12 行。重複第 10 - 14 步,c3 也獲得數字 1

  17. 第 13 行。咱們打印變量 c1c2c3 的值

本身試一下,看看會發生什麼。你會看到,並不會像我上面說的那樣輸出 111。而是,輸出了 123。爲何?

莫名其妙,函數 increment 記住了 counter 的值。它是如何作到的?

難道 counter 是全局上下文的一部分?試着在控制檯打印 console.log(counter),你會看到輸出 undefined。這說明它並不在全局上下文中。

或許,當你調用 increment 時,做用域回到了函數被建立的地方(createCounter)?怎麼會呢?變量 increment 只是有着相同的函數體,並非 createCounter。所以,也不對。

所以,必然有另一種機制。就是閉包。咱們最終說到它了。

如下就是它的工做模式。每當你聲明一個新函數,並把它賦值給一個變量,用來存儲函數的定義,這就是閉包。閉包包含建立函數時做用域內的全部變量。這就相似一個揹包。函數定義時附帶一個小揹包。這個揹包存儲了建立函數時做用域中全部的變量。

所以,以上的分析是錯誤的,咱們從新正確的分析一次。

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. 第 1 - 8 行。咱們在全局上下文中建立了新的函數變量 createCounter。和上次同樣

  2. 第 9 行。咱們在全局上下文中聲明瞭變量 increment。和上次同樣

  3. 仍是第 9 行。咱們須要調用函數 createCounter,並把結果賦值給你變量 increment。和上次同樣

  4. 第 1 - 8 行。調用函數期間,會建立新的本地上下文。和上次同樣

  5. 第 2 行。在本地上下文中聲明瞭變量 counter。默認值是數字 0。和上次同樣

  6. 第 3 - 6 行。聲明瞭名爲 myFunction 的變量。這個變量是在本地上下文中聲明的。這個變量也是一個函數。第4 - 5 行就是相應的函數體。如今,我建立了一個閉包,它是函數的一部分。閉包包含當前做用域的中的變量,在這個示例中變量是 counter(它的值是0)。

  7. 第 7 行。直接放回了函數 myFunction。本地上下文銷燬。myFunctioncounter 也伴隨被銷燬。從新回調了調用上下文。所以,咱們獲得了一個函數和閉包,這個揹包中包含了函數定義時做用域中的全部變量。

  8. 第 9 行。在調用上下文中,也就是全局上下文,createCounter 返回的值賦值給了變量 increment。此時的變量就是一個函數(也包括閉包)。這個函數是由 createCounter 返回的。雖然,不是 myFunction,可是,函數體內容是一致的。在全局上下文中,它就是 imcrement

  9. 第 10 行。聲明新變量(c1

  10. 第 10 行。調用了函數 increment。這個函數是早期在第 4 - 5 行中定義的。(而且變量也有一個揹包)

  11. 建立新的上下文。只是執行函數,並無參數。

  12. 第 4 行。counter = counter + 1。咱們須要查詢變量 counter。在此以前,我在本地或者全局上下文中查找,此次,咱們來看一下揹包,閉包。你瞧,閉包中包含一個名爲 counter 的變量,它的值是 0。通過第 4 行的計算後,它的值變成 1。它也從新存儲在揹包中。這時閉包中的變量 counter 值變成了 1

  13. 第 5 行。咱們返回了 counter 的值,也就是數字 1。同時,銷燬本地上下文。

  14. 回到第 10 行。返回的值(1)賦值給了 c1

  15. 第 11 行。重複第 10 - 14 步。此次,當咱們查看閉包時,看到變量 counter 的值是 1。這是由於第 12 步致使的。這時,它的值再次被遞加獲得了 2,並存儲在閉包中。同時,c2 的值也是 2

  16. 第 12 行。重複第 10 - 14 步,c3 也獲得數字 3

  17. 第 13 行。咱們打印變量 c1c2c3 的值

如今,咱們已經理解了它是如何工做的了。關鍵點在於,當函數被聲明時,它同時包含函數體和一個閉包。這個閉包會收集建立函數時做用域中的全部變量。

你或許會問,全部的函數都有閉包嗎?即便,是在全局做用域中聲明?是的。全局做用建立的函數也會建立閉包。可是,因爲函數是在全局做用域中被建立,所以,它們能夠訪問全局做用域中全部的變量。這並不徹底跟閉包有關。

當函數返回一個函數時,閉包的就比較重要了。返回的函數可訪問那些不在全局做用域,只存在於閉包中的變量。

非同小可的閉包

有時,在你不經意間就會出現閉包。你或許在咱們應用中看到過相似的代碼。

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

總結

爲了記住閉包,我把它比喻爲揹包。當一個函數被建立並傳遞或者經過另一個函數返回,它就會包含一個揹包。揹包中包含函數聲明時做用域中全部的變量。

若是,你喜歡這篇文章,不要吝嗇你的讚賞 👏

謝謝

相關文章
相關標籤/搜索