隨著應用程式規模增長,如何組織和管理程式碼成為關鍵挑戰。本章探討 JavaScript 的模組化技術演進:從 pre-ES6 的手工模式,到 AMD/CommonJS 標準,最終到 ES6 原生模組系統。

11.1 Pre-ES6 的 JavaScript 模組化#

Pre-ES6 的 JavaScript 只有兩種作用域:全域作用域函式作用域,沒有內建的模組、命名空間或套件機制。開發者必須利用既有的語言特性來實現模組化。

一個模組系統至少需要滿足兩個需求:

  • 定義介面(interface):透過它存取模組提供的功能
  • 隱藏內部實作(hide module internals):讓使用者不必關心實作細節,同時防止外部對內部狀態的意外修改

使用物件、閉包和立即函式定義模組#

JavaScript 可利用以下語言特性來實現模組的兩大需求:

  • 隱藏模組內部:呼叫函式會建立新的作用域,函式內定義的變數只在該函式內可見,因此可用函式來隱藏模組內部變數
  • 定義模組介面:從函式模組回傳一個物件,該物件代表模組的公開介面,透過閉包保持對內部變數的存取

模組模式(Module Pattern):

const MouseCounterModule = (function () {
  let numClicks = 0; // 私有變數
  const handleClick = () => {
    // 私有函式
    alert(++numClicks);
  };

  return {
    // 公開介面
    countClicks: () => {
      document.addEventListener("click", handleClick);
    },
  };
})();

這種結合立即函式、物件和閉包來建立模組的模式被稱為 module pattern,由 Douglas Crockford 推廣,是最早流行的 JavaScript 模組化方式之一。

從外部只能存取介面物件上的屬性和方法,而 numClickshandleClick 等內部細節透過閉包保持存活,但無法從外部直接存取。

擴展模組(Augmenting Modules):

可以將現有模組傳入另一個立即函式來擴展功能,而不修改原始模組程式碼:

(function (module) {
  let numScrolls = 0;
  const handleScroll = () => {
    alert(++numScrolls);
  };

  module.countScrolls = () => {
    document.addEventListener("wheel", handleScroll);
  };
})(MouseCounterModule);

模組擴展有一個根本限制:透過不同的立即函式進行的擴展,彼此無法共享私有模組內部變數,因為每次函式呼叫都會建立新的作用域。

模組模式的其他問題:

  • 不處理模組之間的依賴管理
  • 開發者必須自行確保正確的依賴載入順序
  • 在大型應用中,互相依賴的模組可能造成嚴重問題

使用 AMD 和 CommonJS 模組化#

為解決模組模式的依賴管理問題,出現了兩個競爭標準。

AMD(Asynchronous Module Definition):

  • 源自 Dojo toolkit,專為瀏覽器端設計
  • 最流行的實作是 RequireJS
  • 提供 define 函式,接受三個參數:
    • 模組 ID
    • 依賴模組 ID 列表
    • 建立模組的工廠函式
define("MouseCounterModule", ["jQuery"], ($) => {
  let numClicks = 0;
  const handleClick = () => {
    alert(++numClicks);
  };

  return {
    countClicks: () => {
      $(document).on("click", handleClick);
    },
  };
});

AMD 的優點:

  • 自動解析依賴,不需手動管理載入順序
  • 模組非同步載入,避免阻塞
  • 同一個檔案可定義多個模組

CommonJS:

  • 通用 JavaScript 環境設計,在 Node.js 社群最為流行
  • 基於檔案的模組系統,一個檔案一個模組
  • 透過 module.exports 定義公開介面
  • 透過 require 同步載入其他模組
// MouseCounterModule.js
const $ = require("jQuery"); // 同步載入依賴
let numClicks = 0;
const handleClick = () => {
  alert(++numClicks);
};

module.exports = {
  // 定義公開介面
  countClicks: () => {
    $(document).on("click", handleClick);
  },
};
// 使用模組
const MouseCounterModule = require("MouseCounterModule.js");
MouseCounterModule.countClicks();

CommonJS 的優點:

  • 語法簡單,接近標準 JavaScript 寫法
  • 是 Node.js 的預設模組格式,可存取 npm 上大量的套件

CommonJS 的缺點:

  • 不是為瀏覽器設計,瀏覽器中沒有原生的 modulerequire 支援
  • 需要透過 Browserify 或 RequireJS 等工具打包成瀏覽器可用的格式

兩個標準的競爭導致了社群分裂。UMD(Universal Module Definition)嘗試提供同時相容 AMD 和 CommonJS 的語法,但較為繁瑣。最終,ECMAScript 委員會決定制定統一的模組標準。

11.2 ES6 模組#

ES6 模組結合了 AMD 和 CommonJS 的優點:

  • 類似 CommonJS,語法簡潔,且基於檔案(一個檔案一個模組)
  • 類似 AMD,支援非同步模組載入

核心概念:只有被明確 export 的識別符才能從模組外部存取,其他所有識別符(即使定義在頂層作用域)都只在模組內部可見。

ES6 引入兩個新關鍵字:

  • export:讓特定識別符可從模組外部存取
  • import:匯入其他模組導出的識別符

導出與匯入功能#

命名導出(Named Export):

// Ninja.js
export const message = "Hello";

export function sayHiToNinja() {
  return message + " " + ninja;
}

也可以在模組末尾統一導出:

export { message, sayHiToNinja };

匯入命名導出:

import { message, sayHiToNinja } from "Ninja.js";

未導出的變數(如模組內的 ninja)無法從外部存取。

匯入所有命名導出:

import * as ninjaModule from "Ninja.js";
ninjaModule.message; // "Hello"
ninjaModule.sayHiToNinja(); // "Hello Yoshi"

預設導出(Default Export):

當模組主要代表一個單一實體(如一個 class)時,使用預設導出:

export default class Ninja {
  constructor(name) {
    this.name = name;
  }
}

export function compareNinjas(ninja1, ninja2) {
  return ninja1.name === ninja2.name;
}

匯入預設導出時不需要大括號,且可以使用任意名稱:

import ImportedNinja from "Ninja.js";
import { compareNinjas } from "Ninja.js";

// 或簡寫為
import ImportedNinja, { compareNinjas } from "Ninja.js";

重新命名導出和匯入:

使用 as 關鍵字可以重新命名:

// 導出端
export { sayHi as sayHello };

// 匯入端
import { greet as sayHello } from "Hello.js";
import { greet as salute } from "Salute.js";

這在避免命名衝突時特別有用。

ES6 模組語法總覽:

語法意義
export const ninja = "Yoshi";導出命名變數
export function compare(){}導出命名函式
export class Ninja{}導出命名類別
export default class Ninja{}導出預設類別
export { ninja, compare };導出既有變數
export { ninja as samurai };以新名稱導出
import Ninja from "Ninja.js";匯入預設導出
import { ninja, Ninja } from "Ninja.js";匯入命名導出
import * as Ninja from "Ninja.js";匯入所有命名導出
import { ninja as iNinja } from "Ninja.js";以新名稱匯入

11.3 本章重點#

  • 大型單體程式碼庫難以理解和維護,應將程式拆分為較小的、鬆耦合的模組
  • 模組是比物件和函式更大的程式碼組織單元,促進可理解性、可維護性和可重用性
  • Pre-ES6 沒有內建模組系統,開發者利用立即函式建立作用域、閉包保持變數存活、物件定義介面,形成 module pattern
  • AMD 為瀏覽器設計,支援非同步載入和自動依賴解析
  • CommonJS 語法簡潔,同步載入,更適合伺服器端,有大量 npm 套件可用
  • ES6 模組結合了兩者優點:以檔案為基礎、語法受 CommonJS 啟發、支援非同步載入如 AMD
    • 使用 export 關鍵字導出識別符
    • 使用 import 關鍵字匯入識別符
    • 模組可有一個 default 導出
    • 導出和匯入都可使用 as 關鍵字重新命名