iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 9
0

banner

昨天我們聊到到了 JavaScript 中的事件迴圈,文中末段提到了透過 IIFE 的解決方法:

for (var i = 1; i <= 5; i++) {
  (function (x) {
    setTimeout(function () {
      console.log(x)
    }, 1000 * x)
  })(i)
}

仔細想想蠻奇怪的對吧?原本的版本中,console.log() 都指到同一個變數 i,為什麼在經過一層函式後,當下 i 的數值就能被保留住呢?

這就關係到今天的主題 - 閉包(Closure)

Closure

閉包這個名詞,對稍有經驗的開發者應該都不陌生,但具體來說是指什麼呢?

一如既往的,讓我們從範例程式出發:

function add(num) {
  function func(x = 0) {
    return num + x
  }
  return func
}
let addFive = add(5)
console.log(addFive(8)) // 8 + 5 = 13

add 是一個接收參數 num、回傳函式 func 的函式。由於 JavaScript 有自動回收機制,理論上在函式執行完畢後會將函式所佔用的記憶體空間釋放;但在此處的範例中,可以看到參數 numadd 執行完畢後,仍然可以被回傳出來的函式使用,沒有跟著 add 一起被回收掉。

這種把外層變數包在內層使用的方法,也就是耳熟能詳的閉包。

初學者可能會對「回傳函式的函式」感到有些困惑,但從 JavaScript 的基本型別來看是非常正常的事情;更詳細的說明會在後續系列文中提到,這邊暫且先大概知道就好囉。

讓我們再看一個稍微複雜的例子:

loadPicture() {
  let count = this.pageContent.length
  const load = (target, resolve) => {
    const counter = () => (--count ? count : resolve())
    let img = new Image()
    img.onload = counter
    img.onerror = counter
    img.src = target.url
  }
  return new Promise(resolve => {
    if (!count) return resolve()
    this.pageContent.forEach(target => load(target, resolve))
  })
}

注意觀察 count 變數,它在整個 loadPicture 函式建立時便被宣告、賦值,並在圖片讀取的 onload、onerror 時透過事件監聽 counter 逐次減 1,最後判斷當 count 為 0 時 resolve 回傳 的 Promise 物件。

閉包發生的時機是在函式建立的時候,每當新的函式被建立出來,它會紀錄它所在的位置的 執行環境,並記錄外層的 作用域鏈

執行環境

剛提到了執行環境(Execution Context,EC),EC 指的是 Javascript 底層在程式準備執行時,針對「全域」及「函式」所建立的一個物件,主要是儲存了:

  • 內部的變數(Variable Object,VO;這也就是 Hoisting 發生的原因!)
  • 外部環境 & 作用域鏈(Scope Chain)
  • 這個環境的 this 值

YDKJS

借一下JavaScript: Understanding the Weird Parts 的課程影片截圖;這部課程超好看,大大大大推!

範例一:無閉包的情境

同樣的,參考以下的範例程式:

var a = 0
function b() {
  var a = 10
  function c() {
    console.log(a)
  }
  c()
}
b() // 10

可以想像 EC 會長成這樣:

EC 1

當執行時,依序會進行下面的事情:

  • 建立 Global EC ,預留了變數 a 的空間,及函式 b 的作用域鏈
  • 變數 a 賦值 a = 0,接著呼叫函式 b()
  • 準備函式 b,建立 function b() EC,預留了區域變數 a 的空間,及函式 c 的作用域鏈
  • 執行 b(),區域變數 a 賦值 a = 10,接著呼叫函式 c()
  • 準備函式 c,建立 function c() EC,並依照作用域鏈找到 function b() EC 中的區域變數 a
  • 執行 c(),呼叫 console.log(a);印出 10
  • c() 執行結束,消除 function c() EC
  • b() 執行結束,消除 function b() EC
  • 程式執行結束

一般的情境中,依照呼叫的順序、依序建立 EC,並在執行完成後將 EC 消除,釋放記憶體空間。那麼當閉包發生時 EC 會有什麼改變呢?

範例二:有閉包的情境

修改前述的例子,讓函式 b 回傳函式 c

var a = 0
function b() {
  var a = 10
  function c() {
    console.log(a)
  }
  return c
}
var func = b()

console.log(a) //  0
func() // 10

EC 2

  • 建立 Global EC,預留了變數 afunc 的空間,及函式 b 的作用域鏈
  • 執行 Global EC,變數 a 賦值 a = 0,呼叫函式 b()
  • 準備函式 b,建立 function b() EC,預留了區域變數 a 的空間,及函式 c 的作用域鏈
  • 執行 b(),區域變數 a 賦值 a = 10,回傳函式 c
  • 函式 b 執行結束,消除 function b() EC

執行到這時,function b() EC 內的 a 被回傳的函數 c 閉包了!

  • 變數 func 賦值成函式 b() 的執行結果 - 函式 c
  • 執行 console.log(a),印出 Global EC 中的 a: 1;接著呼叫 func()
  • 準備函式 c,建立 function c() EC,並依照作用域鏈找到 function b() EC 中的區域變數 a
  • 執行 func(),執行 console.log(a);印出 10
  • func() 執行結束,消除 function c() EC
  • 程式執行結束

由於閉包,在 b() 執行結束時,其中的區域變數 a 並未跟著 function b() EC 一起消失,而是留給了 function c() EC 的參照使用,直到參照消失,其佔用的記憶體才會跟著一起釋放。因此在使用閉包時需要注意,冗餘的閉包只會造成記憶體的負擔!

延伸閱讀

「閉包」這個詞其實有許多種定義,本文撰寫時所採用的說法,是較偏向實際開發時會考慮的情境,也就是將「內層函式引用外層參數」的行為稱呼為閉包。

但在 MDNWiki 中都有提到類似的定義:「閉包是由函式和與其相關的參照環境組合而成的實體」,而實際上在 JavaScript 底層的行為中,每一個函式建立時都會紀錄它所在的作用域環境,也因此可以說,所有函式是閉包。

更詳細的說明,可以參考 這裡這裡

結語

昨天 提過的範例程式出發,我們深入到了 JavaScript 中著名的特性 - 閉包,並藉由理解執行環境及作用域鏈,進一步的逐步拆解閉包函式的執行過程。

認識了執行環境之後,JavaScript 中許多晦澀難懂的主題,也就漸漸會變得沒那麼難以理解喔!那麼今天就先到這邊吧,我們大家明天見~

參考資料

筆者

Gary

半路出家網站工程師;半生熟的前端加上一點點的後端。
喜歡音樂,喜歡學習、分享,也喜歡當個遊戲宅。

相信一切安排都是最好的路。


上一篇
08. [JS] 請寫出間隔一秒印出 1, 2, 3, 4, 5 的程式碼。
下一篇
10. [JS] 一般函式與箭頭函式的差異?
系列文
前端三十 - 成為更好的前端工程師31

1 則留言

0
Ashe Li
iT邦新手 5 級 ‧ 2019-09-26 08:12:22

可以問一下文章提到的「 You don't know JS 的課程影片」,可以提供連結或reference嗎~

Gary iT邦新手 5 級‧ 2019-09-26 09:56:32 檢舉

啊抱歉我附註錯了;圖片是來自 JavaScript: Understanding the Weird Parts 的課程片段。

謝謝你的回應~

Ashe Li iT邦新手 5 級‧ 2019-09-26 23:15:33 檢舉

哈哈,因為我想說我寫的是 You don't know JS ,怎麼沒看過這個 XD

我要留言

立即登入留言