本文 AI 產出,尚未審核

JavaScript 模組與封裝:深入探討 模組快取 (caching)


簡介

在現代 JavaScript 開發中,模組化已成為標準做法。無論是 Node.js 的 CommonJS、ESM,還是前端的 bundler(如 Webpack、Vite),模組快取 都是保證程式執行效能與行為一致性的關鍵機制。

快取讓同一個模組只會在第一次 require / import 時被執行一次,之後的載入會直接取得已存在的執行結果。這不僅減少重複計算、降低 I/O 開銷,也為單例 (singleton)共享狀態 等設計模式提供了天然支援。

本篇文章將從概念說明、實作範例、常見陷阱到最佳實踐,一步步帶你了解模組快取的運作原理,並學會在實務專案中正確運用它。


核心概念

1️⃣ 為什麼會有模組快取?

需求 解決方式
避免重複執行模組代碼(例如大量計算或 I/O) 只在第一次載入時執行,之後直接回傳已儲存的結果
共享同一份狀態(例如設定檔、資料庫連線) 同一個模組的實例在整個執行環境中只有一個
提升載入速度 快取的物件直接從記憶體返回,省去檔案系統或網路讀取

Node.js 中,快取機制是由 require.cache(CommonJS)或 import 的模組圖(module graph)負責;在瀏覽器端的 ES 模組則由瀏覽器內建的模組解析器管理。


2️⃣ CommonJS(Node.js)快取機制

// a.js
console.log('a.js 被執行');
module.exports = { value: Math.random() };
// b.js
const a1 = require('./a');   // 第一次載入,會印出「a.js 被執行」
const a2 = require('./a');   // 第二次載入,*不會*再次執行 a.js
console.log(a1 === a2);      // true
  • 第一次 require 時,Node 會:
  1. 解析檔案路徑
  2. 建立一個 Module 物件
  3. 執行檔案內容,將 module.exports 塞入快取 (require.cache)
  • 之後的 require 只會直接回傳快取物件,不會重新執行檔案

3️⃣ ES 模組(ESM)快取機制

// utils.mjs
console.log('utils.mjs 被執行');
export const timestamp = Date.now();
// main.mjs
import { timestamp as t1 } from './utils.mjs';
import { timestamp as t2 } from './utils.mjs';
console.log(t1 === t2);   // true,兩次 import 取得同一個值

ESM 的快取行為與 CommonJS 類似,只是它是 靜態分析(在編譯階段就決定依賴)且 只允許一次執行。瀏覽器或 Node 都會把已載入的模組放入內部快取表,之後的 import 直接返回已解析的模組記錄。


4️⃣ 快取的生命週期

環境 快取何時失效
Node.js (CommonJS) 當程式結束、或手動刪除 require.cache[modulePath]
Node.js (ESM) 同上,或在動態 import() 中使用 import.meta.resolve 重新載入(需要自行管理)
瀏覽器 當頁面重新載入、或使用 Service Worker 控制快取策略(較少直接操作)

⚠️ 注意:快取僅在同一個執行上下文(process / tab)內有效,跨 process 或跨瀏覽器視窗不會共享。


5️⃣ 程式碼範例:實作自訂快取層

以下示範在 Node.js 中,利用 require.cache 建立 簡易的模組快取封裝,方便在測試或開發時清除特定模組。

// cacheHelper.js
/**
 * 取得已快取的模組(若未快取則返回 undefined)
 * @param {string} path 模組絕對路徑
 */
function getCached(path) {
  return require.cache[path];
}

/**
 * 手動清除快取,讓下次 require 重新執行模組
 * @param {string} path 模組絕對路徑
 */
function purgeCache(path) {
  delete require.cache[path];
}

/**
 * 重新載入模組(等同於 require + purgeCache)
 * @param {string} path 模組絕對路徑
 */
function reload(path) {
  purgeCache(path);
  return require(path);
}

module.exports = { getCached, purgeCache, reload };
// demo.js
const path = require('path');
const { reload } = require('./cacheHelper');

const modPath = path.resolve(__dirname, './counter.js');

// 第一次載入,counter 為 0
let counter = require('./counter');
console.log(counter.value); // 0

// 重新載入,counter 重新執行模組,值回到 0
counter = reload(modPath);
console.log(counter.value); // 0
// counter.js
let value = 0;
module.exports = {
  get value() {
    return ++value;
  }
};

重點說明

  • require.cache絕對路徑 為鍵,確保不會因相對路徑不同而產生多個快取。
  • reload 函式在測試時特別有用:可以在不重新啟動 Node 的情況下,驗證模組的初始化行為。

6️⃣ 範例:前端 ES 模組快取與動態匯入

<!-- index.html -->
<script type="module">
  // 第一次靜態 import,模組會執行一次
  import { greet } from './hello.mjs';
  greet('Alice');

  // 動態 import:瀏覽器會檢查快取,若已載入則直接返回
  async function loadAgain() {
    const mod = await import('./hello.mjs');
    mod.greet('Bob');
  }
  loadAgain();
</script>
// hello.mjs
console.log('hello.mjs 被執行');
export function greet(name) {
  console.log(`Hello, ${name}!`);
}

執行結果只會在 console 中看到一次 hello.mjs 被執行,證明 動態匯入 也會遵循模組快取規則。


常見陷阱與最佳實踐

陷阱 說明 解法
1️⃣ 依賴副作用 模組在頂層執行大量副作用(例如修改全域變數),快取會讓這些副作用只發生一次,可能導致預期外行為。 將副作用搬到函式內部,或使用明確的初始化 API。
2️⃣ 循環依賴 (circular dependency) A 依賴 B、B 又依賴 A 時,快取會在模組尚未完成執行前就返回「未完成」的物件。 使用 延遲載入import())或重新設計模組介面,避免直接在頂層互相引用。
3️⃣ 快取污染測試環境 單元測試多次 require 同一模組,快取會保留前一次測試的狀態。 在測試前呼叫 purgeCache,或使用測試框架提供的 sandbox 功能。
4️⃣ 記憶體泄漏 大型資料結構被放在模組層級,快取持續佔用記憶體。 僅在需要時建立,或在模組提供 dispose 方法手動釋放。
5️⃣ 路徑不一致 同一檔案使用不同相對路徑載入,會產生多個快取條目。 統一使用絕對路徑require.resolve / import.meta.resolve),或在 lint 規則中強制統一寫法。

最佳實踐清單

  1. 保持模組純粹:盡量讓模組只導出函式、類別或常數,避免在頂層執行副作用。
  2. 使用單例模式時,明確文件化:讓其他開發者知道該模組是「全域唯一」的。
  3. 必要時手動清除快取:在測試、熱重載 (HMR) 或插件開發時,使用 delete require.cache[...]
  4. 避免循環依賴:利用介面抽象或動態匯入打斷環路。
  5. 監控記憶體使用:大型緩存可考慮使用 WeakMap 或自行實作 LRU 機制,防止長時間佔用。

實際應用場景

場景 為何需要快取 範例實作
資料庫連線池 連線建立成本高,應在整個服務期間共用同一個連線物件。 javascript // db.js export const pool = new Pool(config);
設定檔載入 應用啟動時一次讀取設定檔,之後的模組直接使用已解析的物件。 javascript // config.js const cfg = JSON.parse(fs.readFileSync('config.json')); export default cfg;
第三方 SDK 初始化 SDK 需要一次性初始化金鑰、環境,之後全局可直接呼叫。 javascript // analytics.js import SDK from 'analytics-sdk'; const client = SDK.init({key: 'xxx'}); export default client;
前端 UI 元件庫 元件的樣式或 SVG 定義只需載入一次,避免重複渲染。 javascript // icons.js export const IconA = '<svg>...</svg>'; // 只執行一次
Node.js CLI 工具 命令解析與子指令表只需要一次建立,提升啟動速度。 javascript // commands.js const cmds = loadAllCommands(); export default cmds;

這些案例的共同點是:「一次執行、全域共享」,正是模組快取的核心價值。


總結

  • 模組快取 讓同一個模組只會在第一次載入時執行,之後直接回傳已緩存的結果,提升效能並支援單例與共享狀態。
  • CommonJS 中透過 require.cache 管理;在 ESM 中則由瀏覽器或 Node 的模組圖自動快取。
  • 正確使用快取可以避免不必要的 I/O、重複計算,同時要留意副作用、循環依賴與記憶體占用等陷阱。
  • 實務上,資料庫連線、設定檔、SDK 初始化等情境最常依賴快取,掌握快取的生命週期與清除方式,能讓測試與熱重載更順暢。

透過本篇的概念說明與範例,你已經具備在 JavaScript 專案中 安全、有效 使用模組快取的能力。未來面對更大型的系統或複雜的依賴圖時,記得回顧本文的最佳實踐,讓你的程式碼保持 可預測、易維護。祝開發順利!