TypeScript – 模組與命名空間(Modules & Namespaces)
主題:命名空間(namespace)
簡介
在大型的 TypeScript 專案中,程式碼的組織與可維護性 是成功的關鍵。
早期的 JavaScript 只能靠全域變數或立即執行函式 (IIFE) 來避免命名衝突,而 TypeScript 則提供了兩種官方的封裝機制:模組(module) 與 命名空間(namespace)。
本篇聚焦於 命名空間,說明它的設計目的、使用方式以及在什麼情況下仍值得採用。即使在 ES6+ 時代,了解 namespace 的概念仍能幫助開發者在遺留系統、腳本工具或是需要「單檔」部署的情境下,寫出結構清晰、避免全域污染的程式碼。
核心概念
1. 命名空間的基本語法
namespace MyLibrary {
export const version = "1.0.0";
export function greet(name: string): string {
return `Hello, ${name}!`;
}
// 內部不會被外部直接存取的私有成員
const secret = "only inside";
}
namespace關鍵字宣告一個 命名空間,其內容會被包在同一個 JavaScript 立即函式中,避免污染全域。- 必須使用
export標記想要公開的類別、函式、介面或變數,否則它只在命名空間內部可見。
2. 多檔案合併(Declaration Merging)
命名空間支援「宣告合併」:同名的 namespace 可以分散在多個檔案,只要在編譯時一起編譯,就會自動合併成一個完整的命名空間。
// file: math.ts
namespace Utilities {
export function add(a: number, b: number): number {
return a + b;
}
}
// file: string.ts
namespace Utilities {
export function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
}
編譯 tsc math.ts string.ts --outFile utilities.js 後,Utilities 會同時擁有 add 與 capitalize 兩個方法。
3. 巢狀命名空間(Nested Namespaces)
為了更細緻的層級劃分,可以在命名空間內再宣告子命名空間:
namespace App {
export namespace Models {
export interface User {
id: number;
name: string;
}
}
export namespace Services {
export class UserService {
getUser(id: number): App.Models.User {
// 假設從伺服器取得資料
return { id, name: "Alice" };
}
}
}
}
使用時:
const svc = new App.Services.UserService();
const user = svc.getUser(1);
console.log(user.name); // Alice
4. 匯入外部 JavaScript(declare namespace)
當需要在 TypeScript 中使用已有的全域 JavaScript 函式庫(例如 jQuery、Google Maps),可以使用 declare namespace 只描述型別,而不產生任何 JavaScript:
declare namespace GoogleMaps {
function initMap(mapId: string): void;
interface LatLng {
lat: number;
lng: number;
}
}
此時,編譯器只會檢查呼叫方式是否正確,而不會產生 GoogleMaps 的實作。
5. 與 ES 模組的互操作
雖然 ES6 模組是現在的主流,但有時候仍會在同一專案中同時使用 import/export 與 namespace。常見做法是:
// utils.ts – ES 模組
export function formatDate(d: Date): string {
return d.toISOString().split("T")[0];
}
// legacy.ts – 命名空間(舊式腳本)
namespace Legacy {
export function log(msg: string): void {
console.log("[Legacy] " + msg);
}
}
在另一個檔案中混合使用:
import { formatDate } from "./utils";
Legacy.log(`今天日期:${formatDate(new Date())}`);
只要 tsconfig.json 的 module 設為 esnext 或 commonjs,編譯器會自行處理兩者的相容性。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 全域污染 | 忘了 export,導致變數仍在命名空間外可見,破壞封裝。 |
始終使用 export,或在命名空間最外層加上 export 讓整體可見。 |
| 過度巢狀 | 命名空間層級太深,導致引用時寫出長長的路徑。 | 適度分層,只在功能上有明顯界線時才建立子命名空間。 |
| 與模組混用不當 | 同一檔案同時使用 import/export 與 namespace,可能產生編譯警告。 |
盡量分檔:模組檔案使用 ES 模組,純腳本檔案使用 namespace。 |
| 宣告合併衝突 | 不同檔案中同名的介面或類別定義不一致,編譯器會報錯。 | 保持一致的 API 設計,或使用 declare module 取代 namespace。 |
| 編譯輸出設定 | 使用 --outFile 合併多個 namespace 時,若 module 設為 esnext 會失效。 |
將 module 設為 none(或 amd/system),或改用模組系統。 |
最佳實踐:
- 僅在需要單檔輸出或與舊版腳本整合時使用 namespace。新專案優先選擇 ES 模組。
- 將公開 API 放在最外層命名空間,內部實作以
private變數或未export的函式隱藏。 - 利用宣告合併 讓大型程式庫的型別維護更方便,但務必在
tsconfig.json中設定正確的檔案包含範圍。 - 在 TypeScript 4.5+,考慮使用
export type、export const enum來減少編譯後的冗餘程式碼。
實際應用場景
1. 老舊前端專案的漸進式升級
一個使用 jQuery、Bootstrap、全域變數的舊系統,想要逐步引入 TypeScript。可以先把每個功能模組寫成 namespace,然後在 tsconfig.json 設定 outFile: bundle.js,最終仍以單一 <script> 載入,避免改動既有 HTML 結構。
2. 命令列工具(CLI)一次性打包
Node.js 的 CLI 工具往往只需要一個可執行檔。將所有指令實作放在同一個 namespace,配合 tsc --outFile cli.js,即可產生單檔且不依賴模組載入器的執行檔。
#!/usr/bin/env node
namespace CLI {
export function run(argv: string[]) {
if (argv.includes("--help")) {
console.log("使用方式: mytool [options]");
} else {
console.log("執行中...");
}
}
}
CLI.run(process.argv.slice(2));
3. 宣告第三方全域庫的型別
許多外部腳本(例如 Google Analytics)直接掛在 window 上,沒有模組化的入口。使用 declare namespace 可以在 TypeScript 中安全地呼叫:
declare namespace ga {
function create(trackingId: string, options?: object): void;
function send(eventCategory: string, eventAction: string): void;
}
ga.create("UA-XXXXX-Y");
ga.send("button", "click");
總結
- 命名空間 是 TypeScript 提供的「封裝」機制,適合單檔輸出、遺留系統或需要宣告全域變數的情境。
- 透過
export、巢狀、宣告合併 等特性,我們可以在不污染全域的前提下,建立可維護的程式碼結構。 - 在現代開發中,模組(module) 才是主流;但了解 namespace 的運作原理與最佳實踐,能讓我們更靈活地處理舊有專案或特殊需求。
掌握了命名空間的概念後,你就能在 TypeScript 生態中自如切換「模組」與「全域」兩種風格,根據專案需求選擇最合適的封裝方式,寫出既安全又易於維護的程式碼。祝你在 TypeScript 的旅程中,玩得開心、寫得順手!