Native ESMの基本仕組みとCommonJSとの根本的な違いの整理
目次
Native ESMの基本仕組みとCommonJSとの根本的な違いの整理
Native ESMはJavaScriptの言語仕様として標準化されたモジュールシステムであり、ブラウザとNode.jsの双方でトランスパイルなしに動作します。長年デファクトだったCommonJSとは設計思想そのものが異なるため、まずは両者の根本的な違いを構造から整理します。
importとrequireで異なるモジュール解決タイミングの比較観点
CommonJSのrequireは通常の関数呼び出しとして実装されており、コードが実行された瞬間に同期的にモジュールを読み込みます。条件分岐の中でrequireを呼んだり、変数でパスを組み立てたりできるのはこの実行時解決のおかげです。一方、Native ESMのimport宣言は実行前の構文解析の段階で処理され、依存関係のグラフが評価開始前に確定します。読み込みが実行時か解析時かという点こそが、両者を分ける最大の比較観点です。
Native ESMの読み込みは解析、インスタンス化、評価という3つのフェーズに分かれて進行します。すべてのimport宣言はファイル先頭に巻き上げられ、評価が始まる前にモジュール同士の接続が完了している点が特徴です。この仕組みによりブラウザはネットワーク越しでも依存ファイルを並行取得でき、循環参照時の挙動もCommonJSより予測しやすくなります。実行時に動的な読み込みが必要な場面ではimport関数を使うという住み分けも、設計段階で押さえておきたいポイントでしょう。
静的構文解析が可能にするTree Shakingと最適化の仕組み
Tree Shakingとは、バンドラーが未使用のエクスポートを検出して最終成果物から取り除く最適化のことです。ESMのimportとexportは静的な構文であり、コードを実行しなくても依存関係を完全に解析できます。CommonJSではmodule.exportsの中身が実行時に書き換えられる可能性があるため、同水準の解析は原理的に困難でした。ESMを前提にするだけで、バンドラーが安全に削れるコードの範囲が大きく広がるのです。
実務で成果を左右するのは副作用の扱いです。package.jsonのsideEffectsフィールドで副作用がないことを宣言すると、バンドラーはimportされただけのファイルを丸ごと除去できます。利用するライブラリがESM形式で配布されているかどうかでバンドルサイズが数十KB単位で変わる例も珍しくありません。lodashをESM版のlodash-esへ置き換えるだけで転送量を削減できたという報告は、効果が分かりやすい実務例として広く知られています。
厳格モード強制とthisの扱いなどCommonJSとの挙動差5つ
Native ESMとして読み込まれたコードは、宣言の有無にかかわらず常に厳格モードで実行されます。こうした細かな挙動差を知らずに移行すると、これまで動いていたコードが突然エラーを出すことがあります。代表的な違いは次の5つです。
- 厳格モードが強制され、未宣言変数への代入が即座にエラーになる
- トップレベルのthisがundefinedになる(CommonJSではmodule.exportsを指す)
- __dirnameや__filenameといった疑似グローバル変数が存在しない
- import宣言は巻き上げられ、ファイル末尾に書いても先に処理される
- requireのキャッシュ操作のような実行時のモジュール差し替えができない
とりわけthisの違いは例外を出さずに静かに壊れるため厄介です。古いライブラリにはトップレベルのthisへプロパティを生やす実装が残っていることがあり、ESM化した途端に初期化が空振りします。移行前にgrepでトップレベルのthis参照を洗い出しておくと、原因不明の不具合をかなり減らせるでしょう。
ファイル単位スコープとライブバインディングが持つ実務上の意味
ESMでは1ファイルが1モジュールとして独立したスコープを持ち、exportしない限り外部から一切参照できません。グローバル汚染を前提とした古いスクリプト設計と違い、変数名の衝突を気にせず開発できるのが利点です。さらに重要なのがライブバインディングという性質で、exportされた変数は値のコピーではなく参照として共有されます。エクスポート元で値が更新されると、import側から見える値も即座に変わるのです。
一方のCommonJSでは、requireした時点の値がコピーされるため、その後の変更は取り込めません。設定値やカウンタのように後から更新されるものをモジュール間で共有していた場合、ESM移行後に挙動が変わる典型的な箇所になります。実務ではミュータブルな値を直接exportするのを避け、取得用の関数を経由させる設計に寄せると、どちらの仕組みでも一貫した動作を保てます。コードレビューの観点としても覚えておきたい違いです。
デフォルトエクスポートと名前付きエクスポートの使い分け判断基準
export defaultはモジュールの代表となる値を1つだけ公開する書き方で、import側が自由な名前を付けられます。名前付きエクスポートは複数の値を元の名前のまま公開し、import時に分割して受け取る方式です。判断基準としては、モジュールの責務が単一のクラスや関数に集約されるならデフォルト、ユーティリティ集のように複数の機能を提供するなら名前付きが基本線になります。
ただし近年は名前付きエクスポートへ統一する方針のチームが増えています。理由は3つあり、エディタの自動インポートと相性が良いこと、リネーム時の追跡が容易なこと、そしてTree Shakingの解析が確実に効くことです。デフォルトエクスポートはCommonJSとの相互運用時にdefaultプロパティ問題を引き起こす火種にもなります。新規コードでは名前付きを原則とし、フレームワークの規約が要求する場面に限ってデフォルトを使うという線引きが、現実的な運用と言えるでしょう。
ブラウザとNode.jsにおけるNative ESM対応状況と動作条件の確認
Native ESMを採用できるかどうかは、動作環境の対応状況に大きく依存します。ここでは主要ブラウザとNode.jsそれぞれの対応バージョンと、実際に動かすための条件を確認します。
主要ブラウザのscript type=module対応バージョン一覧
ブラウザでNative ESMを使うには、scriptタグにtype属性としてmoduleを指定します。主要ブラウザの対応開始バージョンは以下の通りで、いずれも2017年から2018年にかけて出揃いました。
| ブラウザ | 対応開始バージョン | リリース時期 |
|---|---|---|
| Chrome | 61 | 2017年9月 |
| Firefox | 60 | 2018年5月 |
| Safari | 10.1(完全対応は11) | 2017年3月 |
| Edge(旧EdgeHTML版) | 16 | 2017年10月 |
現行のEdgeはChromiumベースのため、Chromeと同等の対応状況と考えて差し支えありません。世界の主要ブラウザシェアの大半がすでに対応済みであり、Internet Explorerのサポート終了以降は、type属性にmoduleを指定したスクリプトが動かない環境を考慮する必要はほぼなくなりました。社内システムなど特殊な環境を除けば、ブラウザ対応はNative ESM採用の障壁ではなくなっていると判断できます。
Node.js 12以降で段階的に安定化したESMサポートの経緯
Node.jsのESM対応はバージョン8.5でフラグ付きの実験機能として始まり、長い調整期間を経て現在の形になりました。バージョン12では仕様が大きく見直され、package.jsonのtypeフィールドによる判定方式が導入されています。続くバージョン13.2でフラグなしでの利用が可能になり、14系のLTS期間を通じて実運用での安定性が確認されていきました。この経緯を知っておくと、古い記事の情報が現在も有効かどうかを見分けやすくなります。
大きな転機となったのが、requireでESMモジュールを直接読み込めるようにする機能の追加です。これはバージョン22.12以降および20.19以降で標準で有効になり、CommonJSとESMの間にあった最大の壁が事実上取り払われました。現在アクティブにサポートされているNode.jsを使っている限り、ESMの基本機能はすべて安定版として利用できます。これから新規プロジェクトを始めるなら、Node.js側の制約を理由にESMを避ける必要はないでしょう。
動的importとtop-level awaitが使える環境条件の整理
import関数による動的読み込みは、必要になった時点でモジュールを取得できる仕組みで、コード分割や条件付き読み込みに欠かせません。対応はChrome 63、Firefox 67、Safari 11.1からで、Node.jsでも13.2以降であれば安定して利用できます。静的importと違いCommonJSファイルの中からも呼び出せるため、段階的移行の橋渡し役としても重宝する機能です。
一方のtop-level awaitは、モジュールの最上位で直接awaitを書ける機能で、ES2022として標準化されました。ブラウザではChrome 89とFirefox 89、Safari 15が対応の目安で、Node.jsでは14.8でフラグが外れており、それ以降の版なら安心して使えます。注意点として、この機能はESM専用でありCommonJSでは構文エラーになります。初期化処理で接続確立を待つような用途には便利ですが、読み込み元のモジュール評価をブロックするため、多用すると起動時間に影響する点も覚えておきたいところです。
CDN経由でESMモジュールを直接読み込む際のCORS制約と注意点
Native ESMはURL指定でモジュールを取得できるため、esm.shやjsDelivrといったCDNからビルドなしでライブラリを読み込めます。プロトタイプ開発や小規模ページでは強力な選択肢ですが、通常のscriptタグと異なりCORSの制約を受ける点に注意が必要です。モジュールとして読み込まれるファイルはオリジン間リソース共有のチェック対象となり、配信側が適切なヘッダーを返さないと読み込み自体が失敗します。
もう1つの落とし穴がMIMEタイプで、サーバーがJavaScriptとして正しいContent-Typeを返さない場合、ブラウザは実行を拒否します。自前のサーバーから配信する際に設定漏れで詰まる典型的な箇所です。また、CDN依存はそのまま可用性のリスクになるため、本番環境では特定バージョンへの固定とSRIによる整合性検証、さらに障害時のフォールバック手段まで検討しておくべきでしょう。手軽さと引き換えに増える運用上の考慮点を理解した上で使い分けることが大切です。
レガシーブラウザ向けnomodule属性を使ったフォールバック実装例
ESM非対応の古いブラウザもサポート対象に含めたい場合、nomodule属性を使った出し分けが定番の実装例です。ESM対応ブラウザはtype属性がmoduleのスクリプトを読み込み、nomodule付きのスクリプトを無視します。逆に非対応ブラウザはtype属性がmoduleのタグを解釈できずに飛ばし、nomodule付きの従来形式だけを実行するという仕組みです。
<script type="module" src="app.esm.js"></script>
上記のモジュール版と並べて、nomodule属性を付けた従来バンドル版のscriptタグを記述します。この方式なら新しいブラウザには軽量なモジュール版を、古いブラウザには互換バンドルを、それぞれ1つのHTMLから配信できます。ただし現在ではESM非対応ブラウザのシェアが非常に小さくなったため、二重ビルドの維持コストに見合うかは慎重に判断すべきです。アクセス解析で対象ブラウザの実際の利用率を確認し、数値を根拠に廃止時期を決めるのが現実的な進め方でしょう。
package.jsonのtype設定と拡張子による実行モード判定の仕組み
Node.jsは同じ.jsファイルでも、設定によってCommonJSとESMのどちらで実行するかを切り替えます。この判定ルールの理解が曖昧なままだと、移行作業のあらゆる場面でつまずくことになります。
type:moduleとtype:commonjsが切り替える判定の仕組み
Node.jsが.jsファイルをどちらのモジュール形式として実行するかは、そのファイルから最も近い親ディレクトリにあるpackage.jsonのtypeフィールドで決まります。値がmoduleなら.jsはESMとして、commonjsなら従来通りCommonJSとして解釈されます。フィールドを省略した場合のデフォルトはcommonjsであり、既存プロジェクトが壊れないよう後方互換が保たれている形です。
重要なのは、この設定がディレクトリ単位で階層的に効くという点です。プロジェクト直下でtypeにmoduleを指定しても、node_modules内の各パッケージは自身のpackage.jsonの設定に従うため、依存ライブラリの動作には影響しません。逆に、サブディレクトリに別のpackage.jsonを置けば、その配下だけ判定を切り替えることも可能です。モノレポ構成で一部のワークスペースだけESM化するといった運用は、この階層的な判定の仕組みを利用した実務例と言えます。
.mjsと.cjs拡張子が持つ優先順位と明確な使い分けの判断基準
拡張子による指定はtypeフィールドよりも優先されます。.mjsファイルはpackage.jsonの設定にかかわらず常にESMとして、.cjsファイルは常にCommonJSとして実行されるルールです。つまり判定の優先順位は、拡張子による明示が最上位で、その次にtypeフィールド、最後にデフォルトのcommonjsという順序になります。この序列さえ覚えておけば、どのファイルがどちらのモードで動くかを迷わず判断できます。
使い分けの判断基準はシンプルで、プロジェクト全体の方針と異なる形式のファイルだけに専用拡張子を使うのが基本です。typeをmoduleに設定したプロジェクトで、CommonJSでしか動かない設定ファイルが必要なら.cjsにします。逆にCommonJS主体のプロジェクトで一部だけESMを試すなら.mjsを選びます。すべてのファイルに.mjsや.cjsを付けて回る必要はなく、例外側にだけ印を付けると考えると運用がぶれません。ツールによっては専用拡張子の解釈に差があるため、ビルド設定の対応状況も事前に確認しておくと安全です。
デュアルパッケージ対応に必要なexportsフィールドの設定例
ライブラリをESMとCommonJSの両形式で配布するには、package.jsonのexportsフィールドで読み込み方法ごとの参照先を切り替えます。importで読み込まれた場合とrequireで読み込まれた場合に、それぞれ別のファイルを返す設定例は次の通りです。
"exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" } }
この条件付きエクスポートにより、利用者側は読み込み方を意識せず同じパッケージ名で扱えます。設定時の注意点は3つあり、まずtypesコンディションは型定義の解決に使われるため先頭に書かなければなりません。次に、exportsを定義するとそこに列挙していないパスは外部から参照できなくなり、深いパスを直接importしていた利用者を壊す恐れがあります。最後に、ESM版とCommonJS版で別々のモジュールインスタンスが生成される二重読み込み問題があるため、内部状態を持つライブラリでは設計段階からの考慮が欠かせません。
mainとmoduleとexportsの優先関係と解決順序の比較観点
パッケージのエントリポイントを示すフィールドは歴史的な経緯から複数存在し、それぞれ参照する主体と優先度が異なります。比較観点を整理すると以下のようになります。
| フィールド | 主な参照者 | 優先度 | 位置づけ |
|---|---|---|---|
| exports | Node.js本体と主要バンドラー | 最優先 | 現行の標準 |
| module | webpackなどバンドラーのみ | exportsがない場合 | 非公式の慣習 |
| main | すべての環境 | 最後の受け皿 | 従来からの互換用 |
押さえるべき点は、exportsが定義されていればNode.jsはmainを見ないこと、そしてmoduleフィールドはNode.js本体が一切解釈しないバンドラー独自の慣習であることの2つです。新規にパッケージを作るならexportsを正とし、mainは古いツール向けの保険として併記するのが現在の定石です。古い記事ではmoduleフィールドが推奨されている場合がありますが、現在はexportsの条件分岐で代替するのが標準的な書き方になっています。
設定ミスで起こるERR_REQUIRE_ESMエラーの失敗パターン
ERR_REQUIRE_ESMは、CommonJSのコードがESM専用ファイルをrequireしようとした際に発生する代表的なエラーです。典型的な失敗パターンは2つあり、1つは依存ライブラリがESM専用へ移行したことに気づかずバージョンを上げてしまうケースです。chalkやnode-fetchなど著名パッケージのメジャーアップデートで多発し、ビルドが突然壊れる原因として知られています。もう1つは、自分のプロジェクトのtypeフィールドをmoduleに変えた際、requireを使う古いツール設定ファイルが巻き添えで動かなくなるケースです。
対処の選択肢は環境によって変わります。Node.js 22.12以降または20.19以降であればrequireでESMを読み込める機能が標準で有効なため、ランタイムの更新だけで解消することがあります。それより古い環境では、読み込み側を動的importへ書き換えるか、該当ファイルの拡張子を.cjsにして影響範囲を切り離すのが定番です。依存ライブラリ起因の場合は、ESM専用化される直前のバージョンに留めるという暫定対応も実務ではよく使われます。
CommonJSからNative ESMへ移行する際の手順と注意点の整理
既存のCommonJSプロジェクトをESMへ移行する作業は、書き換え自体よりも事前調査と例外処理の設計が成否を分けます。ここでは実際の移行を想定した手順と注意点を順に整理します。
移行前に必ず確認したい依存パッケージのESM対応状況と調査手順
移行作業で最初に行うべきは、コードの書き換えではなく依存パッケージの棚卸しです。調査の起点はnpm lsコマンドで直接依存の一覧を出し、各パッケージのpackage.jsonを確認することです。exportsフィールドにimportコンディションがあるか、typeフィールドがmoduleになっているかを見れば、ESMとして読み込めるかをおおむね判定できます。CommonJS専用のパッケージが残っていても、ESM側からのimportは基本的に可能なため、致命的な障害になるケースは多くありません。
本当に警戒すべきは逆方向、つまりESM専用パッケージへの依存と、requireを前提とした古いツール群の存在です。加えて、実行時にrequireを動的に呼ぶプラグイン機構や、パッチを当てる系のライブラリは移行後に壊れやすい代表格と言えます。調査結果は、そのまま移行できるもの、書き換えが必要なもの、代替を探すべきものの3分類で台帳化しておくと、作業量の見積もり精度が上がるでしょう。この台帳が後続の全工程の土台になるため、時間をかける価値は十分にあります。
requireからimportへ書き換える際の5つの基本変換手順
依存調査が済んだら、いよいよコード本体の書き換えに入ります。機械的に進められる部分が多いため、次の5つの手順に分解して順番に適用していくのが効率的です。
- package.jsonにtypeフィールドを追加し、値をmoduleに設定する
- requireによる読み込みをimport宣言へ、module.exportsをexportへ置換する
- 相対パスのimportすべてに.jsまでの拡張子を明示する
- __dirnameなど存在しない変数をimport.meta由来の処理へ置き換える
- 動かないまま残るファイルを.cjsに改名して一時的に隔離する
ポイントは、完璧を目指して1ファイルずつ仕上げるのではなく、まず全体を一括変換してからエラーを潰していく進め方です。変換の大部分は正規表現や移行支援ツールで自動化でき、人手が必要なのは動的requireや循環参照が絡む少数の箇所に限られます。テストスイートが整備されていれば、各手順の後にテストを回すことで退行を早期に検出できます。
__dirnameや__filename代替となるimport.meta活用例
ESMには__dirnameと__filenameが存在しないため、自身のファイル位置を基準にパスを扱っていたコードはそのままでは動きません。代替の起点となるのがimport.metaオブジェクトで、urlプロパティに現在のモジュールのURLが格納されています。従来と同じディレクトリパスの文字列が必要な場合の定番の書き方は次の通りです。
const __dirname = path.dirname(fileURLToPath(import.meta.url));
fileURLToPathはnode:urlモジュールが提供する関数で、URL形式をOS標準のパス形式へ変換してくれます。Windowsではドライブレターの扱いが絡むため、URLの文字列を自前で加工せず必ずこの関数を経由させるのが安全です。なおNode.js 20.11以降であればimport.meta.dirnameとimport.meta.filenameが直接使えるため、サポート対象のバージョンによってはさらに簡潔に書けます。設定ファイルの読み込みやテンプレートの参照など、ファイル位置基準の処理が多いプロジェクトほど最初に整備しておきたい変換例です。
JSON読み込みとrequire.resolve相当処理の書き換え実務例
CommonJSではrequireでJSONファイルを直接読み込めましたが、ESMのimportでは追加の指定が必要になります。現在の標準仕様ではimport文の末尾にwith構文で型を指定する方法が定義されており、Node.jsでも利用できます。ただし対応バージョンには幅があるため、互換性を最優先するならfsモジュールでファイルを読みJSON.parseで解釈する古典的な方法が確実です。読み込み頻度が低い設定ファイル程度であれば、この方式で困ることはまずありません。
もう1つ困りやすいのがrequire.resolveの代替で、モジュールの解決先を取得する用途ではimport.meta.resolveが対応します(返り値はファイルパスではなくURL文字列である点に注意が必要です)。さらに、どうしてもCommonJSの読み込み機構が必要な場面では、node:moduleが提供するcreateRequire関数でrequire相当の関数を生成する方法が公式に用意されています。ネイティブアドオンの読み込みや古いプラグインの互換維持など、完全移行が難しい箇所の逃げ道として覚えておくと、移行計画の自由度が大きく上がる実務例です。
CommonJSと併存させる段階的移行とデュアル運用可否の判断基準
すべてを一度に書き換える一括移行と、両形式を併存させる段階的移行のどちらを選ぶかは、プロジェクトの規模と性質で決まります。判断基準として有効なのは3つの数値で、対象ファイル数がおおむね100を超えるか、動的requireの使用箇所が10を超えるか、そしてテストカバレッジが十分に確保されているかです。規模が大きく動的な読み込みが多いほど、一括移行のリスクは跳ね上がります。
段階的移行を選ぶ場合は、新規コードをESMで書き、既存のCommonJS資産を動的importまたはrequireのESM対応機能経由で呼び出す構成が現実的です。境界となるファイルには.mjsと.cjsの専用拡張子を使い、どちらの世界に属するかを明示しておくと混乱を防げます。注意すべきは併存期間の長期化で、両形式のビルド設定とテスト設定を維持するコストは想像以上に重くなります。四半期単位で完了期限を区切り、移行済み比率を計測しながら進めるのが、頓挫を避ける実務的な進め方でしょう。
Native ESM移行で頻発するエラーと失敗パターン別の解決策
ESM移行では決まったエラーが繰り返し報告されており、原因のパターンさえ知っていれば大半は短時間で解決できます。ここでは頻出する失敗パターンと、その切り分け方を順に解説します。
Cannot use import statementエラーの原因と解決手順
Cannot use import statement outside a moduleは、ESM構文で書かれたコードがCommonJSとして実行された際に出る最頻出のエラーです。原因は実行モードの不一致に集約され、Node.jsならpackage.jsonのtypeフィールドが未設定のまま.jsファイルでimportを書いているケースが大半を占めます。ブラウザならscriptタグのtype属性にmoduleを指定し忘れているケースが該当します。エラー文言は同じでも、発生場所によって直すべき設定が違う点を最初に切り分けることが大切です。
解決手順は3段階で、まずエラーを出している実行主体がNode.js本体なのか、テストランナーなどの別ツールなのかを特定します。次に該当ファイルがどの判定ルールでCommonJS扱いになっているかを、typeフィールドと拡張子から確認しましょう。最後に、typeをmoduleへ変更するか、ファイルを.mjsへ改名するか、ツール側の変換設定を直すかを選択します。テスト実行時だけ出る場合はツール側の設定問題であることがほとんどで、アプリ本体の設定をいじっても解決しません。
拡張子省略やindex解決が効かない相対パス指定の失敗パターン
CommonJSでは相対パスの拡張子を省略でき、ディレクトリを指定すれば自動でindex.jsを探してくれました。Native ESMではこの自動解決が仕様として廃止されており、拡張子まで含めた完全なパス指定が必須です。移行直後にモジュールが見つからない旨のエラーが大量発生する場合、ほぼ確実にこの失敗パターンに該当します。長年の習慣で省略形を書いてしまうため、移行後もしばらく再発しやすい点が厄介です。
対策の本命は機械的な一括修正と再発防止の自動化です。既存コードについては、importパスに拡張子を補う移行支援ツールやコードモッドで一括変換できます。再発防止にはESLintのルールで拡張子必須を強制し、エディタの自動インポート設定も拡張子付きに変更しておくと効果的です。なおTypeScriptプロジェクトでは、ソース上は.tsでも出力後の.jsを指定するという直感に反するルールがあるため、コンパイラ設定との整合まで含めて方針を決めておく必要があります。
default exportの相互運用で起こるundefinedの失敗パターン
CommonJSパッケージをESMからimportした際、取得した値がundefinedになる、あるいは関数のはずがオブジェクトになっているという相談は後を絶ちません。原因は両形式の相互運用におけるdefaultの解釈差です。Node.jsはmodule.exportsの値全体をデフォルトエクスポートとして扱いますが、トランスパイラを経由したコードは__esModuleフラグの有無で挙動を変えるため、ビルド経路によって受け取れる形が変わってしまいます。
切り分けの手がかりは、importした値をそのままコンソールに出力して実際の構造を確認することです。defaultプロパティの下に目的の関数がぶら下がっていれば、この失敗パターンに該当します。回避策としては、デフォルトではなく名前付きエクスポートを使う、あるいはdefaultを一段掘る互換コードを挟むのが定番です。TypeScriptであればesModuleInteropとallowSyntheticDefaultImportsの設定が挙動を左右するため、プロジェクト全体で設定を統一しておくことが再発防止につながります。
Jestやts-nodeなどテスト環境でESMが動かない場合の対処例
アプリ本体はESMで動くのにテストだけ落ちるという状況は、移行プロジェクトの定番のつまずきどころです。Jestは長らくCommonJSを前提に設計されてきたため、ESMをネイティブ実行するには実験的フラグの指定とモック機能の制約受け入れが必要になります。ts-nodeも同様に、ESMモードでの実行には専用のローダー指定が求められ、設定の組み合わせが複雑になりがちです。テストフレームワーク側の事情を知らないと、アプリの設定を疑って時間を浪費してしまいます。
実務での対処例は大きく3つに分かれます。1つ目は、Jestの変換設定で従来通りCommonJSへ変換してからテストする現状維持型で、設定変更が最小で済む手堅い方法です。2つ目は、最初からESMネイティブで設計されたVitestへ乗り換える方式で、近年の移行事例では最有力の選択肢になっています。3つ目は、Node.js標準のテストランナーを使う構成で、依存を増やしたくない小規模プロジェクトに向いています。テスト資産の規模とモック機能への依存度を秤にかけ、移行コストが最小になる経路を選ぶのが判断のコツです。
循環参照やモジュール読み込み順序に起因するエラーの切り分け基準
移行後に特定の値だけがundefinedになる、初期化前のクラスを参照したというエラーが出る場合、循環参照を疑うのが切り分けの第一基準です。ESMはライブバインディングのおかげでCommonJSより循環に強いものの、評価順序の関係で未初期化の値に触れる可能性は残ります。とくにクラス宣言や定数を相互に参照し合うモジュール群は、読み込みの起点が変わっただけで顕在化することがあります。CommonJSでは偶然動いていた循環が、移行を機に表面化するパターンです。
切り分けには依存関係を可視化するツールが有効で、madgeのような解析ツールを使えば循環している経路を一覧できます。検出した循環への対処は、共有される型や定数を独立したモジュールへ切り出して依存の向きを一方向に整えるのが王道です。応急処置としては、参照箇所を関数の中へ移して評価タイミングを遅らせる方法もあります。再発防止としてESLintやdependency-cruiserで循環を検出するルールをCIに組み込んでおくと、新たな循環の混入を機械的に防げます。
ViteやwebpackなどビルドツールとNative ESMの関係性の理解
Native ESMの普及はビルドツールの設計思想を根本から変えました。各ツールがESMをどう扱っているかを知ると、ツール選定や設定の判断が格段にしやすくなります。
Vite開発サーバーがバンドル不要で高速起動する仕組みと従来比較
Viteの開発サーバーが高速なのは、ブラウザのNative ESM対応を前提に設計されているからです。従来型のツールは起動時にアプリ全体を1つにバンドルしてから配信していたため、規模が大きくなるほど起動時間が線形に伸びていました。Viteはバンドルを作らず、ブラウザからのimport要求に応じて必要なファイルだけをその場で変換して返します。起動時に全体を処理しないため、数千ファイル規模でも数百ミリ秒で立ち上がるという従来比で桁違いの差が生まれます。
ただし完全な無変換ではなく、node_modules内の依存パッケージについてはesbuildで事前に最適化バンドルを作っています。CommonJS形式のパッケージをESMへ変換し、大量の細かいファイルをまとめてリクエスト数を抑えるためです。つまりViteは、変更頻度の低い依存は事前処理、頻繁に触るアプリコードはオンデマンド処理という二層構造で速度を稼いでいます。コード変更時も該当モジュールだけ再変換すれば済むため、画面反映までの待ち時間が規模に左右されにくい点が実務上の最大の恩恵です。
webpackビルド成果物とNative ESM配信の違いと使い分け基準
webpackが生成する成果物は、独自のランタイムでモジュール間を接続した自己完結型のバンドルです。ブラウザのESM機能には依存しないため、古い環境でも同じように動く堅牢さがあります。一方のNative ESM配信は、ビルド後もモジュール構造を保ったままブラウザの標準機能に解決を任せる方式です。中間レイヤーがないぶん仕組みは素直ですが、配信するファイル数が多くなるという特性を持ちます。
使い分けの基準は配信先とアプリの規模で考えます。不特定多数が使う大規模アプリでは、コード分割や遅延読み込みの制御が細かく効くバンドル方式に依然として分があるのが実情です。逆に、社内ツールやモダンブラウザ限定のサービス、ライブラリのサンプル配布などでは、ビルドパイプラインを薄くできるESM配信の単純さが効いてきます。近年はバンドラー自体もESM形式での出力に対応しているため、二者択一ではなくバンドルしつつ出力形式はESMという折衷構成も有力な選択肢です。
バンドルレス配信とバンドル配信のHTTP/2環境における性能比較
HTTP/2の多重化により、多数のファイルを並行取得するコストは大きく下がりました。それならバンドルは不要ではないかという議論がありますが、性能比較の結果はそう単純ではありません。モジュール数が数百を超えると、ブラウザがimportを辿って依存を発見していく過程で読み込みの波が連鎖し、すべてが揃うまでの時間はバンドル方式より長くなる傾向が確認されています。リクエストの多重化はできても、依存の発見自体が逐次的である点は変わらないからです。
一方でバンドルレス配信にはキャッシュ粒度という強みがあります。1ファイルの修正で巨大なバンドル全体が無効になる方式と違い、変更したモジュールだけを再取得させられるため、更新頻度の高いアプリでは再訪問時の転送量を大幅に削減できるのが強みです。性能比較の実務的な結論としては、初回表示の速さを最優先するなら適度なバンドル、再訪問の多い管理画面系ならキャッシュ効率を活かせる細かい分割が有利という整理になります。modulepreloadによる事前読み込み指示を併用すれば、波状読み込みの弱点もある程度は緩和できます。
esbuildとRollupのESM出力対応における機能面の比較観点
esbuildとRollupはどちらもESM出力に対応していますが、得意分野が明確に分かれています。esbuildはGo言語で実装された圧倒的な変換速度が持ち味で、同等処理をJavaScript製ツールと比べると数十倍から100倍速いとされる場面もあるほどです。一方のRollupは出力の綺麗さと最適化の深さに定評があり、複数モジュールを1スコープに統合する処理によって、実行時のオーバーヘッドが小さいコードを生成します。ライブラリ配布物の生成で長年選ばれてきた理由はここにあります。
比較観点として実務で効くのは、変換速度、出力コードの品質、プラグイン資産の3点です。開発時の変換やCIの高速化が目的ならesbuild、配布用ライブラリの最終ビルドや細かい出力制御が必要ならRollupという住み分けが現在の定番です。実際、Viteは開発時にesbuild、本番ビルドにRollup系を使う構成でこの2つを組み合わせています。どちらか一方を選ぶ問題ではなく、工程ごとに適材適所で使い分ける対象と捉えるのが正確な理解と言えるでしょう。
npmライブラリ配布でESMとCJSを両対応させる実務的な設定例
npmで配布するライブラリは、利用者の環境が混在している以上、ESMとCommonJSの両対応がまだ現実的な落としどころです。実務的な構成は、ソースを1系統で管理し、ビルドで2形式を出力してexportsフィールドの条件分岐で振り分ける形になります。型定義を含めた典型的な設定例は次の通りです。
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } }
ビルドにはtsupのような2形式同時出力に対応したツールを使うと、設定の維持コストを最小化できます。検証にはpublintやarethetypeswrongといった診断ツールが便利で、公開前にエントリポイントの不備を機械的に検出できます。なお近年は、Node.jsのrequireがESMを読めるようになった流れを受けて、ESM専用で公開する方針のライブラリも増えてきました。利用者層のNode.jsバージョン分布を確認し、CommonJS版の提供をいつまで続けるかを計画しておくことも、配布側の重要な判断材料になります。
Native ESM採用可否を判断するためのプロジェクト条件の評価基準
ここまでの内容を踏まえ、最後に自分のプロジェクトでNative ESMを採用すべきかどうかを判断するための評価基準を整理します。新規開発と既存資産の移行では、見るべき条件が大きく異なります。
新規開発でNative ESMを第一候補とすべき3つの判断基準
新規プロジェクトであれば、Native ESMを第一候補に置くのが現在の合理的な判断です。根拠となる基準は3つあり、第一に主要なランタイムとブラウザの対応が完了しており、互換性を理由に避ける必要がなくなったことが挙げられます。第二に、ViteやVitest、Honoといった近年の主要ツール群がESMを前提に設計されており、CommonJSを選ぶ方がむしろ設定の手間を増やすことです。第三に、npmの新規パッケージにESM専用のものが増え続けており、CommonJSを選ぶと将来使えるライブラリの選択肢が狭まっていくことです。
逆に新規でもCommonJSを検討する余地があるのは、社内の共通基盤やデプロイ環境が古いNode.jsに固定されている場合や、requireへ依存した既存の社内ライブラリ群を大量に使い回す場合に限られます。判断に迷ったら、package.jsonのtypeフィールドにmoduleを指定した雛形で小さく始めてみることです。新規開発なら移行コストはゼロであり、後からCommonJSへ戻す事態はまず起こりません。エコシステムの流れが一方向である以上、新規案件で逆張りする理由は乏しいと言えます。
既存の大規模プロジェクトにおいて移行コストを見積もる4つの評価観点
既存プロジェクトの移行判断は、利益よりも先にコストを正確に見積もることが出発点になります。見積もりの精度を左右する評価観点は次の4つです。
- 対象規模:ファイル数とrequire使用箇所の総数から機械変換後の手作業量を推定する
- 動的読み込み:パスを実行時に組み立てるrequireの箇所数は人手対応の量に直結する
- 周辺設定:テスト、ビルド、Lint、CIなど書き換えが必要な設定ファイルの数を洗い出す
- 安全網:テストカバレッジの水準が低いほど、退行検出のための追加整備費が乗る
この4観点で算出したコストを、ツール刷新による開発体験の向上やESM専用ライブラリの利用解禁といった便益と比較します。実務では、ビルドツールをViteへ刷新するタイミングや、Node.jsのメジャーアップデート対応と抱き合わせると、検証作業を共通化できて総コストを圧縮できます。便益が明確に上回らないなら、新規コードだけESMで書く併存方針に留めるのも立派な判断です。
TypeScript併用時のmoduleResolution設定値の判断基準
TypeScriptプロジェクトでは、tsconfig.jsonのmoduleResolution設定がESM対応の挙動を大きく左右します。現在の選択肢は実質2つで、バンドラー経由で動かすコードにはbundler、Node.jsで直接実行するコードにはnodenextを選ぶのが判断基準です。bundlerは拡張子省略を許すなどバンドラーの解決規則に合わせた緩い設定で、Viteやwebpackでビルドするフロントエンドはこちらが標準になります。
一方のnodenextは、Node.js本体の厳密な解決規則をそのまま再現する設定です。importパスに出力後の拡張子である.jsを書く必要があるなど一見不便ですが、コンパイル後のコードが実行時に確実に動くことを型チェックの段階で保証できます。サーバーサイドやCLI、npm配布ライブラリではnodenext一択と考えて差し支えありません。古い記事で紹介されているnodeという設定値は現在では非推奨側の扱いであり、新規設定で選ぶ理由はない点も押さえておきたい判断材料です。
DenoやBunといったESM前提ランタイムとNode.jsの構成比較観点
DenoとBunは、どちらも設計の出発点からESMを標準としたランタイムです。DenoはCommonJSを基本的に過去のものとして扱い、URLによるimportやWeb標準APIへの準拠を徹底しています。BunはESMを第一としながらもCommonJSとの相互運用を高速に処理する実装を持ち、既存のnpm資産をほぼそのまま動かせる互換性が売りです。Node.jsだけがCommonJSという歴史的資産を背負っており、両形式の判定ルールが複雑なのはその代償と言えます。
構成比較の観点は、既存npm資産との互換性、ツールチェーンの統合度、運用実績の3つに整理できます。互換性と実績を最重視するならNode.js、テストランナーやバンドラーまで内蔵した統合体験と速度を求めるならBun、権限制御や標準準拠を重視する新規開発ならDenoという住み分けです。ここで重要なのは、どのランタイムを選んでもESMで書いたコードが共通言語になるという事実です。Native ESMへの習熟は、特定ランタイムへの依存を減らし、将来の乗り換え自由度を確保する投資にもなります。
無理に移行して破綻するプロジェクトに共通する典型的な失敗パターン
移行プロジェクトの破綻には共通の型があります。最も多い失敗パターンは、依存調査を省いて一括変換に着手し、ESM化できない依存やツールに後から次々とぶつかって停滞するケースです。次に多いのが、アプリ本体だけ見てテスト環境の対応を見落とし、移行後にテストが全滅して切り戻すケースになります。さらに、併存方針のまま完了期限を切らず、二重のビルド設定を何年も維持し続けて保守コストだけが積み上がる慢性化パターンも頻繁に観測されます。
これらに共通する根本原因は、移行を単なる構文の置換作業と見積もってしまうことです。実際の作業の過半は、依存関係の調査、ツール設定の刷新、退行テストの整備に費やされます。破綻を避ける要点は3つで、着手前の依存台帳づくり、テストを含めた小さな範囲での先行検証、そして期限と撤退条件をあらかじめ決めておくことです。撤退条件まで決めてあれば、想定外の障害に出会っても損害を限定して仕切り直せます。移行は手段であって目的ではないという原則を、計画段階で文書に残しておくことが最後の防波堤になります。