你懂 JavaScript 嗎?#15 閉包(Closure)

你所不知道的 JS

本文主要會談到

閉包(Closure)

閉包是函式記得並存取語彙範疇的能力,可說是指向特定範疇的參考,因此當函式是在其宣告的語彙範疇之外執行時也能正常運作。

範例如下。

function foo() {
  var a = 2;

  function bar() {
    console.log(a);
  }

  return bar;
}

var baz = foo();

baz(); // 2

說明

閉包在 callback 上的應用尤其常見。如下所示, 在程式碼的最後一行 wait('Hello, 閉包!'); 中傳入字串「Hello, 閉包!」給函式 wait 時,儘管 timer 已離開了所宣告的範疇之內,但仍保留了 timer 存取 wait 傳入參數的值的能力,而印出結果。

function wait(message) {
  setTimeout(function timer() {
    console.log(message);
  }, 1000);
}

wait('Hello, 閉包!');

閉包可說是仰賴語彙範疇來撰寫程式碼而得到的必然結果。

有一種一但承諾了就永遠分不開的 feel XD

poinko

迴圈與閉包

如果今天要實作一個每秒依序印出數字 1, 2, 3, …, 5 的功能,你會怎麼做呢?

是這樣嗎?

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

好像有點怪怪的?

好像有點怪怪的?

這的確是錯誤的。由於 console.log(i) 中的 i 會存取的範疇是 for 所在的範疇(目前看起來是全域範疇,因為 var 宣告的變數不具區塊範疇的特性),因此當 1 秒、2 秒…5 秒後執行 console.log(i) 時,就會去取 i 的值,而此時 for 迴圈已跑完,i 變成 6,因此就會每隔一秒印出一個「6」。

若希望每隔一秒印出 1、2、…5,可使用 IIFE 加入新的範疇來修改,意即為每次迭代都建立一個新的函式範疇(但其實我們想要的是建立一個區塊範疇)。

for (var i = 1; i <= 5; i++) {
  (function (j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}

既然是要為每次迭代建立區塊範疇,更好的解法就是使用 let,let 會在每次迭代時重新宣告變數 i,並將上一次迭代的結果作為這一次的初始值。

for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

模組模式(Module Pattern)

模組模式(module pattern)又稱為揭露模組(revealing module),經由建立一個模組實體(module instance,如下範例的 foo),來調用內層函式 doSomething 和 doAnother。而內層函式由於具有閉包的特性,因此可存取外層的變數和函式(something 與 another)。透過模組模式,可隱藏私密資訊,並選擇對外公開的 API。

function CoolModule() {
  var something = 'cool';
  var another = [1, 2, 3];

  function doSomething() {
    console.log(something);
  }

  function doAnother() {
    console.log(another.join(' ! '));
  }

  return {
    doSomething: doSomething,
    doAnother: doAnother,
  };
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

以上這個例子並不難理解,它等於就是本文開頭 foo、bar 和 baz 的變形而已。

又,模組模式的另一個變形是 singleton-包上 IIFE,用於只想要產生單一實體的時候,修改上例如下。

var foo = (function CoolModule() {
  var something = 'cool';
  var another = [1, 2, 3];

  function doSomething() {
    console.log(something);
  }

  function doAnother() {
    console.log(another.join(' ! '));
  }

  return {
    doSomething: doSomething,
    doAnother: doAnother,
  };
})();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

模組依存性載入器(Module Dependency Loader)

既然我們知道了怎麼撰寫模組,那麼,要怎麼管理多個模組呢?像是在模組中引用其他模組時,可能會因程式碼放置的順序不對、產生相依性問題而導致出錯,該怎麼辦呢?

這裡提出兩個解法-使用模組依存性載入器或 ES6 模組,先來看前者。

模組依存性載入器或管理器(module dependency loader / manager)是指將模組定義的模式包裝成一個友善的 API。範例如下,這是一個簡單的模組依存性載入器。

var MyModules = (function Manager() {
  var modules = {};

  function define(name, deps, impl) {
    for (var i = 0; i < deps.length; i++) {
      deps[i] = modules[deps[i]]; // (1)
    }
    modules[name] = impl.apply(null, deps); // (2)
  }

  function get(name) {
    return modules[name];
  }

  return {
    define: define,
    get: get,
  };
})();

說明如下,在 Manager 這個 IIFE 裡面包含了一些變數和函式…

以下是示範如何定義模組…關於模組 foo 與 bar,就依照以上規格分別定義之,即可使用這樣的模組依存性載入器管理多個模組了。

// bar 沒有需要任何其他的模組...
MyModules.define('bar', [], function barImpl() {
  function hello(who) {
    return 'Let me introduce: ' + who;
  }

  function world() {
    return 'Hello World';
  }

  return {
    hello: hello,
  };
});

// foo 需要 bar 模組...
MyModules.define('foo', ['bar'], function fooImpl(bar) {
  var hungry = 'hippo';

  function awesome() {
    console.log(bar.hello(hungry).toUpperCase());
  }

  return {
    awesome: awesome,
  };
});

var bar = MyModules.get('bar');
var foo = MyModules.get('foo');

console.log(bar.hello('hippo')); // Let me introduce: hippo

foo.awesome(); // LET ME INTRODUCE: HIPPO

這樣就可以要用什麼就指定什麼,不用擔心順序問題了。

ES6 模組(ES6 Module)

ES6

在 ES6 中可將個別載入的檔案視為一個模組,每個模組都能匯入(import)其他模組或指定特定的 API 成員,也能匯出(export)自己公開的 API 成員。而瀏覽器或 JavaScript 引擎會經由其內建的模組載入器匯入這個模組檔案。這些模組檔案的內容可視為被包覆在一個閉包內,當被另一個檔案引入和使用時即可在非原先語彙範疇定義處正常運作。

範例如下,在 foo.js 中載入 bar module 的 hello,並用於其內的 awesome 中,以將結果字串轉為大寫;在 baz.js 載入 foo 與 bar 的完整模組,分別執行 bar.hellofoo.awesome()

bar.js

function hello(who) {
  return `Let me introduce: ${who}`;
}

export hello;

foo.js

import hello from 'bar'; // 只會載入 bar module 的 hello

var hungry = 'hippo';

function awesome() {
  console.log(
    hello(hungry).toUpperCase()
  );
}

export awesome;

baz.js

// 載入 foo 與 bar 的完整模組
module foo from 'foo';
module bar from 'bar';

console.log(
  bar.hello('rhino')
); // Let me introduce: rhino

foo.awesome(); // LET ME INTRODUCE: HIPPO

這樣就可以要用什麼就載入什麼,不用擔心同名、順序等問題。

更多關於 ES6 模組可參考

注意!模組模式是以函式為基礎來實作的,因此其 API 是在執行時期才能被識別,所以可在執行時期修改模組內的私有變數和函式;而 ES6 的模組是靜態的,意即在編譯時期而非執行時期被識別,因此可在編譯時檢查 API 是否存在而丟出錯誤訊息,並且無法在執行時修改模組內的私有變數和函式。

回顧

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

References


同步發表於2019 鐵人賽


You-Dont-Know-JS javascript closure 閉包 javascript 2019鐵人賽 你所不知道的JS 你懂JavaScript嗎? 鐵人賽 You-Dont-Know-JS-Scope-and-Closures 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文