本文 AI 產出,尚未審核

TypeScript - Node.js + TypeScript(實務應用)

主題:ESM + CommonJS 混用


簡介

在 Node.js 生態系統裡,ESM(ECMAScript Modules)CommonJS 兩種模組規格長期共存。隨著 Node 12 之後原生支援 ESM,開發者在升級或整合既有程式碼時,常會遇到「如何在同一專案裡同時使用 import/exportrequire/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 fooexport const bar = 1 module.exports = fooexports.bar = 1
靜態分析 可在編譯階段解析依賴 動態解析,執行時才決定
預設行為 嚴格模式(strict mode) 非嚴格模式(除非手動 use strict

重點:ESM 在編譯階段即可確定依賴圖,對於工具(如 tree‑shaking、bundle)非常友善;而 CommonJS 允許動態 require,在某些「條件載入」的情境下仍然很實用。

2. Node.js 如何判斷模組類型?

  1. package.json 中的 type 欄位

    • "type": "module" → 所有 .js 被視為 ESM。
    • "type": "commonjs"(預設)→ 所有 .js 被視為 CommonJS。
  2. 副檔名

    • .mjs → 強制 ESM。
    • .cjs → 強制 CommonJS。
  3. --input-type CLI 參數(較少使用)

技巧:在同一專案中混用時,建議將 ESM 檔案統一使用 .mjs,或在 package.jsontype: "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/.mjspackage.json 中的 type 設定。
  • esModuleInteropCommonJS 模組可以使用 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 無法靜態 import ESM,必須使用 動態 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' → 取得 ESM
  • const legacy = require('my-lib/legacy') → 取得 CommonJS

說明exports 欄位允許 同一套件 同時提供 ESM 與 CJS 入口,對於發佈 npm 套件尤其重要。


常見陷阱與最佳實踐

陷阱 說明 解決方案
混用 requireimport,導致執行時錯誤 在 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 解析 typemodule,卻把所有檔案編譯成 .js(沒有 .mjs),Node 仍會以 CJS 解析。 保持副檔名一致:ESM 用 .mjs,或在 package.json 明確設定 "type": "module"
循環依賴在 ESM 下變成 undefined ESM 的靜態匯入在循環依賴時會返回 undefined(因為尚未初始化)。 重構程式碼、使用 動態 import 或將循環依賴抽離成第三方模組。
型別宣告遺失導致 any 直接 require('./cjs-module') 會失去型別資訊。 為 CommonJS 模組寫 .d.ts,或在使用處加上 JSDoc @type

最佳實踐

  1. 統一模組策略:新開發的檔案盡量使用 ESM (import/export),舊有只能保留 CJS 的程式碼才使用 .cjs
  2. tsconfig.json 使用 moduleResolution: "node16",確保 Node 16+ 的解析行為與 TypeScript 保持一致。
  3. esModuleInterop 設為 true,減少 import/require 的語法差異。
  4. 使用 package.json.exports 控制對外公開的入口,避免使用者誤以為整個套件都是同一種模組。
  5. CI 測試:在 CI 中同時跑一次 node --experimental-modules(或原生 Node)與 ts-node,確保兩種模式都能正確執行。

實際應用場景

1. 漸進式遷移大型專案

企業常有數十萬行的 Node.js 程式碼,直接全部改寫為 ESM 成本過高。採取 「混用」 策略:

  • 新功能全部以 ESM 撰寫 (.mjs / type: "module")。
  • 舊有工具或 CLI 保留 .cjs,並在入口文件使用 require
  • 透過 exports 欄位在 npm 套件中同時提供 importrequire 版本。

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 套件

開發者希望套件同時支援 ESMimport)與 CommonJSrequire):

dist/
├─ index.cjs   // CommonJS 入口 (module.exports = ...)
├─ index.mjs   // ESM 入口 (export default ...)

package.json

{
  "type": "module",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "import": "./dist/index.mjs"
    }
  }
}

使用者根據自己的環境自動取得對應檔案,無需額外設定。


總結

  • ESMCommonJS 各有優勢,Node.js 透過 type、副檔名與 exports 提供靈活的判斷機制。
  • TypeScript 中,只要正確設定 tsconfig.jsonmodule: "ESNext"moduleResolution: "node16"esModuleInterop: true),就能無痛在同一專案裡混用兩種模組。
  • 實務上常見的 「漸進式遷移」「CLI 工具」「跨平台套件」 都可以透過上述技巧達成。
  • 避免常見陷阱(如 require 在 ESM 中、循環依賴、型別遺失),並遵循 最佳實踐(統一檔案副檔名、使用 exports、CI 測試),即可確保專案在 開發、編譯、部署 各階段都保持一致的行為。

關鍵訊息:只要在專案根目錄明確定義模組類型、在 TypeScript 設定中配合 Node 的解析規則,ESM + CommonJS 混用 不再是難題,而是提升遺留系統可維護性、加速新功能開發的利器。祝開發順利!