はじめに
Node.jsはv12からECMAScript Modules(ESM)をサポートしていますが、エコシステムの大部分は依然としてCommonJS(CJS)に依存しています。1つのプロジェクト内で両方のモジュールシステムを併用する、あるいはCJSパッケージをESMコードから利用する際には、エクスポートの解釈やグローバルの違いに起因する落とし穴が存在します。本記事ではその相互運用の境界を整理し、スムーズな移行のための具体的な戦略を提供します。
CJSとESMの比較
| 項目 | CommonJS | ESM |
|---|---|---|
| ファイル拡張子 | .js, .cjs | .js, .mjs |
| package.jsonのデフォルト | "type": "commonjs" | "type": "module" |
| 読み込み方式 | 同期的(require()) | 非同期的(import) |
トップレベルのthis | module.exports | undefined |
__dirname / __filename | 利用可 | 利用不可 |
| バインディング | コピーエクスポート | ライブバインディング |
モジュールシステムの設定
package.jsonの"type"フィールドでモジュールシステムを制御します。
{
"type": "module"
}
.mjs拡張子 → 常にESMとして扱われる.cjs拡張子 → 常にCJSとして扱われる.js拡張子 →"type"の設定に従う
ESMからCJSを読み込む
Node.jsはESMからCJSモジュールをimportできます。CJSのmodule.exportsはESMのデフォルトエクスポートにマッピングされます。
// cjs-module.js
module.exports = { hello: 'world' };
// esm-module.mjs
import cjsModule from './cjs-module.js';
console.log(cjsModule.hello); // "world"
名前付きエクスポートの注意点
Node.jsは静的解析によりCJSの名前付きエクスポートを自動検出しますが、動的または条件付きのエクスポートは正しく解決されない場合があります。
// 静的に解析できない場合、全体をデフォルトとして受け取る
import cjsModule from './cjs-module.js';
const { hello } = cjsModule;
CJSからESMを読み込む
CJSはrequire()でESMモジュールを読み込めません。代わりに**動的import()**を使用します。
// cjs-file.cjs
async function loadEsm() {
const esmModule = await import('./esm-module.mjs');
console.log(esmModule.namedExport);
}
これはwebpackやESLintの設定ファイルなど、CJSのままESMプラグインを読み込む必要がある場合に重要です。
ESMにおける__dirnameの代替
CJSの__dirnameと__filenameはESMでは使用できません。代わりにimport.meta.urlを利用します。
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
相互運用における主な問題点
1. デフォルトエクスポートの混乱
CJSは単一のオブジェクトをエクスポートするのみで、真の「デフォルト」と「名前付き」の区別はありません。
// CJS
exports.default = foo;
exports.named = bar;
ESM側では以下のように解釈されます。
import cjsModule from './cjs.js'; // module.exports全体がバインドされる
2. JSONモジュール
CJSではrequire('./data.json')が同期的に動作します。ESMではアサーションが必要です。
import data from './data.json' with { type: 'json' };
3. トップレベルawait
ESMではトップレベルのawaitが利用可能ですが、CJSでは利用できません。CJSの文脈で使用する場合はasync IIFEでラップします。
移行戦略
| ステップ | 内容 |
|---|---|
| 1 | package.jsonに"type": "module"を追加 |
| 2 | ファイルを.mjsに変更、またはimportに完全な拡張子を付与 |
| 3 | require()をimportに置き換え(CJS用に動的importを併用) |
| 4 | __dirname / __filenameをimport.meta.urlパターンに置き換え |
| 5 | 依存関係の問題を解決 |
大規模なコードベースでは"exports"フィールドを用いたデュアルパッケージ戦略が有効です。
{
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
まとめ
ESMはNode.jsのモジュールシステムの未来ですが、CJSからの移行には相互運用の境界を正しく理解する必要があります。デフォルトエクスポートのマッピング、動的import()の適切な使用、import.meta.urlによる代替を押さえることで、ランタイムエラーを回避できます。段階的な移行とデュアルパッケージの併用により、既存の利用者を壊さずに両方のシステムをサポートすることが可能です。
