TypeScript – 模組與命名空間(Modules & Namespaces)
主題:CommonJS / require 相容性
簡介
在 Node.js 生態系統中,CommonJS 仍是最常見的模組規範;大多數套件都是以 module.exports / require() 的方式提供。
然而,TypeScript 原生支援 ES Module(import / export),兩者的語法與執行機制並不相同。若在 TypeScript 專案中直接使用 require,或是將 ES Module 編譯成 CommonJS,必須了解相容性細節,才能避免執行時錯誤或類型檢查失效。
本篇文章將說明:
- 為什麼需要在 TypeScript 中處理 CommonJS 相容性
- 主要的編譯選項與語法技巧
- 實作範例、常見陷阱與最佳實踐
- 真實專案中可能的使用情境
目標讀者:已具備基本 TypeScript 語法的初學者與中級開發者,想在 Node.js 或混合環境中安全使用
require。
核心概念
1. CommonJS 與 ES Module 的差異
| 項目 | CommonJS | ES Module |
|---|---|---|
| 載入方式 | const foo = require('foo')(同步) |
import foo from 'foo'(靜態、可樹搖) |
| 匯出方式 | module.exports = {...} 或 exports.foo = ... |
export default ...、export const foo = ... |
| 執行時期 | 在程式執行時即解析 | 在編譯階段就確定依賴(靜態分析) |
| 作用域 | 每個檔案都有自己的 module 物件 |
每個檔案都有自己的模組命名空間 |
Node.js 從 v12 起已支援原生 ES Module(副檔名 .mjs 或 package.json 中的 "type":"module"),但 大部分套件仍以 CommonJS 發佈,因此在 TypeScript 中需要兼容兩者。
2. tsconfig.json 中的關鍵設定
| 設定 | 說明 | 常見值 |
|---|---|---|
module |
輸出目標模組系統 | "commonjs"、"esnext"、"es6" |
target |
產出 JavaScript 的語法等級 | "es2017"、"es2020" |
esModuleInterop |
允許 import foo from "foo" 直接對應 module.exports = foo |
true |
allowSyntheticDefaultImports |
允許在沒有 default 匯出的模組上使用 import foo from(不改變編譯結果) |
true |
resolveJsonModule |
允許直接 import json |
true |
重點:若要在 TypeScript 中使用
require,不一定要關閉esModuleInterop;但若想使用import兼容 CommonJS,建議開啟esModuleInterop,可讓編譯器自動為module.exports加上default屬性。
3. export = 與 import = require()
在 TypeScript 中,export = 專門對應 CommonJS 的 module.exports。其配對的載入語法是 import = require(),這是唯一在 TypeScript 中仍保留的 require 形式。
// foo.ts
function greet(name: string): string {
return `Hello, ${name}!`;
}
export = greet; // 等同於 module.exports = greet;
// main.ts
import greet = require("./foo"); // 取得 module.exports
console.log(greet("World"));
使用
export =時,不能同時使用export、export default,否則會產生衝突。
4. import foo from "foo" 與 esModuleInterop
開啟 esModuleInterop 後,編譯器會在輸出時自動產生以下輔助程式碼,讓 import foo from "foo" 能對應到 module.exports = foo。
// 編譯前 (TS)
import foo from "foo";
// 編譯後 (CommonJS, esModuleInterop:true)
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const foo_1 = __importDefault(require("foo"));
const foo = foo_1.default;
這樣的結果是 foo 會是 module.exports 本身,而不是 { default: ... },因此在大多數情況下不需要額外的 .default 取值。
5. 直接使用 Node.js 原生 require
若專案中仍需要 動態載入(例如根據使用者輸入載入不同模組),可以直接使用 Node 的 require,但要加上適當的型別宣告:
// dynamic-loader.ts
// 讓 TypeScript 知道 require 回傳 any
declare const require: NodeRequire;
export function loadModule(name: string) {
// 使用 any,之後自行斷言或轉型
const mod = require(name);
return mod as unknown;
}
或是使用 import()(動態 import)的方式,讓編譯器保留型別資訊:
export async function loadModuleAsync(name: string) {
const mod = await import(name); // 會回傳 Promise<any>
return mod;
}
程式碼範例
以下示範 5 個在 TypeScript 中處理 CommonJS 相容性的實務範例。每個範例皆附上說明與注意事項。
範例 1:最簡單的 require 與型別宣告
// utils.ts
export function add(a: number, b: number): number {
return a + b;
}
module.exports = { add }; // 同時使用 ES export 與 CommonJS
// app.ts
declare const require: NodeRequire; // 告訴 TypeScript 有全域 require
const utils = require("./utils") as typeof import("./utils"); // 取得完整型別
console.log(utils.add(3, 4)); // 7
說明:
as typeof import("./utils")讓utils具備正確的型別,IDE 會自動補全add。
範例 2:export = + import = require()(純 CommonJS)
// logger.ts
class Logger {
log(msg: string) {
console.log(`[LOG] ${msg}`);
}
}
export = new Logger(); // 匯出單例
// server.ts
import logger = require("./logger"); // 取得匯出的單例
logger.log("Server started");
注意:
logger的型別直接從logger.ts推斷為Logger實例,無需額外型別斷言。
範例 3:使用 esModuleInterop 的 import(最常見)
// package.json
{
"type": "commonjs"
}
// tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"esModuleInterop": true,
"strict": true
}
}
// third-party.js (CommonJS 套件)
module.exports = function greet(name) {
return `Hi, ${name}`;
};
// main.ts
import greet from "./third-party"; // 編譯器自動加上 __importDefault
console.log(greet("Alice"));
關鍵:開啟
esModuleInterop後,import greet from "./third-party"能正確取得module.exports。
範例 4:混合 export default 與 module.exports
// mixed.ts
export default function square(x: number): number {
return x * x;
}
module.exports = { // 同時掛上 CommonJS 屬性
version: "1.0.0"
};
// consumer.ts
import square, { version } from "./mixed"; // 會錯誤,因為 module.exports 沒有 default
// 正確做法:
import * as mixed from "./mixed";
console.log(mixed.default(5)); // 25
console.log(mixed.version); // 1.0.0
教訓:同一檔案同時使用 ES
export與module.exports會產生 雙重匯出,在 Node 中會以module.exports為主,export只在編譯階段保留型別。建議 避免混用,選擇其中一種風格。
範例 5:動態 import() 取代 require(保持型別)
// plugins/index.ts
export interface Plugin {
name: string;
run(): void;
}
// plugins/hello.ts
import { Plugin } from "./index";
export const plugin: Plugin = {
name: "hello",
run() { console.log("Hello plugin!"); }
};
// loader.ts
export async function loadPlugin(name: string) {
const mod = await import(`./${name}`); // 動態載入
return mod.plugin; // 型別為 Plugin
}
// app.ts
import { loadPlugin } from "./loader";
(async () => {
const hello = await loadPlugin("hello");
console.log(hello.name); // hello
hello.run(); // Hello plugin!
})();
優點:
import()會保留型別資訊,且支援 Tree Shaking;在需要根據條件載入模組時,比require更安全。
常見陷阱與最佳實踐
| 陷阱 | 可能的症狀 | 解決方式或最佳實踐 |
|---|---|---|
忘記開 esModuleInterop |
import foo from "foo" 編譯錯誤或執行時 foo.default is not a function |
在 tsconfig.json 設 esModuleInterop: true,或改用 import * as foo from "foo" |
同時使用 export = 與 export |
編譯器報 Export assignment conflicts with other exported elements |
選擇單一匯出方式:若要兼容 CommonJS,使用 export =;若想使用 ES Module,改為 export default |
動態 require 缺少型別 |
IDE 無法補全、執行時出現 any 相關錯誤 |
使用 as typeof import("./module") 或改用 import() |
module.exports = 覆蓋了 export |
其他檔案無法 import { X } |
避免混用;若必須混用,將 ES 匯出放在 exports 物件上:exports.X = X |
| Node 版本不支援 ES Module | 程式執行時 SyntaxError: Unexpected token 'import' |
確認 Node >=12,並在 package.json 設 type: "module" 或使用 .mjs 副檔名;或改回 commonjs 目標 |
建議的開發流程
- 決定模組規範:新專案建議使用 ES Module +
esModuleInterop;若必須與大量 CommonJS 套件共存,仍以commonjs為編譯目標。 - 統一匯出風格:在同一個程式碼庫內盡量只使用
export/export default或export =其中之一。 - 設定
tsconfig.json:{ "compilerOptions": { "module": "commonjs", "target": "es2020", "esModuleInterop": true, "strict": true, "skipLibCheck": true } } - 使用型別安全的載入:盡量使用
import/import(),只有在確實需要同步動態載入時才使用require,並加上型別斷言。 - 測試相容性:在 CI 中加入 Node 版本測試(例如
node 14,node 18),確保兩種模組系統皆能正常運作。
實際應用場景
| 場景 | 為何需要 CommonJS 相容性 | 解決方案 |
|---|---|---|
使用舊版套件(如 request, lodash@3) |
這些套件只提供 module.exports |
開啟 esModuleInterop,使用 import pkg from "request",或直接 const request = require("request") 並加型別宣告 |
| 建立 CLI 工具 | Node 執行環境預設使用 CommonJS,且 #!/usr/bin/env node 必須是可執行的 CommonJS 檔案 |
編譯成 commonjs,在入口檔案使用 #!/usr/bin/env node + import(透過 esModuleInterop) |
| 插件系統(使用者自行開發插件) | 插件可能是 ES Module 或 CommonJS,必須同時支援 | 使用 import() 動態載入,搭配 await import(pluginPath).then(m => m.default ?? m),自動判斷 default 匯出 |
| 混合前端/後端專案 | 前端使用 Webpack / Vite(ESM),後端使用 Node(CommonJS) | 在前端使用純 ES Module,在後端 tsconfig 設 module: "commonjs",共用相同 .ts 檔案,透過條件匯出(export const isNode = typeof process !== "undefined") |
| 單元測試 (Jest) | Jest 預設使用 CommonJS,若測試 ES Module 需要 Babel 或 ts-jest 轉譯 |
在 jest.config.js 設 transform: { "^.+\\.tsx?$": "ts-jest" },並把 esModuleInterop 設為 true,讓 import 正常運作 |
總結
- CommonJS 仍是 Node 生態的根基,TypeScript 必須提供相容機制才能順利使用大量既有套件。
- 透過
tsconfig.json的esModuleInterop、allowSyntheticDefaultImports,以及export =/import = require(),可以在編譯期保留類型安全,同時在執行期得到正確的 CommonJS 行為。 - 最佳實踐:盡量統一使用 ES Module(
import/export),在需要與 CommonJS 互通時開啟esModuleInterop;若必須支援舊套件或插件系統,使用import()動態載入並加上型別斷言。 - 了解每種匯出/載入方式的底層產物(
module.exports、__esModule、default)可以避免 「undefined is not a function」 之類的執行時錯誤。
掌握上述概念後,你就能在 TypeScript 專案中自如地切換、混用 CommonJS 與 ES Module,寫出既 型別安全 又 相容性佳 的程式碼。祝開發順利!