medium 五萬贊好文-《我永遠不懂 JS 閉包》

正如《你不知道的 JavaScript》書中所言:javascript

閉包就好像從 JavaScript 中分離出來的一個充滿神祕色彩的未開化世界,只有最勇敢的人才能到達那裏!前端

在實際的開發工做中也確實如此,除了在面試的場景下,或其它幾個少數特定的場景下(如「防抖節流」函數),咱意識到了 —— 這就是「閉包」!其它時候基本不用,或者是用了殊不知道。java

多麼惋惜!!這麼好的東西用了卻不自知。web

本篇藉助 medium 上的五萬贊好文 I never understood JavaScript closures 帶你一次吃透「閉包」!(吃不透找我,找耶穌也沒用,我說的 QAQ)面試

image.png

看完本篇,你會驚奇的發現,居然連如下這段代碼都存在着閉包?!瀏覽器

let a = 1
function b(){
    return a
}
console.log(b())
複製代碼

「我永遠不懂 JS 閉包,由於它無處不在......」markdown

執行上下文

認知「閉包」前,咱得了解一個重要的概念 —— 「執行上下文」閉包

執行上下文分爲:app

  • 全局執行上下文(Global execution context):當 JS 文件加載進瀏覽器運行的時候,進入的就是全局執行上下文。全局變量都是在這個執行上下文中。代碼在任何位置都能訪問。
  • 函數執行上下文(Functional execution context):定義在具體某個方法中的上下文。只有在該方法和該方法中的內部方法中訪問。

好比咱們在全局執行上下文中調用一個函數的時候,JS 解析的流程大概會是這樣的:ide

  1. JS 建立一個新的函數執行上下文(能夠理解爲一個臨時的「執行上下文」),它有局部可訪問的變量集;
  2. 該執行上下文將被放到【執行棧】裏去執行(這裏將【執行棧】視爲一種跟蹤程序執行位置的機制);
  3. 當遇到 return} 時,斷定爲執行結束。
  4. 該執行上下文在執行棧彈出;
  5. 被執行的函數會將返回值發送給調用它的執行上下文,這裏就是發送給全局執行上下文;
  6. 函數執行上下文將被銷燬,它的變量集將不能再被訪問到,這也是爲何它被稱爲臨時的「執行上下文」的緣由;

關於執行棧的操做,可見下面的動圖理解:

咱們再結合下面這段代碼舉例解析,來看看 JS 一步一步究竟是怎麼作的:

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)
複製代碼
  1. 第一行,在全局執行上下文聲明瞭一個變量 a,賦值爲 3;

  2. 第二行到第五行是函數執行上下文。是在全局執行上下文聲明瞭一個 addTwo 的函數,函數內部的代碼不作執行,只是存儲着以供後面調用;

  3. 第六行,聲明瞭一個變量 b,賦值 b 爲 addTwo 函數執行的返回值;

  4. 在全局執行上下文找到 addTwo 函數進行執行,並傳入參數 3 ;

  5. 此時,執行上下文會進行切換!建立一個臨時的名爲 addTwo 的函數執行上下文,推到執行棧當中;

  6. 到第三行,而後是怎樣?建立一個變量 ret 嗎?不對,實際上,是先建立一個變量 x 而後賦值爲 3 ;

  7. 第三行,聲明一個變量 ret ,而後賦值爲 x + 2 的運算結果。

  8. JS 會找加法的項 x 在哪?原來 x 在 addTwo 這個函數執行上下文就已經有了,它的值是 3 ,接着與 2 執行加法運算,而後賦給 ret。

  9. 而後來到了第四行,將 ret 進行 return 返回;

  10. 第4、第五行,addTwo 函數執行結束,臨時的執行上下文被銷燬;變量 x 和變量 ret 都會被清除;函數執行上下文將被在調用棧被推出,而後把函數返回給調用它的執行上下文,這裏也就是全局執行上下文;

  11. 而後回到第 4 點,將函數返回值賦值給 b;

  12. 第七行,進行打印;

這裏雖然是一段很是冗長的解釋,且還並未涉及到「閉包」,可是倒是咱們必需要寫在前面而且理解透徹的。

詞法做用域

接着「執行上下文」,還有一個概念咱們須要理解,它就是 「詞法做用域」

來看下面這段代碼示例:

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)
複製代碼

你能參照上一節的解釋對這段代碼進行描述嗎?

  1. 第一行,在全局執行上下文聲明瞭一個變量 vall,賦值爲 2;

  2. 第二行至第五行聲明一個 multiplyThis 函數執行上下文,內部代碼不作執行,存儲以供調用;

  3. 第六行,聲明一個變量 multiplied,賦值爲 multiplyThis 函數執行的返回值;

  4. 在全局執行上下文找到 multiplyThis 函數進行執行,並傳入參數 6 ;

  5. 此時,執行上下文會進行切換!建立一個臨時的名爲 multiplyThis 的函數執行上下文,推到執行棧當中;

  6. 到第三行,在函數執行上下文聲明一個變量 n ,並賦值爲 6;

  7. 第三行,聲明一個變量 ret ,而後賦值爲 n 與 vall 作乘法的運算結果。n 爲 6,vall 呢?在當前函數執行上下文並未找到 vall!

  8. 此時,JS 會去調用 multiplyThis 函數的全局執行上下文尋找 vall!找到了!它的值是 2,6 * 2 = 12。賦給 ret;

  9. 而後來到了第四行,將 ret 進行 return 返回;

  10. 第4、第五行,multiplyThis 函數執行結束,臨時的執行上下文被銷燬,變量 n 和變量 ret 都會被清除,可是 vall 沒有被銷燬,由於它存在於全局函數執行上下文;

  11. 回到第六行,將返回值 12 賦給變量 multiplied;

  12. 最後打印輸出;

這段描述中,置灰的步驟就是和上一節的描述基本一致,未置灰的是

最重要的是:JS 在當前執行上下文尋找變量的時候,若是找不到,就會去調用這個執行上下文的上一執行上下文去尋找。

這並不難理解,這樣鏈式查找變量的過程,就是 JS 的【做用域鏈】。

函數返回函數

函數能夠返回任何東西,固然也就包括返回另外一個函數了。

讓咱們再按照以上解析一遍如下代碼:

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. 第二行至第八行聲明一個 createAdder 函數執行上下文,內部代碼不作執行,存儲以供調用;

  3. 第十一行,聲明一個變量 adder,賦值爲 createAdder 函數執行的返回值;

  4. 在全局執行上下文找到 createAdder 函數進行執行,它在第二行,ok,調用;

  5. 來到第二行,建立一個新的臨時的函數執行上下文,將其推倒執行棧;

  6. 因爲 createAdder 函數沒有傳參,直接進入函數體內,這裏聲明瞭一個函數 addNumbers,只作聲明,不作執行;

  7. 來到第七行,將 addNumbers 返回到全局執行上下文,它是一個函數;而後將臨時 createAdder 函數執行的上下文推出執行棧;

  8. createAdder 函數執行上下文被銷燬,此時 addNumbers 也將不存在;

  9. 再到第十行,在全局執行上下文聲明一個變量 sum,它的值爲 adder(val, 8) 執行的返回值;

  10. 咱們再到全局執行上下文尋找 adder ,找到了,它正好是一個函數,咱們能夠調用它;

  11. 他有兩個傳參,第一個是 val,它在第一行聲明瞭並複製了,爲 7,第二個參數是 8;

  12. 而後咱們來到第三行到第五行,建立兩個變量 a 和 b ,爲他們賦值分別爲 8 和 7;

  13. 第四行,聲明一個變量 ret ,賦值爲 8 + 7,爲 15;

  14. ret 變量被 return ,臨時的函數執行上下文被銷燬,a ,b ,ret 變量都將被銷燬;

  15. adder 函數執行的返回值賦給 sum 變量;

  16. 最後打印輸出;

主角閉包!!!

看了以上三段的具體步驟詳細分析,我相信再給你一段相似的調用代碼,你也必定能通曉其中端倪,做出相似的解析!

1:let counter
 2: function createCounter() {
 3:   let counter = 0
 4:   const myFunction = function() {
 5:     counter = counter + 1
 6:     return counter
 7:   }
 8:   return myFunction
 9: }
 10: const increment = createCounter()
11: const c1 = increment()
12: const c2 = increment()
13: const c3 = increment()
14: console.log('example increment', c1, c2, c3)
複製代碼

來試試吧?

  1. 第一行,聲明一個變量 counter ,賦值爲 undefined ;

  2. 第二行至第九行,在全局執行上下文聲明瞭一個函數 createCounter ,不作執行,存儲以供調用;

  3. 第十行,在全局執行上下文聲明瞭一個變量 increment ,賦值爲 createCounter 函數執行的返回值;

  4. 調用 createCounter 函數,它在第 2 步已經聲明瞭,執行它!

  5. 第二到九行,建立一個新的臨時的函數執行上下文;

  6. 在這個臨時的函數執行上下文聲明一個變量 counter,賦值爲 0;

  7. 第四到七行,聲明一個 myFunction 函數,不作執行,存儲以供調用;

  8. 將 myFunction 進行返回,賦給變量 increment。createCounter 函數執行上下文被銷燬,myFunction 和 counter 都將被銷燬;

  9. 此時的全局執行上下文沒有 myFunction 函數了,只有 increment 函數;

  10. 第十一行,聲明一個變量 c1 , 賦值爲 increment 函數執行的返回值;

  11. increment 函數是 createCounter 函數執行的返回值,它在第四行到第七行被定義;

  12. 建立一個新的函數執行上下文,沒有傳參,直接進入函數內部進行執行;

  13. 第五行,counter = counter + 1,counter 變量在新建立的函數執行上下文找的到嗎?找不到!只得回到調用它的全局執行上下文去尋找,在全局執行上下文 counter 爲 undefined,因此執行 counter = undefined + 1

  14. 第六行,返回 counter,值爲 1,銷燬新函數執行上席文;

  15. 回到第十一行,c1 賦值爲 1;

  16. 第十二行,重複步驟第 10 到第 14 步,c2 同理賦值爲 1;

  17. 第十三行,重複步驟第 10 到第 14 步,c3 同理賦值爲 1;

  18. 最終打印輸出;

到底是這樣的嗎? 咱們在控制檯打印:

image.png

結果居然與咱們分析的指望打印相悖!

發生了什麼?

必定還有一個神祕的東西在做用!沒錯,它就是 「閉包」!the missing piece !

它的原理是這樣的:

當咱們聲明一個函數時,存儲以供調用,存儲的不只僅是這個函數的定義,同時還有這個函數的「閉包」,閉包包括了這個函數執行上下文全部變量的詞法做用域。這些在函數被建立時就已經肯定下來了。

作個不太恰當的比喻,把函數理解爲一我的,當這人生下來的時候(函數建立時),也附贈了一個揹包(閉包),這個揹包包括了家庭環境(詞法做用域)。

因此咱們上一段的逐步分析是錯的!

咱們再來進行一次正確的分析!

1:let counter
 2: function createCounter() {
 3:   let counter = 0
 4:   const myFunction = function() {
 5:     counter = counter + 1
 6:     return counter
 7:   }
 8:   return myFunction
 9: }
 10: const increment = createCounter()
11: const c1 = increment()
12: const c2 = increment()
13: const c3 = increment()
14: console.log('example increment', c1, c2, c3)
複製代碼
  1. 第一行,聲明一個變量 counter ,賦值爲 undefined ;(同上)

  2. 第二行至第九行,在全局執行上下文聲明瞭一個函數 createCounter ,不作執行,存儲以供調用;(同上)

  3. 第十行,在全局執行上下文聲明瞭一個變量 increment ,賦值爲 createCounter 函數執行的返回值;(同上)

  4. 調用 createCounter 函數,它在第 2 步已經聲明瞭,執行它!(同上)

  5. 第二到九行,建立一個新的臨時的函數執行上下文;(同上)

  6. 在這個臨時的函數執行上下文聲明一個變量 counter,賦值爲 0;(同上)

  7. 第四到七行,聲明一個 myFunction 函數,同時還會建立一個閉包,包括這個函數執行上下文全部變量的詞法做用域。此例中就是 counter,經過做用域鏈查找,它的值爲 0這裏一樣不作執行,存儲以供調用;

  8. 將 myFunction 和它的閉包 一塊兒進行返回,賦值給 變量 increment。createCounter 函數執行上下文被銷燬,myFunction 和 counter 都將被銷燬;

  9. 此時的全局執行上下文沒有 myFunction 函數了,只有 increment 函數;(同上)

  10. 第十一行,聲明一個變量 c1 , 賦值爲 increment 函數執行的返回值;(同上)

  11. increment 函數是 createCounter 函數執行的返回值,它在第四行到第七行被定義;(同上)

  12. 建立一個新的函數執行上下文,沒有傳參,直接進入函數內部進行執行;(同上)

  13. 第五行,counter = counter + 1,counter 變量在新建立的函數執行上下文找的到嗎?找不到!咱們要去它的「閉包」裏面找一找!,居然是有的!它的值爲 0 ,那麼就能夠執行 counter = 0 + 1,等於 1;

  14. 第六行,返回 counter,值爲 1,銷燬新函數執行上席文;(同上)

  15. 回到第十一行,c1 賦值爲 1;(同上)

  16. 第十二行,重複步驟第 10 到第 14 步,咱們再去「閉包」裏找的時候,由於通過上一步的操做 counter 已經變爲 1 了,因此再執行加法運算,counter 結果爲 2

  17. 第十三行,重複步驟第 10 到第 14 步,c3 同理賦值爲 3;

  18. 最終打印輸出;

噢~原來是這樣!

當一個函數聲明的時候不僅僅只作了聲明,後面還帶着個「閉包」呢!閉包裏裝的是這個函數執行上下文全部變量的詞法做用域!

原來這就是閉包!

你可能會疑問:只要一個函數進行了聲明,就包含了閉包,那全局函數是否是也有閉包呢?

答案是:YES !

全局函數聲明時,也有閉包!只不過它的變量詞法做用域就是全局的,因此不是「閉」的不是很明顯。

當一個函數返回另一個函數時,「閉包」是最明顯的!返回的函數的變量詞法做用域能夠訪問非全局範圍的變量,它們僅放在其閉包中!

測驗

這裏再放幾道閉包題目,供你們自查測驗:

// 題目一
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)


// 題目二

function multiply(number1, number2) {
  if (number2 !== undefined) {
    return number1 * number2;
  }
  return function doMultiply(number2) {
    return number1 * number2;
  };
}

multiply(4, 5); 
const double = multiply(2);
double(5);

// 題目三

function showBiBao() {
    for (var i = 0; i < 5; i++) {
      setTimeout( timer => () {
          console.log(i);
      }, 1000 );
    }
    console.log(i)
}

showBiBao()

function showListNumber() {
    for(var i = 0; i < 5; i++) {
        let ret = function(i) {
            setTimeout(function timerr() {
                console.log(i)
            }, 1000)
        }
        ret(i)
    }
    console.log(i)
}
showListNumber()
複製代碼

我相信若是你從頭到尾認真跟着分析步驟來了,這必定不算啥大問題~

歡迎討論 ~

總結

經過本篇咱們知道了:只要有函數申明的地方就有閉包!只不過有時候「閉」的不那麼明顯。

爲何題目說《我永遠不懂 JS 閉包》呢?其實你也看到了,開發工做中,即便你沒有用到閉包或者根本不認識閉包也同樣摸魚打卡上下班。但問題的關鍵是閉包嗎?

不!

你覺得本篇是在講閉包?錯!本篇是在講執行上下文!

你覺得本篇是在講執行上下文?錯!本篇是在講詞法做用域!

你覺得本篇是在講詞法做用域?錯!本篇是在講 JS 的動態語言特性!

你一問本篇是在講 JS 的動態語言?錯!本篇是在講 JS 運行的單線程特性!

......

沒錯,哎,就是玩兒~

v2-cc922db944e651867447654f0858d44f_b.webp

撰文不易✍,點贊鼓勵👍,你的反饋💬,個人動力🚀

我是掘金安東尼,關注公衆號【掘金安東尼】,關注前端技術,也關注生活,持續輸出ing!

相關文章
相關標籤/搜索