Understand JavaScript #15 閉包 (Closure)
本文主要內容為探討「閉包」的相關知識,這是 JavaScript 的一個重要觀念,會用到我們之前學到的所有概念,包含一級函式、執行堆、執行環境等等。
暖身小劇場
以下是一個有趣的寫法:greet
函式會回傳一個匿名函式,所以呼叫 greet
會得到一個函式物件,我們可以再次呼叫這個被回傳的函式。
1function greet(whattosay) { 2 return function (name) { 3 console.log(whattosay + ' ' + name); 4 }; 5} 6 7greet('Hi')('Damao'); // Hi Damao 8
我們把呼叫的步驟拆開,先設一個變數 sayHi
作為呼叫 greet
之後回傳的函式,再去呼叫 sayHi
。
1var sayHi = greet('Hi'); 2sayHi('Damao'); // Hi Damao 3
此時會有一個問題,就是當 greet
函式執行完成後,執行環境會離開執行堆,然而當我們接著呼叫 sayHi
時,為什麼 sayHi
還能知道 greet
執行環境裡面的 whattosay
變數呢?
這一切就是因為有 Closure 這個特性。
什麼是閉包
我們都知道每一個執行環境,都有它自己的記憶體空間,裡面有我們創造的變數與函式。那麼如果沒了執行環境,記憶體空間會發生什麼事呢?
一般情況下 JavaScript 引擎會清除記憶體空間,這個動作稱為「垃圾回收 (Garbage Collection)」。
不過如果是「執行環境結束」的這個時候,則記憶體空間會繼續留在原地。也就是當 sayHi
函式找不到 whattosay
時,它仍然可以沿著範圍鏈,向外參考 greet
函式的執行環境的記憶體位置,從中找到 whattosay
變數。
JavaScript 引擎創造了閉包,讓執行環境可以把它的外部變數關住,只要是執行環境應該要能參考到的變數,即使執行環境已結束的,這些通通都可以包起來!
這種「包住所有可以取用的變數」的現象,就稱為閉包。
經典範例
閉包與自由變數
函式 buildFunction
會創造三個函式並放進 arr
陣列裡面,接著依序執行這三個函式,執行後發現需要 i
於是往外部參考尋找,我們預期結果可能是 0、1、2,但是卻出現全部都是 3 的結果。
1function buildFunction() { 2 var arr = []; 3 for (var i = 0; i < 3; i++) { 4 arr.push(function () { 5 console.log(i); 6 }); 7 } 8 return arr; 9} 10 11var fs = buildFunction(); // (3) [ƒ, ƒ, ƒ] 12 13fs[0](); // 3 14fs[1](); // 3 15fs[2](); // 3 16
為什麼向外尋找 i
的時候,會發現它們都一樣呢?
當函式 buildFunction
的執行環境結束時,最後的 i
經過 i++
變成 3,讓 for 迴圈無法繼續進行,而 arr
陣列裡面總共有三個函式,也因為閉包的特性 i
與 arr
都仍然存在記憶體中。
注意:執行 for 迴圈時,裡面的匿名函式並不會執行,這些函式此時只是被創造。
接著,當我們呼叫陣列裡的函式時,Code 屬性裡面的內容是 console.log(i)
,而它在自己的執行環境下找不到 i
,因此到範圍鏈尋找後,發現 i
等於 3,於是執行 console.log(3)
。
此外,當呼叫函式時,仍然可以被取用的這些外部變數,也被稱為「自由變數」。
使用 ES6 的 let 校正結果
如果要顯示 0、1、2 的結果,有兩個方法可以做到,第一種是使用 JavaScript ES6 的 let 變數。
在 let 屬於「大括號作用域」的情況下,每次 for 迴圈執行時的 j
都會是記憶體中的一個新的變數,於是在執行環境中有「不同的記憶體位置」,也就是每一個 j
在本質上都是不同的變數。
1function buildFunction2() { 2 var arr = []; 3 for (var i = 0; i < 3; i++) { 4 let j = i; // 大括號作用域 5 arr.push(function () { 6 console.log(j); 7 }); 8 } 9 return arr; 10} 11 12var fs2 = buildFunction2(); 13 14fs2[0](); // 0 15fs2[1](); // 1 16fs2[2](); // 2 17
在 ES5 使用 IIFE 校正結果
如果不要用 ES6 的 let,那在 ES5 有辦法解決嗎?根據剛才 let 的處理邏輯,我們必須給每個 i
不同的執行環境,讓它們有不同的記憶體位置。
然而,想要獲得不同的、新的執行環境的唯一方式,就只有「執行函式」這個方法了!
那麼如何在把一個個函式加入陣列時,就(立刻)執行函式呢?沒錯,只要使用 IIFE 就可以很簡單地做到這件事。
每次迴圈執行,都會立刻執行立即函式,創造一個執行環境,而 j
就會被存在這三個執行環境中,分別等於 0、1、2。
1function buildFunction2() { 2 var arr = []; 3 for (var i = 0; i < 3; i++) { 4 arr.push( 5 (function (j) { 6 return function () { 7 console.log(j); 8 }; 9 })(i) 10 ); 11 } 12 return arr; 13} 14 15var fs2 = buildFunction2(); 16 17fs2[0](); // 0 18fs2[1](); // 1 19fs2[2](); // 2 20
上方程式碼當中,每一次都是把函式的執行結果 Push 到陣列,此時 Push 進去的就是立即函式回傳的 Function。
最後執行 fs2[0]()
時會往外尋找 j
,並在立即函式的執行環境中找到 j
,因為 j
就是把迴圈給的 i
當作參數傳進去的。
注意:不需要再新增參數
j
變成return function(j)
,因為這樣會變成一個新的變數,導致結果出現 undefined。
回顧
看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…
- 進階 JavaScript 程式設計中非常重要的閉包的概念
- 藉由閉包的經典範例瞭解閉包與自由變數的概念
- 使用 ES6 的 let 處理閉包造成的情況
- 在 ES5 使用立即函式處理閉包造成的情況