Node.jsモジュールスコープの深層:CommonJSとESMで変数の見え方はどう変わるか
コードレビューをしていて、いまだに `require` と `import` を「構文の好みの違い」や「単なる非同期対応の差」くらいに捉えているエンジニアに出会うことがある。
しかし、V8ランタイムとNode.jsのモジュールローダーの挙動を深く理解している者からすれば、これは「変数のカプセル化とメモリ空間の生存戦略におけるパラダイムシフト」そのものだ。
今回は、CommonJSとESM(ECMAScript Modules)が、それぞれどのようにスクリプトをラップし、変数をメモリ上に展開しているのか。そのメカニズムの核心を、ランタイムの挙動レベルで紐解いていこう。
—
1. CommonJS(CJS)の正体:即時実行関数とラッパーの魔法
まずはレガシーでありながら、今なおNode.jsエコシステムの基盤を支えるCommonJSだ。
多くの開発者は、`require` で読み込んだファイルが独立したスコープを持つことを知っているが、では「なぜ」グローバル空間を汚染しないのか説明できるだろうか。
Node.jsがCommonJSモジュールを実行する際、V8はコードをそのまま実行しているわけではない。実は、モジュールのソースコードは以下のようなラッパー関数で暗黙的に包み込まれている。
// Node.js内部でCommonJSモジュールが実行される際の擬似的な姿
function (exports, require, module, __filename, __dirname) {
// —————————————————————-
// ここにあなたが書いたコードが挿入される
// —————————————————————-
}
このラッパーの存在により、モジュール内で宣言した `var`、`let`、`const` は、すべてこの関数のローカルスコープに閉じ込められる。これが、ファイル間で変数が衝突しない理由の根本だ。
CJSにおけるエクスポートの罠:値のコピー(コピー・オン・リターン)
CommonJSにおける `module.exports`(または `exports`)の本質は、「値の浅いコピー(または参照の代入)」である。ここを誤解していると、プロダクション環境で不可解なバグ(状態の不整合)を生む。
以下のコードを見てほしい。
// counter.js (CommonJS)
let count = 0;
function increment() {
count++;
}
module.exports = {
count, // 0という「値」がここでコピーされる
increment
};
// main.js (CommonJS)
const { count, increment } = require(‘./counter’);
console.log(count); // 0
increment();
console.log(count); // 0 !? 幾ら呼んでも増えない!
「なぜ `count` が更新されないのか?」
コードレビューで「これだからCJSは……」と溜息をつく前に、ランタイムの動きを思い出してほしい。
`module.exports = { count }` の時点で、`count` 変数のプリミティブな値(`0`)がオブジェクトに評価されてエクスポートされている。呼び出し元が受け取ったのは、その時点のスナップショットに過ぎない。元のモジュール内の `count` がインクリメントされようとも、エクスポートされたオブジェクト内のプロ1パティは連動して変わらないのだ。
これを意図通りに動かすには、オブジェクト経由で参照を維持させる必要がある。
// 修正版 counter.js
const state = {
count: 0
};
module.exports = {
state, // オブジェクトの参照をエクスポートする
increment: () => state.count++
};
—
2. ESM(ECMAScript Modules)の真価:ライブバインディングと静的解析
では、モダンな標準仕様であるESMはどうだろうか。
ESMは、CommonJSのような動的なラッパー関数に依存せず、V8のパーサーによる静的解析(Static Analysis)を前提に設計されている。
`import` と `export` は文法レベルでモジュールの依存関係をコンパイル時に確定させるため、ツリーシェイキング(未使用コードの削除)が可能になる。
そして、変数スコープの観点でCommonJSと決定的に異なるのが「ライブバインディング(Live Binding)」だ。
// counter.mjs (ESM)
export let count = 0;
export function increment() {
count++;
}
// main.mjs (ESM)
import { count, increment } from ‘./counter.mjs’;
console.log(count); // 0
increment();
console.log(count); // 1 !? 値が生きて連動している!
なぜESMでは値が同期するのか?
ESMにおいて、エクスポートされた変数は単なるスナップショットのコピーではない。モジュール間の「読み取り専用のライブリンク(生きた参照)」としてバインドされる。
V8エンジンのメモリ空間において、元のモジュールが保持する変数のメモリアドレスと、インポート側のスコープが指し示すポインタが概念的に結びついているため、元で値が書き換われば、インポート側からもその変化が即座に観測できるのだ。
この挙動は、状態管理や設定値の共有において非常に強力である一方、「インポート先から誤って変数を書き換えられないか?」という懸念を生む。
安心してほしい。ESMの仕様上、インポートされた変数は `const` と同様に読み取り専用(Read-only)であり、インポート側のスコープから直接再代入しようとすると、V8は容赦なくSyntaxErrorまたはTypeErrorを吐き出す。
—
3. 現場で使える堅牢な設計パターン:CJS/ESM混在期の注意点
昨今のフロントエンド・バックエンド開発では、TypeScriptの普及やビルドツールの高度化により、CommonJSとESMが混在するカオスな環境に直面することが多い。特にNode.jsでパッケージを書く際や、SSR(サーバーサイドレンダリング)のコンテキストを構築する際、この違い起因するバグは致命傷になり得る。
ここでは、テクニカルリードとしてチームに推奨したい、「環境差異を吸収し、メモリリークや意図せぬ参照共有を防ぐ設計パターン」を提示しよう。
パターンA:イミュータブルな状態カプセル化(ゲッターの活用)
モジュールの外部から内部変数を直接書き換えられたくない、しかし最新の状態は安全に参照させたい場合、ESMであってもゲッター関数(Accessor)を介す設計が最も堅牢である。
// store.mjs – 堅牢な状態管理モジュールの例
let internalState = {
user: null,
isAuthenticated: false
};
// 直接のライブバインディングを避け、関数経由でイミュータブルに公開する
export function getState() {
// 浅いコピーを返してカプセル化を維持する(ディープコピーが必要な場合はstructuredClone等を使用)
return { …internalState };
}
export function setUser(newUser) {
internalState = {
user: newUser,
isAuthenticated: true
};
}
この設計であれば、呼び出し元が不審なコードで `state` オブジェクトを破壊しようとも、モジュール内部のプライベートな実体を保護しつつ、予測可能なデータフローを維持できる。
—
4. パフォーマンスとV8エンジンの最適化視点
最後に、V8エンジンの実行効率という観点から、モジュールスコープと変数について一言添えておこう。
1. 静的インポート(`import … from`)の優位性
Dynamic Import(`import()`)は非同期処理であり、コード分割(Code Splitting)には不可欠だが、無計画な多用はV8のインラインキャッシュ(Inline Caching)の効きを悪くし、モジュール解決のオーバーヘッドを増大させる。初期ロードに必要な依存関係は常に静的インポートでトップレベルに記述し、V8のコンパイル時最適化の恩恵を最大限に受けるべきだ。
2. ブロックスコープ(`let`/`const`)のコスト
現代のV8は、`var` と `let`/`const` の実行時パフォーマンスの差をほぼ完全に最適化している。しかし、TDZ(Temporal Dead Zone:一時的死区間)の存在により、巻き上げ(Hoisting)に起因する予期せぬバグをコンパイル段階で検知できるメリットは圧倒的である。モジュールスコープであっても、`var` を使う理由はもはや1ミリも存在しない。
総括
変数スコープの挙動を制する者は、JavaScriptの実行モデルを制する。
`require` と `import` の違いを「構文の違い」で片付けることなく、その裏にある「値のコピー(CJS)」と「ライブバインディング(ESM)」のメカニズムをコードの隅々まで意識し抜け。
あなたの書くその1行が、V8のメモリ空間とブラウザ/ランタイムのパフォーマンスを最適化する鍵となる。コードレビューでは、常にその背後にあるランタイムの挙動まで見通す視点を持って臨んでほしい。