本文 AI 產出,尚未審核

TypeScript – 模組與命名空間(Modules & Namespaces)

主題:CommonJS / require 相容性


簡介

在 Node.js 生態系統中,CommonJS 仍是最常見的模組規範;大多數套件都是以 module.exports / require() 的方式提供。
然而,TypeScript 原生支援 ES Moduleimport / export),兩者的語法與執行機制並不相同。若在 TypeScript 專案中直接使用 require,或是將 ES Module 編譯成 CommonJS,必須了解相容性細節,才能避免執行時錯誤或類型檢查失效。

本篇文章將說明:

  1. 為什麼需要在 TypeScript 中處理 CommonJS 相容性
  2. 主要的編譯選項與語法技巧
  3. 實作範例、常見陷阱與最佳實踐
  4. 真實專案中可能的使用情境

目標讀者:已具備基本 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(副檔名 .mjspackage.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 = 時,不能同時使用 exportexport 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:使用 esModuleInteropimport(最常見)

// 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 defaultmodule.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 exportmodule.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.jsonesModuleInterop: 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.jsontype: "module" 或使用 .mjs 副檔名;或改回 commonjs 目標

建議的開發流程

  1. 決定模組規範:新專案建議使用 ES Module + esModuleInterop;若必須與大量 CommonJS 套件共存,仍以 commonjs 為編譯目標。
  2. 統一匯出風格:在同一個程式碼庫內盡量只使用 export/export defaultexport = 其中之一。
  3. 設定 tsconfig.json
    {
      "compilerOptions": {
        "module": "commonjs",
        "target": "es2020",
        "esModuleInterop": true,
        "strict": true,
        "skipLibCheck": true
      }
    }
    
  4. 使用型別安全的載入:盡量使用 import / import(),只有在確實需要同步動態載入時才使用 require,並加上型別斷言。
  5. 測試相容性:在 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,在後端 tsconfigmodule: "commonjs",共用相同 .ts 檔案,透過條件匯出(export const isNode = typeof process !== "undefined"
單元測試 (Jest) Jest 預設使用 CommonJS,若測試 ES Module 需要 Babel 或 ts-jest 轉譯 jest.config.jstransform: { "^.+\\.tsx?$": "ts-jest" },並把 esModuleInterop 設為 true,讓 import 正常運作

總結

  • CommonJS 仍是 Node 生態的根基,TypeScript 必須提供相容機制才能順利使用大量既有套件。
  • 透過 tsconfig.jsonesModuleInteropallowSyntheticDefaultImports,以及 export = / import = require(),可以在編譯期保留類型安全,同時在執行期得到正確的 CommonJS 行為。
  • 最佳實踐:盡量統一使用 ES Module(import/export),在需要與 CommonJS 互通時開啟 esModuleInterop;若必須支援舊套件或插件系統,使用 import() 動態載入並加上型別斷言。
  • 了解每種匯出/載入方式的底層產物(module.exports__esModuledefault)可以避免 「undefined is not a function」 之類的執行時錯誤。

掌握上述概念後,你就能在 TypeScript 專案中自如地切換、混用 CommonJS 與 ES Module,寫出既 型別安全相容性佳 的程式碼。祝開發順利!