Sean's Blog

An image showing avatar

Hi, I'm Sean

這裡記錄我學習網站開發的筆記
歡迎交流 (ゝ∀・)b

LinkedInGitHub

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

我們把呼叫的步驟拆開,先設一個變數 sayHi 作為呼叫 greet 之後回傳的函式,再去呼叫 sayHi

1var sayHi = greet('Hi');
2sayHi('Damao'); // Hi Damao

此時會有一個問題,就是當 greet 函式執行完成後,執行環境會離開執行堆,然而當我們接著呼叫 sayHi 時,為什麼 sayHi 還能知道 greet 執行環境裡面的 whattosay 變數呢?

這一切就是因為有 Closure 這個特性。

什麼是閉包

我們都知道每一個執行環境,都有它自己的記憶體空間,裡面有我們創造的變數與函式。那麼如果沒了執行環境,記憶體空間會發生什麼事呢?

一般情況下 JavaScript 引擎會清除記憶體空間,這個動作稱為「垃圾回收 (Garbage Collection)」。

不過如果是「執行環境結束」的這個時候,則記憶體空間會繼續留在原地。也就是當 sayHi 函式找不到 whattosay 時,它仍然可以沿著範圍鏈,向外參考 greet 函式的執行環境的記憶體位置,從中找到 whattosay 變數。

Closure

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

為什麼向外尋找 i 的時候,會發現它們都一樣呢?

當函式 buildFunction 的執行環境結束時,最後的 i 經過 i++ 變成 3,讓 for 迴圈無法繼續進行,而 arr 陣列裡面總共有三個函式,也因為閉包的特性 iarr 都仍然存在記憶體中。

注意:執行 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

在 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

上方程式碼當中,每一次都是把函式的執行結果 Push 到陣列,此時 Push 進去的就是立即函式回傳的 Function。

最後執行 fs2[0]() 時會往外尋找 j,並在立即函式的執行環境中找到 j,因為 j 就是把迴圈給的 i 當作參數傳進去的。

注意:不需要再新增參數 j 變成 return function(j),因為這樣會變成一個新的變數,導致結果出現 undefined。

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

  • 進階 JavaScript 程式設計中非常重要的閉包的概念
  • 藉由閉包的經典範例瞭解閉包與自由變數的概念
  • 使用 ES6 的 let 處理閉包造成的情況
  • 在 ES5 使用立即函式處理閉包造成的情況

References