JavaScript閉包的那些事~

MomentYY發表於2022-02-09

JavaScript閉包

1.函式在JavaScript中的地位

在介紹閉包之前,可以先聊聊函式在JavaScript中的地位,因為閉包的存在是與函式息息相關的。

  • JavaScript之所以可以稱之為支援頭等函式的程式語言,是因為JavaScript中函式是一等公民
  • 函式不僅在JavaScript中扮演著重要的角色,而且可以使用的非常靈活;
  • 函式不僅可以作為另一個函式的引數,也可以作為另一個函式的返回值
  • 這樣使用的函式也稱之為高階函式,像JS的陣列中就實現了許多高階函式(map、filter、reduce等);

2.JavaScript中閉包的定義

閉包的概念出現於60年代,最早實現閉包的程式是Scheme,那麼就可以理解為什麼JavaScript中有閉包了,因為JavaScript中大量的設計是源自於Scheme的。而在不同的地方對JavaScript閉包的定義是不一樣的,但是整體核心還是一致的,只是用不同的話來描述JavaScript閉包,以下是摘抄自三個地方的定義。

維基百科中對閉包的定義:

  • 閉包(Closure),又稱詞法閉包(Lexical Closure)或函式閉包(function closures),是在支援頭等函式的程式語言中,實現詞法繫結的一種技術;
  • 閉包在實現上是一個結構體,它儲存了一個函式一個關聯的環境(相當於一個符號查詢表);
  • 閉包跟函式最大的區別在於,當捕捉閉包的時候,它的自由變數會在捕捉時被確定,這樣即使脫離了捕捉時的上下文,它也能照常執行;

MDN中對閉包定義:

  • 一個函式和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函式被引用包圍),這樣的組合就是閉包(closure);
  • 也就是說,閉包讓你可以在一個內層函式中訪問到其外層函式的作用域
  • 在JavaScript中,每當建立一個函式,閉包就會在函式建立的同時被建立出來;

《JavaScript高階程式設計》中對閉包的定義:

  • 閉包指的是那些引用了另一個函式作用域中變數的函式,通常是在巢狀函式中實現的;

對閉包定義的總結:

  • 以上對閉包的三種定義,都提到了函式、環境、作用域和變數,總結為就是:一個函式,如果它可以訪問外層作用域自由變數,那麼這個函式就是一個閉包;
  • 閉包由兩部分組成:內層函式+可以訪問的外層自由變數
  • 廣義角度:JavaScript中的函式都是閉包(都可以形成閉包);
  • 狹義角度:JavaScript中的一個函式,如果訪問了外層作用域的變數,那麼它是一個閉包;

3.閉包是如何形成的?

看了一大堆閉包的定義,那麼到底什麼情況下就形成了閉包呢?

(1)產生閉包的條件:簡單來說,滿足以下幾個條件就可以說產生了閉包。

  • 函式巢狀;
  • 內層函式引用了外層函式作用域中的變數;
  • 外層函式執行;

(2)常見的閉包。

  • 將一個函式作為另一個函式返回值,例如:

    function foo() {
      var name = 'foo'
    
      return function bar() {
        console.log(name)
      }
    }
    
    var fn = foo()
    fn()
    
  • 將一個函式作為實參傳遞另一個函式,例如:

    function showDelay(msg) {
      setTimeout(function() {
        console.log(msg)
      })
    }
    
    showDelay('我形成了閉包')
    

4.閉包的訪問和執行過程

下面介紹閉包在訪問和執行過程中的記憶體表現,進一步深入對閉包的瞭解,以如下程式碼為例:

示例程式碼:

function foo() {
  var name = 'foo'

  return function bar() {
    console.log(name)
  }
}

var fn = foo()
fn()
  • 首先,在執行全域性程式碼之前,會在記憶體中建立一個全域性物件(GO),將全域性執行上下文壓入棧中,這時的fn還未被賦值;

  • 當執行到var fn = foo()時,在呼叫foo之前建立foo的活動物件(AO),建立foo函式執行上下文,並將其壓入棧中,接著執行foo函式,執行完成後fn指向bar函式記憶體地址;

  • foo函式執行完成後,foo函式執行上下文會彈出棧,而按道理foo的活動物件(AO)是需要被銷燬的,那到底有沒有銷燬,我們接著看;

  • 接著執行fn(),因為fn是指向bar函式的,執行之前會先建立bar的活動物件(AO),然後執行console.log(name),而name會先去自己的AO中查詢,發現沒有找到就會去到上層作用域(父級作用域)中查詢,最終找到foo並列印,這裡bar函式的上層作用域就是foo函式的作用域對應foo的活動物件(AO);

  • bar函式執行完成後,bar函式的執行上下文彈出棧,對應bar的活躍物件(AO)被銷燬,而foo的活躍物件(AO)還一直存留在記憶體中;

  • 但是bar函式的父級作用域是在什麼時候確定的呢?

    • 在編譯bar函式時就已經確定了bar函式的父級作用域——foo的活動物件(AO)早在編譯時就加入到了bar函式的作用域鏈中;
    • 為什麼執行完foo函式後還可以訪問其name變數,就可以回答上面的問題了,foo的活動物件(AO)是沒有被銷燬的;
    • 因為bar函式的作用域鏈中依然對foo的活動物件(AO)有引用,導致其不能正常銷燬;

根據上面閉包的訪問和執行過程結合閉包的定義做一個總結:

  • 在維基百科中定義的“閉包在實現上是一個結構體,它儲存了一個函式和一個關聯的環境(相當於一個符號查詢表)”,以及MDN中定義的“一個函式和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函式被引用包圍),這樣的組合就是閉包(closure)”,其函式和關聯的環境、函式和對其周圍狀態的引用,對應的就是上面的bar函式和上層作用域中的name;

  • 而對於維基百科中提到的“閉包跟函式最大的區別在於,當捕捉閉包的時候,它的自由變數會在捕捉時被確定,這樣即使脫離了捕捉時的上下文,它也能照常執行”,也就是在捕捉bar函式時,同時捕捉到對name這個自由變數的引用,執行完foo函式後,將bar函式賦值給fn,最後執行fn時,也是能正常訪問到name的;

  • 瞭解了其訪問執行過程後,可以發現本應該被銷燬的foo的活躍物件(AO),在程式碼執行完後最終沒能被銷燬,而這樣的情況稱之為記憶體洩露,下面就來談談閉包的記憶體洩露;

  • 如果對上面的執行過程不清楚,可以先看看這篇文章:JavaScript的執行過程(深入執行上下文、GO、AO、VO和VE等概念)

5.閉包的記憶體洩露

閉包會保留它們包含函式的作用域,所以比其它函式更佔用記憶體,而過渡使用閉包可能導致記憶體過度佔用,也就是記憶體洩露,而除了記憶體洩露這個概念還有一個記憶體溢位的概念,下面就先了解一下這兩者的區別和關係:

  • 記憶體溢位:程式執行出現錯誤,當程式執行需要的記憶體超過了剩餘的記憶體時,就會丟擲記憶體溢位的錯誤(比如,死迴圈)。
  • 記憶體洩露:佔用的記憶體沒有及時釋放,記憶體洩露積累過多就容易導致記憶體溢位(比如,意外的全域性變數、沒有及時清理計時器或回撥、閉包

那具體怎麼解決閉包產生的記憶體洩露呢?

  • 針對於上面的程式碼,可以在最後執行fn = null
  • 因為將fn設定為null時,就不再對bar函式有引用,bar函式失去了全部的引用就會被銷燬,對應foo的活動物件(AO)也就失去了引用,在下一次的垃圾回收(GC)檢測中,就會被銷燬掉;

6.使用瀏覽器檢視閉包

閉包其實是可以在瀏覽器中觀察到的,在檢視閉包之前先來討論一個問題,外層函式的活躍物件(AO)不會被銷燬,是不是裡面所有的屬性都不會被銷燬呢?

如果將上面的程式碼改成下面這樣,多增加兩個變數age和message,但是bar函式中並沒有對age和message有引用:

function foo() {
  var name = 'foo'
  var age = 18
  var message = 'hello bibao'

  return function bar() {
    console.log(name)
  }
}

var fn = foo()
fn()
  • 形成閉包後,name是一定不會被銷燬的,這個上面已經驗證過了;

  • 具體age和message有沒有被銷燬,可以在程式碼中打上斷點,在Chrome瀏覽器檢視對應的閉包;

  • 觀察上面的結果是沒有age和message屬性的,這個就涉及到JS引擎的實現了,像V8引擎就對其進行了優化,對於閉包內層函式沒有使用到的自由變數,是不會被儲存的,這樣就大大提升了記憶體的使用率;

相關文章