承接上一章透過立即函式(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 有自動回收機制,理論上函式執行完畢後會釋放佔用的記憶體;但範例中,參數 num 在 add 執行完畢後仍能被回傳的函式使用,沒有跟著 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 函式建立時便被宣告、賦值,並在圖片讀取的 onload、onerror 時透過事件監聽 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,預留了變數a、func的空間,及函式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