Module Pattern:替程式劃分房間

·16 min

此為《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

解決的問題

  1. 避免全域污染todostodoId 不會污染全域空間
  2. 封裝私有邏輯render 函式外部無法直接呼叫
  3. 清楚的公開介面:只暴露 adddeletegetAll 方法

多個模組的組成

// 統計模組
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 }

解決的問題

  1. 檔案間依賴管理:用 require 明確指定依賴關係
  2. 同步載入:適合伺服器端環境,檔案都在本地
  3. 標準化: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 ModulesNode.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 當介面。以前覺得那只是框架的規範,現在才意識到背後其實就是設計模式。

感謝你讀到這裡!如果我的分享讓你有所共鳴或啟發,歡迎請我喝杯珍奶 🧋,給我一點繼續分享的能量: https://anna.bobaboba.me