Back To Articles

Understand JavaScript #15 閉包 (Closure)

🧑🏻‍💻 海豹人 Sealman 📅 April 1, 2021

Article Image

本文主要內容為探討「閉包」的相關知識,這是 JavaScript 的一個重要觀念,會用到我們之前學到的所有概念,包含一級函式、執行堆、執行環境等等。

暖身小劇場

以下是一個有趣的寫法:greet 函式會回傳一個匿名函式,所以呼叫 greet 會得到一個函式物件,我們可以再次呼叫這個被回傳的函式。

function greet(whattosay) {
  return function (name) {
    console.log(whattosay + " " + name);
  };
}

greet("Hi")("Damao"); // Hi Damao

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

var sayHi = greet("Hi");
sayHi("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 的結果。

function buildFunction() {
  var arr = [];
  for (var i = 0; i < 3; i++) {
    arr.push(function () {
      console.log(i);
    });
  }
  return arr;
}

var fs = buildFunction(); // (3) [ƒ, ƒ, ƒ]

fs[0](); // 3
fs[1](); // 3
fs[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 在本質上都是不同的變數。

function buildFunction2() {
  var arr = [];
  for (var i = 0; i < 3; i++) {
    let j = i; // 大括號作用域
    arr.push(function () {
      console.log(j);
    });
  }
  return arr;
}

var fs2 = buildFunction2();

fs2[0](); // 0
fs2[1](); // 1
fs2[2](); // 2

在 ES5 使用 IIFE 校正結果

如果不要用 ES6 的 let,那在 ES5 有辦法解決嗎?根據剛才 let 的處理邏輯,我們必須給每個 i 不同的執行環境,讓它們有不同的記憶體位置。

然而,想要獲得不同的、新的執行環境的唯一方式,就只有「執行函式」這個方法了!

那麼如何在把一個個函式加入陣列時,就(立刻)執行函式呢?沒錯,只要使用 IIFE 就可以很簡單地做到這件事。

每次迴圈執行,都會立刻執行立即函式,創造一個執行環境,而 j 就會被存在這三個執行環境中,分別等於 0、1、2。

function buildFunction2() {
  var arr = [];
  for (var i = 0; i < 3; i++) {
    arr.push(
      (function (j) {
        return function () {
          console.log(j);
        };
      })(i),
    );
  }
  return arr;
}

var fs2 = buildFunction2();

fs2[0](); // 0
fs2[1](); // 1
fs2[2](); // 2

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

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

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

回顧

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

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

References