Node.js がコードを読み込む仕組み:CJS、ESM、そしてモジュールパイプライン
前提知識
- ›第 1 回:architecture-overview
- ›第 2 回:startup-and-bootstrap
- ›第 3 回:cpp-object-model-and-bindings
- ›CommonJS の require() と ES Module の import セマンティクスへの理解
Node.js がコードを読み込む仕組み:CJS、ESM、そしてモジュールパイプライン
Node.js にはモジュールシステムが 2 つ あります。さらに、ランタイム自身が内部でのみ使う独自のモジュールシステムも存在します。CommonJS ローダーは Node.js 0.1 から搭載されており、ES module ローダーはその後に登場し、根本的に異なるセマンティクスを持っています。この 2 つを共存させ、相互に連携させることは、コードベースの中でも特に複雑なエンジニアリング上の課題のひとつです。
この記事では、Node.js におけるコードの読み込み過程を追っていきます。内部モジュールを守るための primordials パターンから始まり、CommonJS ローダーと ESM ローダーを経て、TypeScript の型除去を可能にするカスタマイズフックまでを解説します。
Primordials:プロトタイプ汚染への防御
モジュールが読み込まれる前に、Node.js はユーザーランドのモンキーパッチから内部コードを保護する必要があります。誰かが Array.prototype.push = () => { throw new Error('gotcha') } などと書いても、fs.readFile() が壊れてはいけません。そのための仕組みが primordials.js です。このファイルは他の何よりも先に実行され、すべての JavaScript 組み込みオブジェクトのフリーズ済みコピーをキャプチャします。
仕組みは明快です。組み込みオブジェクトのプロトタイプメソッドそれぞれに対して、Function.prototype.call.bind() を使った「アンカリー化」バージョンを作成します。
// Instead of: array.push(item) — vulnerable to monkey-patching
// Internal code uses: ArrayPrototypePush(array, item) — safe
const uncurryThis = bind.bind(call);
そのため、すべての内部モジュールはファイルの先頭で primordials から必要な関数をインポートします。lib/internal/modules/cjs/loader.js の冒頭を見てみましょう。
const {
ArrayIsArray,
ArrayPrototypeFilter,
ArrayPrototypeIncludes,
ArrayPrototypeIndexOf,
ArrayPrototypeJoin,
// ... dozens more
} = primordials;
パフォーマンスへのトレードオフは無視できません。ソースコードのコメント にも記載されているとおり、「primordials の使用はパフォーマンスに大きな影響を与える場合があります。パフォーマンスに敏感な箇所への変更は必ずベンチマークを取ってください」とあります。ArrayPrototypePush(arr, item) は arr.push(item) よりも測定可能なほど遅くなります。アンカリー化された形式は V8 がインライン展開しにくいためです。しかし、そのコストに見合うセキュリティ上の保証があると判断されています。
ヒント: 内部モジュールへのパッチを書いたりレビューしたりする際は、組み込みメソッドには必ず primordials を使いましょう。リンターがこれを強制しており、
node-core/prefer-primordialsルールがプロトタイプメソッドの直接呼び出しを検出します。
内部モジュールシステム(BuiltinModule)
第 3 回で解説したとおり、BuiltinLoader は実行時に埋め込み済み JavaScript をコンパイルします。その JavaScript 側の制御は realm.js で行われており、ここで BuiltinModule クラスが生成されます。
flowchart TD
REQ["require('internal/fs/utils')"] --> BM["BuiltinModule.require()"]
BM --> CACHED{"Already compiled<br/>and cached?"}
CACHED -->|Yes| RET["Return cached exports"]
CACHED -->|No| COMPILE["BuiltinLoader::CompileAndCall()"]
COMPILE --> WRAP["Wrap source in function:<br/>(exports, require, module,<br/>__filename, __dirname,<br/>internalBinding, primordials)"]
WRAP --> V8["V8 ScriptCompiler<br/>Compile + Execute"]
V8 --> CACHE["Cache in module registry"]
CACHE --> RET
BuiltinModule は内部モジュール向けに require() 関数を提供していますが、これはパブリックな require() とは別物です。内部モジュールは 'internal/fs/utils' のようなパスで互いを require でき、ユーザーランドからは見えない internalBinding() や primordials に自動的にアクセスできます。
モジュールの読み込み履歴は process.moduleLoadList で追跡されており、プロセスの起動から現在に至るまで読み込まれたすべてのバインディングとモジュールが順番に記録されます。起動パフォーマンスのデバッグに非常に役立ちます。
CommonJS ローダーの詳細
CommonJS ローダーの本体は lib/internal/modules/cjs/loader.js です。Node.js の黎明期から変化し続けてきた、2,158 行に及ぶファイルです。
エントリーポイントは Module._load() で、require アルゴリズムのコアを実装しています。
flowchart TD
LOAD["Module._load(request, parent)"] --> CACHE_CHECK{"Fast path:<br/>relativeResolveCache hit?"}
CACHE_CHECK -->|Yes| MODULE_CACHE{"Module._cache[filename]?"}
CACHE_CHECK -->|No| RESOLVE["resolveForCJSWithHooks()"]
MODULE_CACHE -->|Yes, loaded| RETURN_EXPORTS["Return cached exports"]
MODULE_CACHE -->|Yes, loading| CIRCULAR["getExportsForCircularRequire()"]
MODULE_CACHE -->|No| NEW_MODULE["new Module(filename)"]
RESOLVE --> HOOKS{"Custom resolve hooks?"}
HOOKS -->|Yes| CUSTOM["Run custom resolver"]
HOOKS -->|No| RESOLVE_FN["Module._resolveFilename()"]
RESOLVE_FN --> BUILTIN{"Is builtin module?"}
BUILTIN -->|Yes| LOAD_BUILTIN["Load from BuiltinModule"]
BUILTIN -->|No| FIND_PATH["Module._findPath()<br/>Search node_modules tree"]
FIND_PATH --> NEW_MODULE
NEW_MODULE --> COMPILE["module._compile(content, filename)"]
COMPILE --> RETURN_EXPORTS
Module._resolveFilename() は解決アルゴリズムを実装しています。組み込みモジュールかどうかの確認、node_modules ツリーの走査、package.json の main および exports フィールドの確認、という順で処理を進めます。相対パスの解決キャッシュ(relResolveCacheIdentifier = parent.path + '\x00' + request)により、同じディレクトリからの繰り返しの require はほぼコストなしで処理されます。
Module.prototype._compile() では、CommonJS ラッパーの核心部分が実行されます。すべての CJS モジュールは次のような関数でラップされます。
(function(exports, require, module, __filename, __dirname) {
// Your module code here
});
これが、CommonJS ファイルで exports、require、module、__filename、__dirname を明示的にインポートすることなく使える理由です。これらはグローバル変数ではなく、この関数のパラメーターです。
ESM ローダーのアーキテクチャ
ESM ローダーは CommonJS とは根本的に異なります。非同期で動作し、import assertions をサポートし、フェーズベースのライフサイクルを持ち、ModuleWrap C++ バインディングを通じて V8 のネイティブモジュール API に委譲します。
オーケストレーターは lib/internal/modules/esm/loader.js にある ModuleLoader です。resolve → load → translate → instantiate → evaluate というパイプラインを管理します。
sequenceDiagram
participant USER as import 'specifier'
participant ML as ModuleLoader
participant MJ as ModuleJob
participant TR as Translators
participant MW as ModuleWrap (C++)
participant V8 as V8 Module API
USER->>ML: import('specifier')
ML->>ML: resolve(specifier) → URL
ML->>ML: load(URL) → source + format
ML->>TR: translate(source, format)
TR->>MW: new ModuleWrap(source, url)
MW->>V8: v8::Module::Compile()
ML->>MJ: new ModuleJob(loader, url, moduleWrap)
MJ->>MJ: link() — resolve all dependencies
MJ->>MW: instantiate()
MW->>V8: v8::Module::InstantiateModule()
MJ->>MW: evaluate()
MW->>V8: v8::Module::Evaluate()
V8-->>USER: module namespace
ModuleJob は、ライフサイクルを処理中の単一モジュールを表します。コンストラクターは即座にリンクを開始します。モジュール内のすべての import 文を解決して依存モジュールの ModuleJob インスタンスを作成し、依存グラフを構築します。
translators モジュールは、ファイルフォーマットと変換戦略のマッピングを担います。JavaScript ファイルは ModuleWrap を通じて ES module としてコンパイルされます。JSON ファイルは export default でラップされます。WebAssembly の .wasm ファイルは V8 の WebAssembly API を通じてコンパイルされます。.node のネイティブアドオンは process.dlopen() で読み込まれます。そして CJS ファイルは専用の CJS-to-ESM トランスレーターを通ります。
CJS↔ESM の相互運用
2 つのモジュールシステム間の相互運用は、Node.js の中でも特に難しい部分のひとつです。根本的な矛盾があります。CJS の require() は同期的ですが、ESM の import は非同期です。
flowchart LR
subgraph "ESM importing CJS"
ESM1["import cjs from 'pkg'"] --> TRANSLATE["CJS translator<br/>Executes CJS module<br/>Wraps exports"]
TRANSLATE --> NS1["Module namespace<br/>default = module.exports"]
end
subgraph "CJS requiring ESM"
CJS1["require('esm-pkg')"] --> SYNC{"Module already<br/>evaluated?"}
SYNC -->|Yes| NS2["Return namespace"]
SYNC -->|No| ERR["ERR_REQUIRE_ESM<br/>(unless --experimental-require-module)"]
end
ESM から CJS を import する場合は、CJS モジュールを同期的に実行し、module.exports をデフォルトエクスポートとしてラップします。名前付きエクスポートは CJS ソースを静的解析することで検出されます。
CJS から ESM を require するのはより難しい問題です。require() は同期的ですが、ESM の評価はトップレベル await によって非同期になりえます。Node.js は現在、トップレベル await を使わない ESM モジュールであれば require() で読み込めるようになっています。ただし、非同期評価を含むモジュールを require しようとすると ERR_REQUIRE_ASYNC_MODULE が発生します。
ModuleJobSync クラスは、CJS が ESM を require する同期パスを処理します。Promise を使わずに動作できるよう、フルの ModuleJob ライフサイクルを簡略化したバージョンを提供します。
モジュールカスタマイズフックと TypeScript サポート
Node.js はフックを通じてモジュールの解決と読み込みをカスタマイズできます。仕組みは 2 種類あります。
-
同期フック —
lib/internal/modules/customization_hooks.jsによるresolveとloadフック。メインスレッドで動作します。 -
非同期フック —
--experimental-loaderを使うもの。メインのイベントループをブロックしないよう、別のワーカースレッドで動作します。
flowchart TD
REGISTER["register() hook API"] --> SYNC{"Sync or async?"}
SYNC -->|Sync| MAIN["Hooks run in main thread<br/>customization_hooks.js"]
SYNC -->|Async| WORKER["Hooks run in worker thread<br/>Communicates via MessagePort"]
MAIN --> RESOLVE["resolve(specifier, context, next)"]
MAIN --> LOAD["load(url, context, next)"]
WORKER --> RESOLVE2["resolve(specifier, context, next)"]
WORKER --> LOAD2["load(url, context, next)"]
LOAD --> TS{"TypeScript file?"}
TS -->|Yes| AMARO["deps/amaro<br/>Strip type annotations"]
TS -->|No| CONTINUE["Continue normal loading"]
AMARO --> CONTINUE
TypeScript サポートはこのフックの仕組みの上に構築されています。--strip-types が有効になっている場合(またはファイルの拡張子が .ts の場合)、Node.js は lib/internal/modules/typescript.js を使い、amaro 依存パッケージ(deps/amaro/ にベンダリング済み)で型アノテーションを除去します。これは*型除去(type stripping)*であり、フルの TypeScript コンパイルではありません。型アノテーションを取り除くだけで、型チェックは行わず、enum のような TypeScript 固有の構文の変換も行いません。
ヒント: コンパイル型言語向けのカスタムローダーを作る場合は、非推奨となった
--experimental-loaderフラグではなくregister()API を使いましょう。register()API は同期・非同期どちらのフックもサポートしており、今後のメインパスです。
エントリーポイントのモジュール解決
node app.js を実行したとき、Node.js はどのようにして CJS と ESM のどちらで読み込むかを判断するのでしょうか。その処理は lib/internal/modules/run_main.js にあります。
flowchart TD
ENTRY["node app.js"] --> RESOLVE["resolveMainPath(main)"]
RESOLVE --> EXT{".mjs extension?"}
EXT -->|Yes| ESM["Load as ESM"]
EXT -->|No| CJS_EXT{".cjs extension?"}
CJS_EXT -->|Yes| CJS["Load as CJS"]
CJS_EXT -->|No| WASM{".wasm extension?"}
WASM -->|Yes| ESM
WASM -->|No| TS{".mts extension?<br/>(--strip-types)"}
TS -->|Yes| ESM
TS -->|No| CTS{".cts extension?"}
CTS -->|Yes| CJS
CTS -->|No| PKG_TYPE["Check nearest<br/>package.json 'type' field"]
PKG_TYPE -->|"module"| ESM
PKG_TYPE -->|"commonjs" or absent| CJS
この判断を行うのが shouldUseESMLoader() 関数です。まずファイル拡張子を確認し(.mjs → ESM、.cjs → CJS)、拡張子が .js の場合は最も近い package.json の type フィールドにフォールバックします。--experimental-loader や --import フラグが指定されている場合も ESM ローダーが使われます。
その後 run_main_module.js が Module.runMain() を呼び出し、CJS ローダーで直接ファイルを実行するか、ESM ローダーに処理を渡すかを決定します。
次回予告
ここまでで、C++ バインディングからユーザーモジュールに至るまで、コードが Node.js プロセスに読み込まれる仕組みを網羅しました。次回は、読み込まれたコードが実際に何かをする場面に踏み込みます。I/O システム、ストリーム、タイマー、そして Node.js の非同期 I/O を支えるイベントループのメカニズムを解説します。