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 會:
- 解析檔案路徑
- 建立一個
Module物件 - 執行檔案內容,將
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 規則中強制統一寫法。 |
最佳實踐清單
- 保持模組純粹:盡量讓模組只導出函式、類別或常數,避免在頂層執行副作用。
- 使用單例模式時,明確文件化:讓其他開發者知道該模組是「全域唯一」的。
- 必要時手動清除快取:在測試、熱重載 (HMR) 或插件開發時,使用
delete require.cache[...]。 - 避免循環依賴:利用介面抽象或動態匯入打斷環路。
- 監控記憶體使用:大型緩存可考慮使用
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 專案中 安全、有效 使用模組快取的能力。未來面對更大型的系統或複雜的依賴圖時,記得回顧本文的最佳實踐,讓你的程式碼保持 可預測、易維護。祝開發順利!