承接上一章透過立即函式(IIFE, Immediately Invoked Function Expression)的解決方法:

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 一起被回收。

這種把外層變數包在內層使用的手法,就是閉包。

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

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 函式建立時便被宣告、賦值,並在圖片讀取的 onloadonerror 時透過事件監聽 counter 逐次減 1,最後判斷當 count 為 0 時 resolve 回傳的 Promise 物件。

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

執行環境#

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

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

範例一:無閉包的情境#

參考以下的範例程式:

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

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

  • 建立 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
  • 建立 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:0;接著呼叫 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 的參照使用,直到參照消失,其佔用的記憶體才會跟著一起釋放。

在使用閉包時需要注意,冗餘的閉包只會造成記憶體的負擔。

延伸閱讀#

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

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

結語#

從事件迴圈的範例程式出發,深入 JavaScript 著名特性——閉包,並藉由理解執行環境與作用域鏈,逐步拆解閉包函式的執行過程。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10220557