この投稿では、Node.jsにおいてCommonJSとES Modulesのモジュールロードの仕組みの違いを、同期的か非同期的かの観点で説明します。
本稿では、CommonJSはCJS、ES ModulesはESMと略称で記載します。
執筆にあたり、できるだけ努力して調査したつもりですが、もし用語の使い間違いや誤った説明などがございましたら、ご指摘や訂正の提案などを頂けると幸いです。
同期的なCJS、非同期的なESM
CJSはもともとサーバサイドJavaScriptにおけるモジュールの問題を解決するためにねられた仕様でした。サーバサイドではJSファイルがローカルディスク上にあることが普通です。そのため、JSファイルを探し、ファイルの内容を読み込む処理はCJSでは同期的なロード方式になっています。
一方の、ESMはサーバサイドだけでなく、ブラウザで使われることも考えて、同期的なロード方法に限定しないことになっています。もしも、同期的なロードに限定してしまうと、ブラウザでのユーザ体験が悪くなるからです。
ESMのロード方式を同期的な実装にするか、非同期的にするかは、JavaScriptの実行エンジンが決めていいことになっています。Node.jsは、ESMは非同期的にロードする方式を採用しています。
ファイル読み込みと実行
モジュールのロードプロセスは基本的に、JavaScriptのファイルを読み込み実行されるわけですが、ファイル読み込みと実行のタイミングはCJSとESMで異なってきます。
CJSのファイル読み込みと実行
CJSでは、モジュールをrequire
したとき、モジュールごとに「ファイル読み込み」→「実行」の手続きをセットで行っていきます。
例えば、次の例のように3つのモジュールをrequire
しているコードで具体的に考えてみましょう:
// 1.js
console.log("1.js");
// 2.js
console.log("2.js");
// 3.js
console.log("3.js");
// cjs.js
require("./1");
require("./2");
require("./3");
このcjs.jsを動かすと、ロードプロセスはどんな流れで進んでいくでしょうか。まず、1行目でrequire
した1.jsのファイルの内容が読み込まれます。そして、そのJavaScriptコードが評価実行されます。ここで、コンソールの標準出力に1.js\n
が出力されます。次に、2.jsが読み込まれ、実行、2.js\n
が出力されます。最後に、3.jsが読み込まれ、実行、3.js\n
が出力されます。
ちなみに、CJSのロードプロセスが実際にどうなっているかは、環境変数NODE_DEBUG=module
をセットしてNode.jsを実行してみると観察することができます。
CJSが同期的と言われるのは、このようにモジュールのファイル読み込みと実行が同時期に発生しながらロードプロセスが進んでいくためです。
ESMのファイル読み込みと実行
Node.jsのESMでは、モジュールをimport
したとき、非同期的なロードを行うようになっています。なぜ非同期かというと、モジュールのファイル読み込み、依存関係グラフの構築、そして、実行がバラバラのタイミングで行われるためです。
具体的に、次のように3つのモジュールをimport
しているコードを見てみましょう:
// 1.mjs
console.log("1.mjs");
// 2.mjs
console.log("2.mjs");
// 3.mjs
console.log("3.mjs");
// esm.mjs
import "./1.mjs";
import "./2.mjs";
import "./3.mjs";
このesm.mjsを動かすと、モジュールのロードプロセスは次のようになります。まず、esm.mjs自体が読み込まれ、静的解析され、依存するモジュールのリストが作られます。この時点では、esm.mjs自体はまだ実行されません。
1.mjs、2.mjs、3.mjsがリストアップされるので、このリストに基づいて、3つファイルを並行で読み込みます。これらも静的解析され、依存するモジュールのリストを作るのですが、これ以上依存先がないため、モジュールの依存関係グラフが完成します。
最後にそのグラフに基づいて、各モジュールを実行していきます。実行順は、post-order traversalというアルゴリズムで行われます。ざっくりいうと、グラフの中で末端かつ左側に位置するモジュールから実行していき、ルートに到達するまでそれを繰り返すというものです。
post-order traversalの探索順序
なので、この例では、まず1.mjsが実行され、コンソールの標準出力に1.mjs\n
が出力されます。次に、2.mjsが実行され、2.mjs\n
が出力されます。最後に、3.mjsが実行され、3.mjs\n
が出力されます。そして最後にesm.mjs
が実行されます。
ちなみに、ESMのロードプロセスが実際にどうなっているかは、環境変数NODE_DEBUG=esm
をセットしてNode.jsを実行してみると観察することができます。
ESMローダーが非同期型であることのメリット
ESMのロード方式は、ECMAScriptの仕様で決められておらず、Node.jsが非同期的ロードなのはNode.jsがそう作ったからというのは前述したとおりです。Node.jsが同期的なローダーを実装する選択肢も無くはなかったわけですが、非同期を採用したことでいくつかメリットも生まれました。
メリット1: 並行読み込みによるパフォーマンス
CJSは同期的ロードなので、モジュールをロードし終わるまでは、別のモジュールをロードすることができません。ESMは非同期的なので、複数のモジュールを並行して読み込むことができます。このおかげで、読み込むモジュールが多くなるほど、パフォーマンスにいい影響を与えられると考えられます。
メリット2: リモートモジュール対応への将来性がある
Node.jsはブラウザと異なり、import "https://..."
のようにして、リモートのモジュールをロードすることは現在できません。しかし、ESMローダーが非同期な設計になっているおかげで、リモートのモジュールをダウンロードしてきて実行することが、技術的には可能になっています。もしも、将来的にNode.jsがリモートモジュールに対応した場合、ローダーの仕様を変えないで済むので、Nodeユーザとしては下位互換性を崩されることなく、リモートモジュール対応の恩恵を享受できることでしょう。
メリット3: ブラウザのローダーと同じ
ブラウザはリモートモジュールをロードする必要があるので、非同期的なローダーになっています。Node.jsもブラウザと同じ非同期型であることはメリットです。モジュールを開発するとき、「ブラウザではこう、Node.jsではこう」と場合分けしてモジュールの設計を考えずに済みます。また、モジュールを使う側としても、同じマインドセットで臨めるので、学習コストがかからなかったり、知らぬがゆえにバグらせてしまうといった事故も避けられます。
最後までお読みくださりありがとうございました。Twitterでは、Qiitaに書かない技術ネタなどもツイートしているので、よかったらフォローお願いします→Twitter@suin