Featured image of post Node.jsプロジェクトのESM移行における諸問題と相互互換性の確保 Featured image of post Node.jsプロジェクトのESM移行における諸問題と相互互換性の確保

Node.jsプロジェクトのESM移行における諸問題と相互互換性の確保

Node.jsプロジェクトにおけるESM移行の諸問題とCJSとの相互互換性確保方法を解説。CJSモジュールのESMからの読み込み、拡張子制約、dynamic importの活用など、モジュールシステム移行時に直面するエラーとその解決策を詳しく紹介します。

はじめに

Node.jsはv12からECMAScript Modules(ESM)をサポートしていますが、エコシステムの大部分は依然としてCommonJS(CJS)に依存しています。1つのプロジェクト内で両方のモジュールシステムを併用する、あるいはCJSパッケージをESMコードから利用する際には、エクスポートの解釈やグローバルの違いに起因する落とし穴が存在します。本記事ではその相互運用の境界を整理し、スムーズな移行のための具体的な戦略を提供します。


CJSとESMの比較

項目CommonJSESM
ファイル拡張子.js, .cjs.js, .mjs
package.jsonのデフォルト"type": "commonjs""type": "module"
読み込み方式同期的(require()非同期的(import
トップレベルのthismodule.exportsundefined
__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でラップします。


移行戦略

ステップ内容
1package.json"type": "module"を追加
2ファイルを.mjsに変更、またはimportに完全な拡張子を付与
3require()importに置き換え(CJS用に動的importを併用)
4__dirname / __filenameimport.meta.urlパターンに置き換え
5依存関係の問題を解決

大規模なコードベースでは"exports"フィールドを用いたデュアルパッケージ戦略が有効です。

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

まとめ

ESMはNode.jsのモジュールシステムの未来ですが、CJSからの移行には相互運用の境界を正しく理解する必要があります。デフォルトエクスポートのマッピング、動的import()の適切な使用、import.meta.urlによる代替を押さえることで、ランタイムエラーを回避できます。段階的な移行とデュアルパッケージの併用により、既存の利用者を壊さずに両方のシステムをサポートすることが可能です。