TypeScript - Node.js + TypeScript(實務應用)
主題:ESM + CommonJS 混用
簡介
在 Node.js 生態系統裡,ESM(ECMAScript Modules) 與 CommonJS 兩種模組規格長期共存。隨著 Node 12 之後原生支援 ESM,開發者在升級或整合既有程式碼時,常會遇到「如何在同一專案裡同時使用 import/export 與 require/module.exports」的問題。
對於使用 TypeScript 的團隊而言,這個挑戰更具複雜度:編譯設定、型別宣告、以及執行時的相容性都需要仔細規劃。本文將從概念說明、實作範例、常見陷阱到最佳實踐,提供 從初學者到中階開發者 都能直接套用的完整指南。
核心概念
1. 什麼是 ESM?什麼是 CommonJS?
| 特性 | ESM (ECMAScript Modules) | CommonJS |
|---|---|---|
| 檔案副檔名 | .js(預設為 ESM)或 .mjs |
.js(預設為 CommonJS)或 .cjs |
| 匯入語法 | import foo from './foo.js' |
const foo = require('./foo') |
| 匯出語法 | export default foo、export const bar = 1 |
module.exports = foo、exports.bar = 1 |
| 靜態分析 | 可在編譯階段解析依賴 | 動態解析,執行時才決定 |
| 預設行為 | 嚴格模式(strict mode) | 非嚴格模式(除非手動 use strict) |
重點:ESM 在編譯階段即可確定依賴圖,對於工具(如 tree‑shaking、bundle)非常友善;而 CommonJS 允許動態
require,在某些「條件載入」的情境下仍然很實用。
2. Node.js 如何判斷模組類型?
package.json中的type欄位"type": "module"→ 所有.js被視為 ESM。"type": "commonjs"(預設)→ 所有.js被視為 CommonJS。
副檔名
.mjs→ 強制 ESM。.cjs→ 強制 CommonJS。
--input-typeCLI 參數(較少使用)
技巧:在同一專案中混用時,建議將 ESM 檔案統一使用
.mjs,或在package.json設type: "module",再把只能使用 CommonJS 的套件放在.cjs檔案中。
3. TypeScript 與模組相容性設定
在 tsconfig.json 必須同時配合 編譯目標 與 模組系統:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext", // 產出 ESM
"moduleResolution": "node16", // 支援 .cjs/.mjs 判斷
"esModuleInterop": true, // 允許 default import CommonJS
"allowSyntheticDefaultImports": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
moduleResolution: "node16"(或"nodenext")是 TypeScript 4.7+ 新增的選項,能正確解析.cjs/.mjs與package.json中的type設定。esModuleInterop讓 CommonJS 模組可以使用import foo from 'foo'的語法,背後會自動產生default轉換。
程式碼範例
以下示範 5 種常見情境,每個範例皆附上說明與最佳實踐。
範例 1️⃣:在 ESM 中 import CommonJS 套件
// src/app.mjs (ESM)
import express from 'express'; // 透過 esModuleInterop 自動加上 default
import { createServer } from 'http';
// 直接使用
const app = express();
app.get('/', (_, res) => res.send('Hello ESM + CJS'));
createServer(app).listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
說明:
express本身是 CommonJS,esModuleInterop: true讓我們可以使用import express from 'express',而不必寫import * as express from 'express'。
範例 2️⃣:在 CommonJS 中 require ESM 模組(Node 16+)
// src/legacy.cjs (CommonJS)
(async () => {
const { greet } = await import('./greet.mjs'); // 動態 import 必須是 async
console.log(greet('World'));
})();
// src/greet.mjs (ESM)
export function greet(name) {
return `Hello, ${name}!`;
}
說明:CommonJS 無法靜態
importESM,必須使用 動態import()。此寫法同時保留了舊有的.cjs檔案結構。
範例 3️⃣:混合型別宣告 – 讓 TypeScript 明確知道 module.exports
// src/utils.cjs
/** @type {import('./types').Util } */
const utils = {
sum(a: number, b: number) {
return a + b;
},
mul(a: number, b: number) {
return a * b;
},
};
module.exports = utils;
// src/types.d.ts
export interface Util {
sum(a: number, b: number): number;
mul(a: number, b: number): number;
}
// src/main.mjs
import utils from './utils.cjs'; // 依賴 esModuleInterop
console.log(utils.sum(2, 3)); // 5
說明:透過 JSDoc
@type或.d.ts,讓 TypeScript 能正確推斷 CommonJS 匯出的結構,避免any。
範例 4️⃣:使用 .cjs 與 .mjs 混合專案結構
my-project/
├─ package.json // type: "module"
├─ tsconfig.json
├─ src/
│ ├─ server.mjs // ESM entry
│ ├─ legacy.cjs // 仍保留舊版程式
│ └─ lib/
│ ├─ db.mjs // ESM 模組
│ └─ logger.cjs // CommonJS 模組
└─ dist/ (編譯輸出)
在 server.mjs 中:
import logger from './lib/logger.cjs';
import { connect } from './lib/db.mjs';
logger.info('Server starting...');
await connect();
說明:即使
package.json設為"module",只要副檔名是.cjs,Node 仍會以 CommonJS 方式載入,讓遺留程式碼無痛遷移。
範例 5️⃣:設定 exports 欄位以控制子路徑的模組類型
// package.json
{
"type": "module",
"exports": {
"./legacy": "./dist/legacy.cjs",
"./utils": "./dist/utils.mjs"
}
}
import { foo } from 'my-lib/utils'→ 取得 ESMconst legacy = require('my-lib/legacy')→ 取得 CommonJS
說明:
exports欄位允許 同一套件 同時提供 ESM 與 CJS 入口,對於發佈 npm 套件尤其重要。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
混用 require 與 import,導致執行時錯誤 |
在 ESM 檔案裡直接使用 require 會拋出 ReferenceError: require is not defined。 |
使用 動態 import() 或將該檔案改為 .cjs。 |
esModuleInterop 未開啟,導致 default import 失效 |
import foo from 'cjs-lib' 會變成 undefined。 |
在 tsconfig.json 加上 "esModuleInterop": true,或改為 import * as foo from 'cjs-lib'。 |
TS 編譯輸出仍是 .js,但 Node 以 CommonJS 解析 |
若 type 為 module,卻把所有檔案編譯成 .js(沒有 .mjs),Node 仍會以 CJS 解析。 |
保持副檔名一致:ESM 用 .mjs,或在 package.json 明確設定 "type": "module"。 |
循環依賴在 ESM 下變成 undefined |
ESM 的靜態匯入在循環依賴時會返回 undefined(因為尚未初始化)。 |
重構程式碼、使用 動態 import 或將循環依賴抽離成第三方模組。 |
型別宣告遺失導致 any |
直接 require('./cjs-module') 會失去型別資訊。 |
為 CommonJS 模組寫 .d.ts,或在使用處加上 JSDoc @type。 |
最佳實踐:
- 統一模組策略:新開發的檔案盡量使用 ESM (
import/export),舊有只能保留 CJS 的程式碼才使用.cjs。 - 在
tsconfig.json使用moduleResolution: "node16",確保 Node 16+ 的解析行為與 TypeScript 保持一致。 - 將
esModuleInterop設為true,減少 import/require 的語法差異。 - 使用
package.json.exports控制對外公開的入口,避免使用者誤以為整個套件都是同一種模組。 - CI 測試:在 CI 中同時跑一次
node --experimental-modules(或原生 Node)與ts-node,確保兩種模式都能正確執行。
實際應用場景
1. 漸進式遷移大型專案
企業常有數十萬行的 Node.js 程式碼,直接全部改寫為 ESM 成本過高。採取 「混用」 策略:
- 新功能全部以 ESM 撰寫 (
.mjs/type: "module")。 - 舊有工具或 CLI 保留
.cjs,並在入口文件使用require。 - 透過
exports欄位在 npm 套件中同時提供import與require版本。
2. 建置 CLI 工具
CLI 常需要 #!/usr/bin/env node shebang,且在 Windows 上執行時會直接呼叫 .js。若 CLI 需要 ESM 語法,可:
// package.json
{
"type": "module",
"bin": {
"my-cli": "./dist/cli.mjs"
}
}
若 CLI 內部仍需使用老舊的 CJS 套件,就在 cli.mjs 中 動態 import() 那些套件,確保執行時不會因為 require 不存在而失敗。
3. 發佈跨平台 npm 套件
開發者希望套件同時支援 ESM(import)與 CommonJS(require):
dist/
├─ index.cjs // CommonJS 入口 (module.exports = ...)
├─ index.mjs // ESM 入口 (export default ...)
在 package.json:
{
"type": "module",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.mjs"
}
}
}
使用者根據自己的環境自動取得對應檔案,無需額外設定。
總結
- ESM 與 CommonJS 各有優勢,Node.js 透過
type、副檔名與exports提供靈活的判斷機制。 - 在 TypeScript 中,只要正確設定
tsconfig.json(module: "ESNext"、moduleResolution: "node16"、esModuleInterop: true),就能無痛在同一專案裡混用兩種模組。 - 實務上常見的 「漸進式遷移」、「CLI 工具」、「跨平台套件」 都可以透過上述技巧達成。
- 避免常見陷阱(如
require在 ESM 中、循環依賴、型別遺失),並遵循 最佳實踐(統一檔案副檔名、使用exports、CI 測試),即可確保專案在 開發、編譯、部署 各階段都保持一致的行為。
關鍵訊息:只要在專案根目錄明確定義模組類型、在 TypeScript 設定中配合 Node 的解析規則,ESM + CommonJS 混用 不再是難題,而是提升遺留系統可維護性、加速新功能開發的利器。祝開發順利!