本文 AI 產出,尚未審核

JavaScript 教學:模組與封裝(Modules & Encapsulation)

主題 – CommonJS(require / module.exports


簡介

在大型的 JavaScript 專案中,程式碼的組織與重用是不可或缺的課題。
早期的瀏覽器環境只支援全域變數,導致檔案之間的相依關係難以掌控、命名衝突層出不窮。為了解決這些問題,Node.js 以及許多前端打包工具(如 Browserify、Webpack)引入了 CommonJS 規範,讓開發者可以透過 requiremodule.exports模組化 的方式撰寫、載入與匯出功能。

CommonJS 不僅是 Node.js 的核心模組系統,也在許多舊有專案或需要 同步載入 的情境下仍然非常實用。掌握它的運作原理與使用方式,能讓你在 伺服器端工具腳本,甚至 前端打包 時,寫出更乾淨、可維護的程式碼。


核心概念

1. 模組的基本結構

在 CommonJS 中,每一個檔案本身就被視為一個獨立的 模組。Node.js 會在執行時為每個檔案包裝一個函式,提供以下兩個關鍵變數:

變數 作用
module 代表當前模組本身,包含 exports 物件
exports module.exports 的快捷寫法,預設指向同一個物件

重點:最終被外部引用的,是 module.exports 的值,而不是 exports 本身。


2. 匯出(Export)方式

2.1 匯出單一值(函式、物件、類別)

// math.js
function add(a, b) {
  return a + b;
}

// 匯出單一函式
module.exports = add;

2.2 匯出多個屬性(使用 exports

// stringUtil.js
exports.capitalize = function (str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
};

exports.trim = function (str) {
  return str.trim();
};

提示:如果同時使用 module.exportsexports,請確保最後只保留 module.exports,避免產生 意外的空物件

2.3 匯出類別

// Person.js
class Person {
  constructor(name) {
    this.name = name;
  }
  greet() {
    return `Hi, 我是 ${this.name}`;
  }
}

// 匯出類別
module.exports = Person;

3. 載入(Require)模組

3.1 基本載入

// app.js
const add = require('./math');      // 取得 math.js 匯出的函式
console.log(add(3, 5)); // 8

3.2 取得多屬性模組

// app.js
const stringUtil = require('./stringUtil');
console.log(stringUtil.capitalize('hello')); // Hello
console.log(stringUtil.trim('  hello  '));   // hello

3.3 載入類別並建立實例

// app.js
const Person = require('./Person');
const alice = new Person('Alice');
console.log(alice.greet()); // Hi, 我是 Alice

4. 循環相依(Circular Dependency)

當兩個模組互相 require 時,Node 會先建立 空的匯出物件,之後再填入實際內容。若在相依的早期階段直接使用尚未完成的匯出,會得到 未定義空物件

// a.js
const b = require('./b');
module.exports = { name: 'module A', getBName: () => b.name };

// b.js
const a = require('./a');
module.exports = { name: 'module B', getAName: () => a.name };

解決方式:將相依的程式碼延遲到函式內部,或抽離共同的部分到第三個模組。


5. 快取機制(Cache)

require 只會在第一次載入時執行模組程式碼,之後會直接從 module cache 取回已匯出的物件。這意味著:

  • 單例模式:如果模組內部維護狀態,所有引用者會共享同一個實例。
  • 變更會即時反映:改變匯出物件的屬性會影響所有已載入的模組。
// counter.js
let count = 0;
module.exports.increment = () => ++count;
module.exports.get = () => count;

// a.js
const counter = require('./counter');
counter.increment(); // count = 1

// b.js
const counter = require('./counter');
console.log(counter.get()); // 1,因為是同一個快取實例

常見陷阱與最佳實踐

陷阱 說明 解決/最佳實踐
混用 module.exportsexports 兩者指向同一個物件,但重新指派 module.exportsexports 失效。 只使用其中一種;若要匯出單一值,直接使用 module.exports
相對路徑錯誤 require('./utils') 必須正確指向檔案,缺少副檔名或路徑層級錯誤會拋出 MODULE_NOT_FOUND 使用 絕對路徑path.resolve)或 別名(Webpack alias)降低錯誤機率。
循環相依導致未定義 兩個模組互相依賴時,早期使用可能取得空物件。 把共用邏輯抽到第三個模組,或把相依的程式碼 延遲到函式呼叫時
快取誤用 修改匯出物件會影響所有引用者,可能造成不可預期的副作用。 若需要獨立實例,匯出 工廠函式類別,而非直接的物件。
同步阻塞 require 是同步的,過度在程式入口載入大量模組會增加啟動時間。 把不常用的模組 延遲載入require 放在需要時的函式內),或改用 ESM 的動態匯入 (import())。

最佳實踐總結

  1. 明確命名:檔案與匯出名稱保持一致,提升可讀性。
  2. 單一職責:每個模組只負責一件事,避免過大或過於耦合。
  3. 使用工廠或類別:需要多個實例時,避免直接匯出可變的物件。
  4. 檔案結構:依功能分層(utils/, services/, models/),讓 require 路徑簡潔。
  5. 測試:因為模組快取會共享狀態,單元測試前可呼叫 jest.resetModules() 或手動清除快取。

實際應用場景

1. 建立 Node.js CLI 工具

// bin/cli.js
#!/usr/bin/env node
const program = require('commander');
const pkg = require('../package.json');

program.version(pkg.version).parse(process.argv);

cli.js 只負責指令列介面,所有實際的業務邏輯則寫在 src/ 內的模組,透過 require 引入,保持 入口檔案極簡

2. 共享設定檔

// config/index.js
module.exports = {
  port: process.env.PORT || 3000,
  db: {
    host: 'localhost',
    user: 'root',
    password: '',
  },
};

其他模組只要 require('./config') 即可取得同一份設定,且因為快取機制,只會載入一次。

3. 建構資料存取層(DAO)

// dao/userDao.js
const db = require('../db'); // 假設是一個封裝好的資料庫連線模組

module.exports = {
  getById: async (id) => db.query('SELECT * FROM users WHERE id = ?', [id]),
  create: async (data) => db.query('INSERT INTO users SET ?', data),
};

在服務層(service)中:

// service/userService.js
const userDao = require('../dao/userDao');

module.exports = {
  async findUser(id) {
    return await userDao.getById(id);
  },
};

透過 層層抽象,每個模組只關心自己的職責,測試與維護都更容易。

4. 前端打包(Webpack)使用 CommonJS

// src/util/math.js
exports.add = (a, b) => a + b;
exports.sub = (a, b) => a - b;

在另一個檔案:

// src/main.js
const math = require('./util/math');
console.log(math.add(2, 3)); // 5

Webpack 會在建置時把 require 轉成瀏覽器可執行的模組,讓 舊有程式碼 仍能在現代前端專案中使用。


總結

CommonJS 以 同步、簡潔require / module.exports 機制,為 Node.js 與許多前端打包工具提供了穩固的模組化基礎。掌握以下幾點,你就能在實務開發中充分發揮它的威力:

  1. 了解 module.exportsexports 的差異,避免匯出失效。
  2. 善用快取:單例與工廠模式的選擇取決於模組是否需要共享狀態。
  3. 避免循環相依,必要時抽離共用模組或延遲載入。
  4. 遵守單一職責原則,讓每個檔案只負責一件事,提升可維護性。
  5. 在大型專案中結合目錄結構與別名,讓 require 路徑清晰且不易出錯。

無論是寫 伺服器端 APICLI 工具,或是 前端打包 的舊有程式碼,CommonJS 都是值得信賴的模組解決方案。掌握它,你的 JavaScript 專案將更具可讀性、可測試性,也更易於在團隊中協作與擴展。祝你寫程式愉快! 🚀