Module Pattern:替程式劃分房間
此為《Learning JavaScript Design Patterns, 2nd Edition》學習筆記,對應部分第 5 章(Modern JavaScript Syntax and Features)和第 7 章(JavaScript Design Patterns - The Module Pattern)
模式用途
- 分類:建立型
- 解決什麼問題:如何封裝程式碼邏輯,避免全域污染並區分公開與私有介面
在讀第 5 章的時候,腦中不停冒出許多疑惑:為什麼要模組化?為什麼要解耦? 書裡的寫法是先講模組化,然後才帶出解耦這個概念。但如果從發展脈絡來看,應該是反過來的──因為程式耦合度太高,維護困難,所以才需要模組化。換句話說,模組只是手段,真正的目的是降低耦合、提升可維護性。抓到這個方向後,我後續再看模組相關的章節就比較不會被「模組」這個詞困住了。
當程式碼開始失控時
這篇會用「待辦事項清單」這個常見例子來說明 Module Pattern。從單純的新增、刪除,到需求不斷增加、程式碼越來越混亂,最後再看看 Module Pattern 怎麼解決這些問題。
階段一
最初的待辦事項清單很簡單,所有程式碼都寫在同一個檔案裡:
// 全域變數
let todos = []
let todoId = 0
// 全域函式
function addTodo(text) {
todos.push({ id: ++todoId, text, completed: false })
renderTodos()
}
function deleteTodo(id) {
todos = todos.filter(todo => todo.id !== id)
renderTodos()
}
function renderTodos() {
// 渲染邏輯
}
階段二
這時候有需求要加分類功能,於是程式碼開始變複雜:
// 全域變數越來越多
let todos = []
let categories = ['工作', '生活', '學習']
let currentCategory = '全部'
let todoId = 0
let categoryId = 0
function addTodo(text, category) {
todos.push({
id: ++todoId,
text,
category,
completed: false
})
renderTodos()
updateCategoryCount() // 新的依賴關係
}
function filterByCategory(category) {
currentCategory = category
renderTodos()
}
開始出現問題:全域變數增加,功能間有了依賴關係。
階段三
接著又要加統計、通知功能⋯⋯等:
// 全域變數大爆炸
let todos = []
let categories = []
let stats = { total: 0, completed: 0 }
let notifications = []
let settings = { theme: 'light', showNotifications: true }
let todoId = 0
let categoryId = 0
let notificationId = 0
function addTodo(text, category) {
// 一個函式要處理太多事情
const todo = { id: ++todoId, text, category, completed: false }
todos.push(todo)
// 更新統計
stats.total++
// 發送通知
if (settings.showNotifications) {
notifications.push({
id: ++notificationId,
message: `新增任務:${text}`,
type: 'success'
})
}
// 重新渲染多個區域
renderTodos()
renderStats()
renderNotifications()
}
階段四
若同時多人開發,每個人都往全域加變數:
// 小安加的統計變數
let dailyStats = {}
let weeklyStats = {}
// 小娜加的通知變數
let notificationQueue = []
let notificationTimer = null
// 結果:變數名衝突、功能互相影響、誰都不敢改程式碼
到這裡應該很明顯了:如果程式碼全部都塞在同一個檔案、全丟到全域裡,問題會一個接一個跑出來。
- 全域污染:變數名衝突,不知道誰在用什麼
- 高耦合:改一個地方,其他地方也壞掉
- 難維護:程式碼越來越複雜,不敢隨便修改
- 協作困難:多人開發時互相影響
IIFE:最初的解決方案
IIFE 是什麼?IIFE(Immediately Invoked Function Expression)就是「宣告完馬上執行的函式」。 它的用途是建立一個獨立的作用域,避免變數跑到全域去。
(function () { const msg = '只在這裡有效' console.log(msg) })()
在 Module Pattern 出現之前,這是 JavaScript 常見的封裝技巧。(延伸閱讀:MDN - Immediately Invoked Function Expression)
面對全域污染問題,最直接的解決方案就是用函式的作用域把程式碼「包起來」。
IIFE 的封裝
// 用 IIFE 包裝待辦功能
const TodoModule = (function () {
// 私有變數,外部無法直接存取
let todos = []
let todoId = 0
// 私有函式
function render() {
console.log('渲染待辦清單')
}
// 回傳公開介面
return {
add(text) {
todos.push({ id: ++todoId, text, completed: false })
render()
},
delete(id) {
todos = todos.filter(todo => todo.id !== id)
render()
},
getAll() {
return [...todos] // 回傳副本,避免外部直接修改
}
}
})()
// 使用方式
TodoModule.add('學習 Module Pattern')
TodoModule.delete(1)
console.log(TodoModule.getAll())
// 私有變數無法從外部存取
console.log(TodoModule.todos) // undefined
解決的問題
- 避免全域污染:
todos
、todoId
不會污染全域空間 - 封裝私有邏輯:
render
函式外部無法直接呼叫 - 清楚的公開介面:只暴露
add
、delete
、getAll
方法
多個模組的組成
// 統計模組
const StatsModule = (function () {
const stats = { total: 0, completed: 0 }
return {
update(todos) {
stats.total = todos.length
stats.completed = todos.filter(t => t.completed).length
},
get() {
return { ...stats }
}
}
})()
// 通知模組
const NotificationModule = (function () {
const notifications = []
return {
show(message) {
notifications.push({ message, timestamp: Date.now() })
console.log(`通知:${message}`)
}
}
})()
可以看到 IIFE 解決了基本的封裝問題,但還是有一個限制:所有程式碼還是得寫在同一個檔案裡,或者手動管理載入順序。
CommonJS:伺服器端的標準化
Node.js 的出現帶來了 CommonJS 模組系統,解決了檔案間依賴管理的問題。
模組的匯出與匯入
// todo-manager.js
let todos = []
let todoId = 0
function add(text) {
todos.push({ id: ++todoId, text, completed: false })
}
function remove(id) {
todos = todos.filter(todo => todo.id !== id)
}
function getAll() {
return [...todos]
}
// 匯出公開介面
module.exports = {
add,
remove,
getAll
}
// stats-manager.js
function calculateStats(todos) {
return {
total: todos.length,
completed: todos.filter(t => t.completed).length
}
}
module.exports = { calculateStats }
// notification-manager.js
const notifications = []
function show(message) {
notifications.push(message)
console.log(`通知:${message}`)
}
module.exports = { show }
// app.js
// 匯入模組
const statsManager = require('./stats-manager')
const todoManager = require('./todo-manager')
// 使用模組
todoManager.add('學習 CommonJS')
const todos = todoManager.getAll()
const stats = statsManager.calculateStats(todos)
console.log(stats) // { total: 1, completed: 0 }
解決的問題
- 檔案間依賴管理:用
require
明確指定依賴關係 - 同步載入:適合伺服器端環境,檔案都在本地
- 標準化:Node.js 生態系統的統一標準
限制
CommonJS 是同步載入,不適合瀏覽器環境。瀏覽器需要透過網路下載檔案,同步載入會阻塞頁面渲染。
補充:AMD / UMD在 CommonJS 與 ESM 之間,前端社群其實還提出過 AMD 和 UMD 等方案,主要是為了瀏覽器端的載入方式與相容性問題。不過這些屬於另一條發展支線,細節等讀到第 10 章再深入。
ES6 Modules:現代瀏覽器的原生支援
在 IIFE 只能解決封裝、CommonJS 又主要侷限於伺服器端的背景下,ES6 引入了原生的模組系統(ECMAScript Modules,ESM)。這是一次把模組化提升到語言層級的標準:瀏覽器已經原生支援,Node.js 也在後來逐步補齊支援。
為什麼要有 ESM?
- IIFE:只能在單一檔案裡隔離變數,跨檔案的依賴要自己管理。
- CommonJS:雖然解決了伺服器端依賴,但因為
require
是動態的,不適合瀏覽器環境,也讓靜態分析變得困難。
→ 需要一個跨平台、瀏覽器原生支援、依賴可靜態分析的方案。
ESM 的核心特性
- 標準化:不再需要各自為政的 CommonJS、AMD,所有環境都能用同一套語法。
- 靜態結構:
import/export
必須在檔案最上層,路徑要是字串常數,匯出的符號名稱固定。 - 可分析:語法的限制讓依賴關係在程式還沒執行前就能被確定,不僅方便瀏覽器和 Node.js 正確載入,也讓建置工具(如 Webpack、Rollup、Vite、esbuild)在建置階段就能畫出完整的模組依賴圖(module graph)。
- 最佳化:因為依賴圖明確,才能做到 tree-shaking、錯誤檢查、甚至瀏覽器的預先載入。
靜態結構、靜態分析、module graph 的關係
我又在「靜態」這個詞卡住了,之前學 TypeScript 的時候在「靜態檢查」也卡過。
這次發現一個比較好理解的角度:靜態 = 執行程式「之前」,就能確定下來的事情。相對的,「動態」就是要等程式真的跑起來,才能知道的結果。
回到 ESM,三個名詞的關係可以這樣記:
- 靜態結構:指的是
import/export
的語法限制(必須在頂層、字串常數、匯出名稱固定)。這些規則保證了依賴關係在程式執行前就能被完整看懂。 - 靜態分析:工具利用這些規則,在建置期就能讀懂依賴,整理出依賴圖。
- module graph:這個依賴圖的正式名稱,後續 tree-shaking、錯誤檢查、甚至載入順序的決定,都靠它來完成。
如果再對照 TypeScript:TS 的靜態是「型別」檢查;ESM 的靜態是模組「語法」結構。雖然方向不同,但都是在「程式還沒執行前」先做事,這也是我最後理解「靜態」的關鍵。
TS vs ESM 的差異TypeScript 的型別資訊只存在編譯期;而 ESM 的
import/export
則會一路保留到執行期,由瀏覽器或 Node.js 解析載入。
module graph 的應用場景module graph 會根據不同流程有不同用途:
- 建置期的 module graph:由建置工具(如 Webpack、Rollup、esbuild、Vite)透過靜態分析產生,用來做最佳化(tree-shaking、code splitting 等)。
- 執行期的 module graph:由瀏覽器或 Node.js 的 ES loader 在模組連結(linking)階段建立,用來決定載入順序、處理循環依賴與快取。
→ 載入和執行順序是執行期的工作,並不依賴建置工具的 module graph。
flowchart LR
A["靜態結構<br/>(ESM 語法限制)"]
subgraph RunTime["執行期(ES Loader)"]
D["模組連結"]
E["執行期 module graph<br/>(載入順序、快取、循環依賴)"]
end
subgraph BuildTime["建置期(工具)"]
B["靜態分析"]
C["建置期 module graph<br/>(最佳化用途)"]
end
A --> B --> C
A --> D --> E
匯出與匯入語法
// todo-manager.js
let todos = []
let todoId = 0
export function add(text) {
todos.push({ id: ++todoId, text, completed: false })
}
export function remove(id) {
todos = todos.filter(todo => todo.id !== id)
}
export function getAll() {
return [...todos]
}
// 預設匯出
export default {
add,
remove,
getAll
}
// stats-manager.js
export function calculateStats(todos) {
return {
total: todos.length,
completed: todos.filter(t => t.completed).length
}
}
// notification-manager.js
const notifications = []
export function show(message) {
notifications.push(message)
console.log(`通知:${message}`)
}
// app.js
import { calculateStats } from './stats-manager.js'
import todoManager, { add, remove } from './todo-manager.js'
// 使用方式一:預設匯出
todoManager.add('學習 ES6 Modules')
// 使用方式二:具名匯出
add('另一個任務')
const todos = todoManager.getAll()
const stats = calculateStats(todos)
環境差異在 瀏覽器 中,ESM 透過
<script type="module">
就能直接載入; 在 Node.js 中,則需要在package.json
宣告"type": "module"
或使用.mjs
副檔名。另外
.mjs
在瀏覽器端其實不是必須(.js
一樣能跑,只要伺服器回應正確 MIME type), 但教學與跨環境範例常會刻意用.mjs
,讓人一眼就知道這是 ESM。→ 細節可參考 MDN Modules 與 Node.js 官方文件。
重點整理:五步學習卡
1) 問題來源
程式碼規模增長時:
- 全域變數污染:命名衝突、不知道誰在用什麼
- 功能高耦合:改一個地方影響其他地方
- 協作困難:多人開發時互相影響
👉 需要一種方式來組織程式碼,避免混亂。
2) 白話描述
Module Pattern 就像給程式碼「劃分房間」:
- 每個功能都有自己的獨立空間(避免全域污染)
- 房間有門鎖,可以控制誰能進出(公私介面)
- 房間之間可以透過明確的管道溝通(模組依賴)
- 房間門口會掛門牌,避免名字混亂(命名空間)
3) 專業術語
在 JavaScript 裡,Module Pattern 是透過封裝來組織程式碼的設計模式。從 IIFE 的基本封裝,到 CommonJS 的檔案模組系統,再到 ES6 Modules 的原生支援,都是這個模式的不同實現方式。
4) 視覺化
每個模組就像一個獨立的「房間」(灰藍色區域),內部存放私有邏輯(深灰藍),只透過「公開介面」與外界溝通。全域環境(暗棕色)則充滿混亂,模組化讓程式碼井然有序,降低耦合、避免污染。
graph TD
subgraph Global["全域環境"]
A[app.js]
end
subgraph NotificationModule["notification-manager.js(模組)"]
F[公開介面<br/>show]
G[私有邏輯<br/>notifications]
end
subgraph StatsModule["stats-manager.js(模組)"]
D[公開介面<br/>calculateStats]
E[私有邏輯<br/>stats]
end
subgraph TodoModule["todo-manager.js(模組)"]
B[公開介面<br/>add, remove, getAll]
C[私有邏輯<br/>todos, todoId, render]
end
A -->|呼叫 add, remove| B
A -->|呼叫 calculateStats| D
A -->|呼叫 show| F
B --> C
D --> E
F --> G
style Global fill:#5c4033,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
style TodoModule fill:#4a5a6b,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
style StatsModule fill:#4a5a6b,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
style NotificationModule fill:#4a5a6b,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
style C fill:#2e3b4e,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
style E fill:#2e3b4e,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
style G fill:#2e3b4e,stroke:#d0d0d0,stroke-width:2px,color:#ffffff
5) 程式碼與適用情境
適用情境
- 程式碼規模較大,需要組織結構
- 多人協作開發
- 需要避免全域變數污染
- 功能需要明確的介面邊界
不適用情境
- 簡單的小專案(過度設計)
- 只有少量程式碼的情況
IIFE 基本封裝
const MyModule = (function () {
let privateVar = 0
return {
publicMethod() {
return ++privateVar
}
}
})()
ES6 Modules
// module.js
let privateVar = 0
export function publicMethod() {
return ++privateVar
}
// main.js
import { publicMethod } from './module.js'
後記
原來我一直在 React 裡默默使用 Module Pattern。每個元件就是一個小模組:檔案封裝、props 當介面。以前覺得那只是框架的規範,現在才意識到背後其實就是設計模式。