JavaScript 模組與封裝:深入 ES Modules (ESM)
簡介
在現代前端與 Node.js 開發中,模組化 已成為組織程式碼、提升可維護性與重用性的核心手段。過去 JavaScript 只能透過全域變數或 IIFE(Immediately‑Invoked Function Expression)來模擬模組,隨著 ES6(ECMAScript 2015)正式引入 ES Modules (ESM),語言本身便提供了原生、統一的模組系統。
ESM 不僅解決了舊有 CommonJS(require/module.exports)在瀏覽器端的相容問題,也讓開發者可以在 編譯期 靜態分析依賴關係,進一步支援 Tree‑shaking、原生動態匯入(import())以及瀏覽器的原生模組載入。對於想要寫出結構清晰、易於測試與維護的程式碼的開發者來說,熟悉 ESM 是必備技能。
本篇文章將從 語法基礎、實作細節、常見陷阱 與 最佳實踐,一步步帶你掌握 ES Modules,並提供多個實用範例,讓你能在實務專案中立即上手。
核心概念
1. ES Module 的基本語法
| 功能 | 語法 | 說明 |
|---|---|---|
| 匯入單一成員 | import { foo } from './module.js'; |
只匯入 module.js 中以 export 暴露的 foo。 |
| 匯入全部成員 | import * as utils from './utils.js'; |
以 utils 命名空間取得所有匯出的成員。 |
| 匯入預設成員 | import Bar from './Bar.js'; |
只匯入 export default 的值。 |
| 匯出變數/函式 | export const PI = 3.14; |
直接在宣告時匯出。 |
| 匯出預設成員 | export default function() {} |
只能有一個預設匯出。 |
| 動態匯入 | const mod = await import('./module.js'); |
會回傳一個 Promise,適合懶載入或條件載入。 |
注意:ESM 必須在檔案副檔名為
.js(或.mjs)且在瀏覽器或 Node.js 中以 模組模式 執行,否則會拋出SyntaxError: Unexpected token import。
2. 匯出(Export)方式
2.1 命名匯出(Named Export)
// math.js
export const add = (a, b) => a + b;
export const sub = (a, b) => a - b;
// 也可以一次匯出多個
const mul = (a, b) => a * b;
const div = (a, b) => a / b;
export { mul, div };
小技巧:使用
export前綴可以直接在宣告時匯出,讓檔案結構更直觀;若需要在最後統一匯出,使用export { … }會更易於閱讀。
2.2 預設匯出(Default Export)
// logger.js
export default class Logger {
constructor(prefix = '') {
this.prefix = prefix;
}
log(msg) {
console.log(`[${this.prefix}] ${msg}`);
}
}
提醒:一個模組只能有 一個
export default,但可以同時擁有多個命名匯出。
3. 匯入(Import)技巧
3.1 同時匯入命名與預設成員
// app.js
import Logger, { level } from './logger.js';
3.2 重新命名匯入
import { add as sum, sub as difference } from './math.js';
3.3 只匯入副作用(Side‑effects)
有時模組只負責執行初始化程式碼(例如 polyfill),不需要任何匯出:
import './polyfills.js'; // 只執行檔案內容
4. 動態匯入(Dynamic Import)
動態匯入允許在程式執行時才載入模組,常見於 懶載入(Lazy Loading)、條件載入 或 程式碼分割:
// 假設使用在路由切換時才載入對應的頁面模組
async function loadPage(page) {
const module = await import(`./pages/${page}.js`);
module.render(); // 每個 page 模組都必須 export render
}
// 範例:僅在使用者點擊「下載」時才載入大型檔案處理函式
document.getElementById('downloadBtn')
.addEventListener('click', async () => {
const { downloadFile } = await import('./utils/download.js');
downloadFile();
});
最佳實踐:在 Webpack、Vite 等打包工具中,
import()會自動產生 code‑splitting,減少首屏載入大小。
5. 模組解析(Resolution)與檔案類型
| 環境 | 預設檔案類型 | 設定方式 |
|---|---|---|
| 瀏覽器 | type="module" 的 <script>,支援 .js(視為 ESM) |
<script type="module" src="app.js"></script> |
| Node.js | .mjs 或在 package.json 設 "type": "module" |
node index.mjs 或 node index.js(若 type: "module") |
陷阱:在 Node.js 中混用 CommonJS (
require) 與 ESM (import) 會產生錯誤,必須透過import()或使用createRequire轉換。
程式碼範例
範例 1:基本的工具函式模組
// utils/math.js
export const PI = 3.14159;
export function areaOfCircle(r) {
return PI * r * r;
}
export function circumference(r) {
return 2 * PI * r;
}
// main.js
import { areaOfCircle, circumference, PI } from './utils/math.js';
console.log('π =', PI);
console.log('Area (r=2) =', areaOfCircle(2));
console.log('Circumference (r=2) =', circumference(2));
說明:此範例展示 命名匯出 與 解構式匯入,讓使用者僅挑選需要的函式,減少不必要的程式碼載入。
範例 2:預設匯出 + 類別封裝
// services/apiClient.js
export default class ApiClient {
constructor(baseURL) {
this.baseURL = baseURL;
}
async get(path) {
const resp = await fetch(`${this.baseURL}${path}`);
return resp.json();
}
async post(path, data) {
const resp = await fetch(`${this.baseURL}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
}
}
// app.js
import ApiClient from './services/apiClient.js';
const client = new ApiClient('https://api.example.com/');
client.get('/users').then(users => console.log(users));
說明:
export default讓類別在匯入時可以自行命名,適合「單一職責」的服務類別。
範例 3:動態匯入實作懶載入元件(Vue 3 + Vite 示例)
// components/HelloWorld.vue
<template><h1>Hello, World!</h1></template>
<script setup></script>
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../components/HelloWorld.vue'), // 動態匯入
},
// 其他路由...
];
export default createRouter({
history: createWebHistory(),
routes,
});
說明:
component: () => import('…')會在使用者首次訪問該路由時才載入HelloWorld.vue,有效減少初始 bundle 大小。
範例 4:同時支援 CommonJS 與 ESM(跨平台套件)
// lib/index.mjs
export function greet(name) {
return `Hello, ${name}!`;
}
// 讓 CommonJS 也能使用
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
module.exports = { greet };
說明:使用
createRequire可以在 ESM 檔案內部建立 CommonJS 的require,讓套件同時提供兩種入口,提升相容性。
範例 5:利用副作用匯入初始化全域設定
// config/setup.js
import { setLocale } from './i18n.js';
import { setTheme } from './theme.js';
// 只要被匯入,就會自動執行設定
setLocale('zh-TW');
setTheme('dark');
// main.js
import './config/setup.js'; // 觸發副作用
import { translate } from './i18n.js';
console.log(translate('welcome'));
說明:此種寫法常見於 polyfill、全域監聽 或 一次性初始化 的情境。
常見陷阱與最佳實踐
| 陷阱 | 可能的錯誤 | 解決方案 / 最佳實踐 |
|---|---|---|
忘記在瀏覽器加 type="module" |
Uncaught SyntaxError: Cannot use import statement outside a module |
所有 <script> 必須加 type="module",或使用 bundler 產生相容的檔案。 |
| 相對路徑錯誤 | Cannot find module './utils' |
使用 完整副檔名(.js、.mjs)且確保路徑相對於匯入檔案。 |
| 混用 CommonJS 與 ESM | SyntaxError: Unexpected token 'export' 或 ERR_REQUIRE_ESM |
在 Node 中統一使用 type: "module",或將 CommonJS 模組改寫為 ESM;若必須混用,使用 import() 或 createRequire。 |
| 預設匯出與命名匯出同名 | 匯入時產生混淆 | 避免 同時使用相同名稱的預設與命名匯出;若需要,使用別名(import Foo, { Foo as FooNamed })但要保持清晰。 |
| 動態匯入的錯誤處理 | Promise 拒絕未捕獲導致未捕獲的例外 | 使用 try...catch 或 .catch() 處理 import() 的錯誤。 |
| Tree‑shaking 失效 | 打包後未去除未使用程式碼 | 只使用命名匯出,避免在同一檔案內同時使用 export default + 大量未使用的副作用程式碼。 |
進階最佳實踐
- 保持模組單一職責:每個檔案只負責一件事(工具函式、服務、組件),有助於測試與重用。
- 使用明確的檔案副檔名:在 Node 中統一使用
.mjs或在package.json設type: "module",避免混淆。 - 避免循環依賴:ESM 雖支援循環引用,但會導致「未初始化」的變數錯誤,設計時應盡量使用 依賴注入 或 中介者。
- 利用
import.meta.url:在模組內部取得當前檔案的 URL,可用於動態路徑或讀取同目錄資源(Node 需要import.meta.resolve)。 - 設定
exports欄位(在package.json):明確定義套件對外暴露的子路徑,提升安全性與相容性。
// package.json
{
"type": "module",
"exports": {
"./utils": "./src/utils/index.js",
"./logger": "./src/logger.js"
}
}
實際應用場景
| 場景 | 為何使用 ESM | 範例 |
|---|---|---|
| 大型前端單頁應用(SPA) | 透過 import() 實現程式碼分割、懶載入,提高首次渲染速度。 |
Vite、Webpack 的 dynamic import。 |
| Node.js 微服務 | 使用原生 ESM 可直接在服務間共享相同的模組語法,避免 Babel 轉譯成本。 | 以 type: "module" 建立微服務入口。 |
| 共用 UI 元件庫 | 命名匯出讓使用者只匯入需要的元件,配合 Tree‑shaking 減少 bundle 大小。 | export { Button, Modal } from './components'。 |
| 插件系統 | 動態匯入插件模組,允許在執行時載入第三方擴充功能。 | const plugin = await import(./plugins/${name}.js); |
| 測試框架 | 使用 ESM 可以直接在測試檔案中 import 被測試的模組,保持語法一致。 |
Jest 2024 版支援 esm。 |
總結
ES Modules 為 JavaScript 帶來了 標準化、靜態分析與跨平台 的模組機制。透過 export/import、export default、以及支援 動態匯入 的 import(),開發者能夠:
- 明確管理依賴:編譯器可在編譯期檢查未使用或未匯出的成員。
- 提升效能:結合 Tree‑shaking 與程式碼分割,減少不必要的下載與執行成本。
- 增進可維護性:單一職責的模組讓程式碼結構更清晰,測試與重構更容易。
在實務開發中,建議從 小型工具函式 開始使用命名匯出,逐步擴展到 服務類別、元件庫,並善用 動態匯入 於懶載入與插件系統。只要遵守上述的最佳實踐與避免常見陷阱,ESM 將成為你打造高品質、可擴展 JavaScript 應用的基石。祝開發順利,玩得開心! 🎉