モジュールスコープの罠:CommonJSとESMで「変数の見え方」はどう変わるのか
コードレビューをしていて、いまだにCommonJS(`require`)とESM(`import`)のスコープの境界線を曖昧にしたままコードを書いているエンジニアを見かける。
「とりあえず動くから」と、CommonJSからESMへ部分的に移行した結果、グローバル汚染を引き起こしたり、循環参照で突如として変数が `undefined` になったりするバグに頭を抱えた経験はないだろうか。
今回は、Node.jsのランタイム内部でモジュールがどのようにロードされ、変数空間がどう切り分けられているのか、その深層メカニズムを解き明かす。V8エンジンのメモリ管理とモジュールローダーの挙動を脳内トレースしながら、プロダクションで絶対に事故らない設計アプローチを習得してほしい。
—
1. 内部実装の真実:Node.jsはモジュールをどうラップしているか
JavaScriptの仕様書(ECMAScript)の上では、ファイル単位のモジュールスコープが定義されているが、それを具体的にOSのファイルシステムから読み込み、実行環境を提供しているのはNode.jsのランタイム(Module System)だ。
CommonJS (`require`) の裏側
私たちが普段何気なく書いている CommonJS のファイルは、Node.jsによって実行時にある関数でラップ(包囲)される。V8エンジンがコードを評価する直前、Node.jsはソースコードの周囲に以下のようなラッパー関数を動的に生成している。
// Node.jsが内部的に生成するCommonJSラッパーの概念図
function (exports, require, module, __filename, __dirname) {
// ————————————————–
// あなたが書いたCommonJSのコードは、このスコープの中に配置される
const myVariable = ‘secret’;
exports.myVariable = myVariable;
// ————————————————–
}
このラッパーが存在するため、ファイル内で宣言した `const` や `let` はグローバルスコープを汚染せず、この関数スコープ内に閉じ込められる。これが「CommonJSのカプセル化」の正体だ。
また、`module.exports` や `exports` はこの関数に渡される「参照」に過ぎないため、これらを破壊的な方法で上書きすると、モジュール間の依存関係が容易に崩壊する。
ESM (`import`) の裏側
一方、ESM(ECMAScript Modules)はNode.jsの内部ではインテリジェントなモジュールレコード(Module Record)として管理される。ESMはCommonJSのような「関数ラッパー」に依存せず、V8のパーサーが静的解析(Static Analysis)を行い、厳格なモジュールグラフを構築する。
ESMにおける変数の最大の特徴は、「ライブバインディング(Live Binding)」だ。エクスポート元の変数が書き換わると、インポート側の変数(正確にはバインディング)もリアルタイムにその変化を反映する。これはCommonJSの「値のコピー(または参照のコピー)」という静的な挙動とは根本的に異なる。
—
2. コードで比較:CommonJS vs ESM の変数の挙動
百聞は一見に如かず。両者の変数の見え方の違いを、極めて実践的なコードで比較する。
パターンA:CommonJSのコピー挙動とキャッシュ
// counter.js (CommonJS)
let count = 0;
function increment() {
count++;
}
module.exports = {
count, // プリミティブ値はここで「値」としてコピーされる
increment
};
// main.js (CommonJS)
const { count, increment } = require(‘./counter’);
console.log(count); // 0
increment();
console.log(count); // 0 !? (カウントが増えていない!)
【テクニカルリードの解説】
なぜ `count` が `0` のままなのか。`module.exports` に渡した時点で、数値などのプリミティブ型は値のコピーとして評価されるため、`increment()` で `counter.js` 側の変数が変化しても、呼び出し元のローカル変数には反映されない。
これを防ぐには、オブジェクトのプロパティとしてアクセスするか、getter関数をエクスポートする必要がある。
パターンB:ESMのライブバインディング
// 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 (しっかりと反映される!)
// 注意: インポートした変数は読み取り専用(read-only)
// count = 10; // TypeError: Assignment to constant variable.
ESMでは、インポートされたバインディングはイミュータブル(再代入不可)だが、エクスポート側で値が書き換われば、インポート側でもその最新値を参照できる。この挙動の違いを理解していないと、状態を持つモジュールを設計した際に、原因不明のバグに直面することになる。
—
3. プロダクションで実践すべき「堅牢なモジュール設計パターン」
モダンなフロントエンド・Node.js混在環境(Vite、Next.js、TypeScriptなど)において、変数のカプセル化と保守性を担保するための設計指針を提示する。
① ステートフルな変数を直接エクスポートしない
モジュールスコープを持つ変数であっても、外部から直接書き換え可能な状態で公開するのはカプセル化の原則に反する。状態の変更は必ず「アクセサ関数(Getter/Setter)」経由にカプセル化せよ。
// 堅牢な状態管理モジュール (ESM)
let internalState = {
user: null,
isAuthenticated: false
};
// 状態の「読み取り専用」のビューを返す(シャローコピーまたは凍結)
export function getState() {
return Object.freeze({ …internalState });
}
export function setUser(newUser) {
if (!newUser || typeof newUser.id !== ‘string’) {
throw new Error(‘無効なユーザーオブジェクトです’);
}
internalState.user = newUser;
internalState.isAuthenticated = true;
}
このパターンを採用することで、V8のヒープ上にある実データへの不意の外部からの直接変異(Mutation)を防ぎ、予期せぬバグの温床を断つことができる。
② 循環参照(Circular Dependency)への耐性を持つ設計
CommonJSとESMでは、循環参照が発生したときの変数の見え方が異なる。CommonJSでは、循環が起きた時点でエクスポート途中のオブジェクトが返されるため、未定義(`undefined`)のメソッドを叩いてクラッシュすることが多い。
ESMは静的構造のおかげでCommonJSよりは堅牢に扱えるが、やはり初期化順序依存のバグは起こり得る。依存関係は常に「有向非巡回グラフ(DAG)」になるよう設計し、もし循環が避けられない場合は、関数スコープの内部で `import` または `require` を評価する遅延評価パターンを採用せよ。
—
4. まとめ:チーフアーキテクトからの提言
JavaScriptの変数スコープは、単なる文法のルールではない。それはV8ランタイムがメモリを効率的に管理し、ガベージコレクションを円滑に行うための境界線でもある。
- CommonJS (`require`) は、動的なロードと値のコピーを前提としたレガシーだが柔軟な仕組み。スコープは関数ラッパーによって守られている。
- ESM (`import`) は、静的解析によるパフォーマンス最適化と、ライブバインディングによるリアクティブな変数共有を実現するモダンスタンダード。
「なんとなく動く」から脱却し、ランタイムがメモリ上で何を行っているかをイメージしながらコードを紡ぎ出すこと。それこそが、プロダクションの荒波に耐えうる、真に美しいソフトウェアを創り出す唯一の道である。