TypeScript – 模組與命名空間(Modules & Namespaces)
主題:ES Modules(import / export)
簡介
在現代前端與 Node.js 開發中,模組化已成為必備的程式設計概念。它讓我們可以把程式切割成可重用、易維護的小單位,並透過明確的介面(export)與依賴(import)進行溝通。
ES Modules(簡稱 ESM)是 ECMAScript 官方標準的模組系統,已被所有主流瀏覽器與 Node.js 完全支援。TypeScript 完全遵循 ESM 的語法與行為,同時提供型別檢查與編譯期的安全性,讓開發者在寫 JavaScript 時也能享受到靜態型別的好處。
本篇文章將從 概念、語法、實作範例 三個層面,帶領讀者深入了解 ES Modules 在 TypeScript 中的使用方式,並分享常見陷阱與實務最佳實踐,幫助你在真實專案中快速上手、寫出更乾淨、更可維護的程式碼。
核心概念
1. ES Modules 的基本語法
| 關鍵字 | 用途 | 範例 |
|---|---|---|
export |
將變數、函式、類別、型別等 公開 給其他模組使用 | export const PI = 3.14; |
export default |
唯一 的預設匯出,讓匯入端可以自行命名 | export default class Logger {} |
import |
從其他模組 匯入 先前 export 的成員 |
import { PI } from "./constants"; |
import * as |
匯入整個模組為一個命名空間物件 | import * as utils from "./utils"; |
import … from |
匯入預設匯出 | import Logger from "./logger"; |
註:在 TypeScript 中,
export也可以直接匯出型別(type、interface),但型別資訊只在編譯期存在,最終產出的 JavaScript 不會保留。
2. 匯出(Export)方式
2.1 命名匯出(Named Export)
// file: math.ts
export const add = (a: number, b: number): number => a + b;
export const mul = (a: number, b: number): number => a * b;
// 也可以一次匯出多個成員
export { sub, div };
function sub(a: number, b: number): number {
return a - b;
}
function div(a: number, b: number): number {
return a / b;
}
重點:命名匯出可以在同一檔案中出現多次,匯入端必須使用相同的名稱(或使用
as重新命名)。
2.2 預設匯出(Default Export)
// file: logger.ts
export default class Logger {
private prefix: string;
constructor(prefix: string = "[Log]") {
this.prefix = prefix;
}
info(msg: string) {
console.log(`${this.prefix} ${msg}`);
}
}
技巧:若模組只輸出單一功能或類別,使用
default可以讓匯入程式碼更簡潔。
2.3 同時使用 Named 與 Default
// file: config.ts
export const API_URL = "https://api.example.com";
export const TIMEOUT = 5000;
export default {
API_URL,
TIMEOUT,
};
3. 匯入(Import)方式
3.1 匯入命名匯出
// file: app.ts
import { add, mul } from "./math";
console.log(add(2, 3)); // 5
console.log(mul(4, 5)); // 20
3.2 匯入預設匯出
// file: main.ts
import Logger from "./logger";
const log = new Logger("[MyApp]");
log.info("啟動完成");
3.3 匯入所有成員(Namespace Import)
// file: utilDemo.ts
import * as MathUtil from "./math";
console.log(MathUtil.add(1, 2)); // 3
console.log(MathUtil.mul(3, 4)); // 12
3.4 重新命名匯入
// file: renameDemo.ts
import { add as sum, mul as product } from "./math";
console.log(sum(5, 6)); // 11
console.log(product(2, 7)); // 14
3.5 動態匯入(Dynamic Import)
// file: lazyLoad.ts
async function loadLogger() {
const { default: Logger } = await import("./logger");
const logger = new Logger("[Lazy]");
logger.info("動態匯入成功");
}
loadLogger();
說明:
import()會回傳一個Promise,適合在需要 懶載入(code‑splitting)或條件載入的情境。
4. TypeScript 與 ES Modules 的相容設定
| 設定項目 | 說明 | 範例 |
|---|---|---|
module |
指定編譯目標的模組系統,ESNext、ES2020、CommonJS 等 |
"module": "ESNext" |
target |
設定編譯後的 JavaScript 版本 | "target": "ES2019" |
esModuleInterop |
允許 import foo = require("foo") 與 import foo from "foo" 混用 |
"esModuleInterop": true |
allowSyntheticDefaultImports |
允許在沒有 default 匯出的模組上使用預設匯入 |
"allowSyntheticDefaultImports": true |
// tsconfig.json 範例
{
"compilerOptions": {
"target": "ES2019",
"module": "ESNext",
"moduleResolution": "Node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
程式碼範例(實務應用)
範例 1:建立一個簡易的 HTTP Client 模組
// file: httpClient.ts
export interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "DELETE";
headers?: Record<string, string>;
body?: any;
}
/**
* 使用 fetch 包裝的通用 HTTP 請求函式
* @param url 請求的 URL
* @param options 可選的設定
*/
export async function request<T = any>(url: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", headers = {}, body } = options;
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return (await response.json()) as T;
}
// file: userService.ts
import { request, RequestOptions } from "./httpClient";
export interface User {
id: number;
name: string;
email: string;
}
/**
* 取得使用者清單
*/
export async function getUsers(): Promise<User[]> {
return request<User[]>("/api/users");
}
/**
* 新增使用者
*/
export async function createUser(user: Omit<User, "id">): Promise<User> {
const opts: RequestOptions = { method: "POST", body: user };
return request<User>("/api/users", opts);
}
重點:透過 型別參數
<T>讓request能在呼叫端取得正確的回傳型別,減少手動斷言的錯誤機會。
範例 2:使用 預設匯出 建立一個 Logger 單例
// file: logger.ts
class Logger {
private prefix: string;
constructor(prefix = "[App]") {
this.prefix = prefix;
}
log(message: string, ...optionalParams: any[]) {
console.log(`${this.prefix} ${message}`, ...optionalParams);
}
error(message: string, ...optionalParams: any[]) {
console.error(`${this.prefix} ${message}`, ...optionalParams);
}
}
// 匯出唯一實例
const logger = new Logger("[MyProject]");
export default logger;
// file: index.ts
import logger from "./logger";
logger.log("系統啟動");
logger.error("發生未處理的例外");
實務技巧:使用 預設匯出 搭配單例模式,可避免在大型專案中重複建立相同工具物件。
範例 3:動態匯入 搭配 Tree Shaking
// file: chart.ts
export function drawBarChart(data: number[]) {
console.log("畫 Bar Chart", data);
}
export function drawPieChart(data: number[]) {
console.log("畫 Pie Chart", data);
}
// file: dashboard.ts
async function loadChart(type: "bar" | "pie") {
const module = await import("./chart");
if (type === "bar") {
module.drawBarChart([10, 20, 30]);
} else {
module.drawPieChart([40, 60, 20]);
}
}
loadChart("pie");
說明:Webpack、Vite 等打包工具會根據
import()的路徑只載入實際使用的函式,減少最終 bundle 大小。
範例 4:混用 CommonJS 與 ES Modules(在 Node.js 中)
// file: legacy.js (CommonJS)
module.exports = {
greet(name) {
return `Hello, ${name}!`;
},
};
// file: modern.ts (ESM)
import legacy from "./legacy.js"; // 需要 esModuleInterop / allowSyntheticDefaultImports
console.log(legacy.greet("TypeScript"));
提醒:在 Node.js 18+,若
package.json設定"type": "module",則.js會被視為 ES Module,需使用.cjs或require()方式載入舊模組。
常見陷阱與最佳實踐
| 陷阱 | 可能的結果 | 解決方案 / 最佳實踐 |
|---|---|---|
忘記加 .js 副檔名(在 Node 的 ES Module) |
執行時 ERR_MODULE_NOT_FOUND |
在 import 路徑中寫完整的檔名(./utils.js),或使用 tsc --moduleResolution node16 |
同時使用 export default 與 export,但匯入時只寫預設 |
其他成員無法被使用 | 匯入時 同時 寫 { … } 與 default:import logger, { level } from "./logger" |
在 tsconfig.json 中未開啟 esModuleInterop,導致 import foo from "foo" 錯誤 |
必須使用 import * as foo from "foo",代碼較冗長 |
設定 "esModuleInterop": true 讓 CommonJS 模組也能使用預設匯入 |
動態匯入的路徑寫成相對路徑但缺少 .js(在編譯後) |
打包後找不到模組 | 使用 import(/* webpackChunkName: "chart" */ "./chart.js") 或在 tsconfig 裡設定 moduleResolution: "node" |
過度使用 export * from,導致命名衝突 |
編譯期或執行期報錯 | 明確列出要匯出的成員,或使用 as 重新命名衝突的項目 |
最佳實踐
- 盡量使用命名匯出:讓匯入端清楚知道每個成員的來源,減少未來重構時的破壞性變更。
- 預設匯出只用於單一核心功能(例如 React 元件、單例服務)。
- 保持檔案與資料夾結構的對應:每個功能模組放在自己的資料夾,並在
index.ts中匯出公共介面,方便import { Foo } from "@/features/foo"。 - 使用
type匯出純型別:export type User = …,可避免在編譯後產生無效的 JavaScript。 - 設定嚴格的編譯選項:
"strict": true、"noImplicitAny": true,確保模組之間的型別安全。
實際應用場景
| 場景 | 為何使用 ES Modules | 範例 |
|---|---|---|
| 大型前端單頁應用(SPA) | 透過 import/export 自然形成 依賴圖,配合 Webpack/Vite 進行 tree‑shaking、code splitting。 |
React + Redux:import store from "./store"、export const actions = … |
| Node.js 微服務 | Node 18+ 原生支援 ESM,讓服務間可以共享相同的工具函式庫,且不需要 Babel 轉譯。 | import { connect } from "./db",在每個微服務中重複使用。 |
| 共用函式庫(npm package) | 使用 export 再配合 package.json 中的 "exports" 欄位,限制外部只能存取公開 API。 |
export function formatDate(date: Date): string,在 package.json 設定 "exports": "./dist/index.js" |
| 動態載入第三方套件 | 在使用者互動時才載入較大的套件(如圖表、編輯器),降低首次載入時間。 | const { Editor } = await import("@toast-ui/editor"); |
| SSR(Server‑Side Rendering) | 使用同一套 ES Module 程式碼在伺服器與瀏覽器執行,避免寫兩套模組系統。 | Next.js 中 import { getServerSideProps } from "./data" |
總結
ES Modules 為 JavaScript(與 TypeScript)提供了 標準化、可預測 的模組機制。透過 export/import,我們可以:
- 明確劃分 程式碼邊界,提升可讀性與維護性。
- 利用編譯器與打包工具 進行型別檢查、tree‑shaking、code‑splitting,顯著改善效能。
- 在前端與 Node.js 之間共享相同的模組寫法,降低學習成本與技術債。
在實務開發中,建議 以命名匯出為主,僅在單一核心功能時使用預設匯出;同時配合 tsconfig.json 的嚴格設定與適當的檔案結構,能讓你的 TypeScript 專案在長期維護上更加穩固。
掌握了 ES Modules 的概念與最佳實踐,你就能在任何規模的 TypeScript 專案中,寫出 乾淨、可測試且易於擴充 的程式碼。祝開發順利,持續享受模組化帶來的開發快感!