Node.jsでESMをrequire可能にする仕様の安定化とエコシステムの変化
長年にわたり、Node.js開発者はCommonJS(CJS)とES Modules(ESM)の間にある厄介な分断に直面してきました。CJSコードはimport()でESMモジュールを非同期に読み込めましたが、CJSの心臓部である同期的なrequire()はESMをまったく読み込めませんでした。そのため、コードベースの移行は全か無かの対応を強いられていました。
Node.js 22および23で--experimental-require-moduleフラグが安定化され、状況が変わりました。Node 23以降、ESモジュールのグラフがトップレベルawaitを使用していない限り、同期的なrequire()でESモジュールを読み込めるようになりました。
問題:CJSとESMの相互運用性
根本的な問題は、CommonJSが同期的にモジュールを読み込む(require())のに対し、ES Modulesは非同期的にパース・実行される(トップレベルawait、Promiseを返す静的インポート文)ことです。ESMグラフは循環参照やライブバインディングを含むことがあり、CJSでは処理できませんでした。
Node 22以前にCJSからESMを読み込む唯一の方法は以下の通りです。
// CJSファイル
async function loadESM() {
const esmModule = await import('./esm-module.mjs');
return esmModule.default;
}
これにより、CJSのコードベースはすべてをESMに書き換えるか、厄介な非同期ラッパー層を維持するかを強いられていました。
解決策:Node 22+ での require(esm)
Node 22で導入された--experimental-require-moduleフラグにより、ESモジュールとその依存関係グラフ全体にトップレベルawaitがなければ、require()でESMモジュールを同期的に読み込めるようになりました。
Node 23ではこの機能が--require-moduleフラグ(experimentalなし)の背後で安定化され、Node 24ではデフォルトで有効化される予定です。
// CJSファイル — Node 22+ でフラグ付きで動作
const lodashEs = require('lodash-es'); // ESMパッケージ!
const result = lodashEs.merge({ a: 1 }, { b: 2 });
console.log(result); // { a: 1, b: 2 }
内部動作
require()がESMファイルに遭遇すると、以下の解決が行われます。
- NodeはモジュールをESMとして識別(
package.jsonの"type": "module"または.mjs拡張子) - ESMグラフにトップレベルawaitが含まれているかチェック — 含まれている場合はERR_REQUIRE_ASYNC_MODULEエラーをスロー
- トップレベルawaitがない場合、ESMグラフを同期的にパース・評価
- デフォルトエクスポートが直接返される。名前付きエクスポートは返されたオブジェクトのプロパティからアクセス可能
// ESMモジュール: esm-lib.mjs
export const greet = (name) => `Hello, ${name}!`;
export default class Logger {
log(msg) { console.log(msg); }
}
// CJSコンシューマー
const lib = require('./esm-lib.mjs');
lib.greet('World'); // "Hello, World!"
new lib.default().log('hi'); // "hi"
名前付きエクスポートは直接アクセスでき、デフォルトエクスポートは.defaultでアクセスします。
サポートされるESM機能
| 機能 | require(esm)でのサポート | 備考 |
|---|---|---|
| 名前付きエクスポート | 可 | 直接プロパティアクセス |
| デフォルトエクスポート | 可 | 単一エクスポートの場合は直接、または.defaultで返却 |
| ライブバインディング | 不可 | 値のスナップショットを取得、ライブバインディングはなし |
| 循環依存 | 部分的 | 単純な循環は可、複雑なグラフは失敗する可能性あり |
| トップレベルawait | 不可 | ERR_REQUIRE_ASYNC_MODULEをスロー |
| 動的import() | 可 | require経由で読み込まれたESM内部でも動作 |
制限とエラーハンドリング
最も重要な制限はトップレベルawaitです。トップレベルでawaitを使用するESMモジュールはrequire()で読み込めません。
// top-level-await.mjs
const data = await fetch('https://api.example.com/data').then(r => r.json());
export default data;
// CJSコンシューマー
const data = require('./top-level-await.mjs');
// ERR_REQUIRE_ASYNC_MODULE: require() はトップレベルawaitを持つ
// ESモジュールには使用できません
対処方法:
- ESMモジュールからトップレベルawaitを削除する
require()の代わりにawait import()を使用する- 非同期ラッパーで包む(ただしモジュール自体の問題のため上記の例では効果なし)
正しいアプローチ:
const data = await import('./top-level-await.mjs');
CJSコードベースの移行メリット
この機能により、CJSからESMへの段階的移行が劇的に簡素化されます。
以前(Node 20):CJSコードベースではESM専用パッケージをawait import()ラッパーなしでは使用できず、CJSモジュールのトップレベルで使用することが不可能でした。
// 以前の苦痛 — 非同期ラッパーが必要
const getExpress = () => import('express');
以後(Node 23+):ほとんどのESMパッケージを直接require()できるようになり、段階的な移行が可能に。
// CJSファイル、Node 23+
const express = require('express'); // expressがESMを出荷していれば動作
const { nanoid } = require('nanoid'); // ESM専用パッケージ!
CJSコードベースはモジュールシステムを書き換えることなく、ESM専用の依存関係を採用できるようになりました。nanoid、chalk(v5+)、p-limitなどのESM専用ライブラリがCJSからアクセス可能になります。
エコシステムへの影響
require(esm)の安定化はエコシステムに大きな影響を与えます。
- パッケージメンテナーはCJS/ESMのデュアルビルドを廃止し、ESM専用で出荷できるようになる(CJSコンシューマーが
require()できるため) - CJSコードベースは「ビッグバン」な書き換えなしにESM依存関係を段階的に導入可能
- ツール(Jest、ESLint、TypeScript)のモジュール解決が簡素化
ただし、パッケージメンテナーはエクスポート内のトップレベルawaitがrequire()互換性を壊すことを認識しておく必要があります。
まとめ
Node.js 22/23でのrequire(esm)の安定化は、CommonJSとES Modulesの間の長年にわたるギャップを埋めるものです。CJSコードベースはESM専用パッケージを直接利用できるようになり、段階的な移行を妨げていた非同期ラッパーの負担がなくなりました。
大規模なCJSアプリケーションを維持しているチームにとって、これはゲームチェンジャーです。今日からモダンなESM依存関係を採用し、コードベースをモジュールごとに移行し、require()の同期的なシンプルさを犠牲にすることなくESMエコシステムのフルアドバンテージを活用できます。
