本文 AI 產出,尚未審核

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.mjsnode 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 + 大量未使用的副作用程式碼。

進階最佳實踐

  1. 保持模組單一職責:每個檔案只負責一件事(工具函式、服務、組件),有助於測試與重用。
  2. 使用明確的檔案副檔名:在 Node 中統一使用 .mjs 或在 package.jsontype: "module",避免混淆。
  3. 避免循環依賴:ESM 雖支援循環引用,但會導致「未初始化」的變數錯誤,設計時應盡量使用 依賴注入中介者
  4. 利用 import.meta.url:在模組內部取得當前檔案的 URL,可用於動態路徑或讀取同目錄資源(Node 需要 import.meta.resolve)。
  5. 設定 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/importexport default、以及支援 動態匯入import(),開發者能夠:

  • 明確管理依賴:編譯器可在編譯期檢查未使用或未匯出的成員。
  • 提升效能:結合 Tree‑shaking 與程式碼分割,減少不必要的下載與執行成本。
  • 增進可維護性:單一職責的模組讓程式碼結構更清晰,測試與重構更容易。

在實務開發中,建議從 小型工具函式 開始使用命名匯出,逐步擴展到 服務類別元件庫,並善用 動態匯入 於懶載入與插件系統。只要遵守上述的最佳實踐與避免常見陷阱,ESM 將成為你打造高品質、可擴展 JavaScript 應用的基石。祝開發順利,玩得開心! 🎉