Read OSS

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.jsonmain および exports フィールドの確認、という順で処理を進めます。相対パスの解決キャッシュ(relResolveCacheIdentifier = parent.path + '\x00' + request)により、同じディレクトリからの繰り返しの require はほぼコストなしで処理されます。

Module.prototype._compile() では、CommonJS ラッパーの核心部分が実行されます。すべての CJS モジュールは次のような関数でラップされます。

(function(exports, require, module, __filename, __dirname) {
  // Your module code here
});

これが、CommonJS ファイルで exportsrequiremodule__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 種類あります。

  1. 同期フックlib/internal/modules/customization_hooks.js による resolveload フック。メインスレッドで動作します。

  2. 非同期フック--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.jsontype フィールドにフォールバックします。--experimental-loader--import フラグが指定されている場合も ESM ローダーが使われます。

その後 run_main_module.jsModule.runMain() を呼び出し、CJS ローダーで直接ファイルを実行するか、ESM ローダーに処理を渡すかを決定します。

次回予告

ここまでで、C++ バインディングからユーザーモジュールに至るまで、コードが Node.js プロセスに読み込まれる仕組みを網羅しました。次回は、読み込まれたコードが実際に何かをする場面に踏み込みます。I/O システム、ストリーム、タイマー、そして Node.js の非同期 I/O を支えるイベントループのメカニズムを解説します。