こんにちは!フロントエンドからNode.jsの深層まで、日夜JavaScriptと向き合っているシニアアーキテクトです。
今回は、Node.jsにおける「モジュールスコープ:CommonJS(require)とESM(import)で変数の見え方はどう変わるか」というテーマについて、トコトン深掘りしていきましょう。
「変数を作ったら他のファイルからも見えちゃった…」「`require`だと動くのに、`import`に変えたらエラーが出る…」そんなモヤモヤを抱えていませんか?ここをクリアすれば、JavaScriptのスコープとNode.jsのモジュールシステムの基本はバッチリマスターできますよ。
一見するとただの「ファイルの読み込み方の違い」に見えますが、実はV8エンジンのメモリ空間や、Node.jsがファイルを読み込むランタイムの仕組みにおいて、両者は全く異なるアプローチを取っています。
さあ、一緒にその内部の扉を開けてみましょう!
—
1. モジュールスコープってそもそも何?
JavaScriptの世界では、かつて「すべての変数がグローバル空間(windowやglobal)を汚染してしまう」という暗黒の時代がありました。どこで書き換えられるか分からない変数は、バグの温床です。
そこで生まれたのが「モジュール」という概念です。
Node.jsでは、「1つのJavaScriptファイル=1つの独立したモジュール」として扱われます。
基本原則として、あるファイル内で宣言した変数(`let`, `const`, `var`)は、デフォルトではそのファイルの外からは一切見えません(カプセル化)。
// secret.js
const secretCode = “秘密のパスワード”;
// このファイルの外からは secretCode にアクセスできません!
この「ファイルの外から隠す」という安全な仕組みを、Node.jsは長年CommonJS(`require`)で支えてきました。そして近年、モダンな標準規格であるESM(`import` / `export`)が主役に躍り出ています。
この2つで、変数の見え方や扱われ方がどう違うのか、順を見ていきましょう。
—
2. CommonJS(`require`)の裏側と変数の見え方
まずは、長年Node.jsを支えてきた大御所、CommonJSの世界です。
実際に書いてみましょう
// counter.js (CommonJS)
let count = 0;
function increment() {
count++;
}
// 外に見せたいものだけを module.exports に詰める
module.exports = {
count,
increment
};
これを別のファイルで読み込んでみます。
// main.js (CommonJS)
const counterModule = require(‘./counter.js’);
console.log(counterModule.count); // 0 出力
counterModule.increment();
console.log(counterModule.count); // あれ?いくつになると思いますか?
ここで重要な「値のコピー」の罠
最後の `console.log`、いくつになったか予想がつきましたか?
答えは `0` のままです。
「えっ、`increment()`で `count++` したのに!?」と驚くかもしれません。
これは、CommonJSの `module.exports` が「値のコピー(スナップショット)」を渡しているからです。
Node.jsが `require` を実行するとき、内部的にはファイル全体を次のような即時実行関数(IIFE)でラップして実行しています。
// Node.jsが内部で行っているラップのイメージ
(function (exports, require, module, __filename, __dirname) {
let count = 0;
// … あなたが書いたコード …
module.exports = { count, increment };
});
この関数スコープの中に閉じ込められた `count` 変数は、外から直接書き換えることができません。`module.exports` に渡した瞬間、当時の値(`0`)がオブジェクトとして切り出されてしまうため、元のモジュール内の変数が変化しても、外側の世界にはリアルタイムで同期しないのです。
—
3. ESM(`import`)の裏側と「ライブバインディング」
では次に、モダンなJavaScriptの標準である ESM(`import` / `export`) を見てみましょう。
ファイルの拡張子を `.mjs` にするか、`package.json` に `”type”: “module”` を記述することで有効になります。
コードで比較してみる
// 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); // さあ、今度はいくつになるでしょうか?
ライブバインディングという魔法
このコードを実行すると、最後の `console.log` は `1` を出力します!
なぜCommonJSと挙動が違うのでしょうか?
ここにESMの真骨頂があります。ESMが提供するのは、値のコピーではありません。「ライブバインディング(Live Binding)」と呼ばれる、参照の紐付けです。
ESMの `import` は、読み込み元の変数への「直通回線(ポインタのようなもの)」を繋ぎます。そのため、モジュール内部で変数の値が書き換わると、それをインポートしている側からも、最新の書き換わった値がリアルタイムに見えるようになります。
[ counter.mjs ] [ main.mjs ]
let count = 1; <===========> import { count }
(値が変化すると) (即座に反映される!)
この違いは、アプリケーションの設計や状態管理(State管理)を行う上で非常に重要です。ESMを使うことで、モジュール間で「生きた状態」を安全に共有できるようになりました。
—
4. 陥りがちな文法エラーとデバッグの極意
初学者の開発現場で本当によくあるトラブルを2つご紹介します。ここを知っておくだけで、無駄なエラーに悩まされる時間を何時間も節約できますよ。
トラブル①:ESMで CommonJS のように動かそうとしてエラーになる
ESM環境で、つい慣れ親しんだ `require` や `module.exports` を書いてしまうミスです。
// エラーになる例 (ESM環境下)
const fs = require(‘fs’); // ReferenceError: require is not defined
【解決策】
Node.jsのESM環境では、必ず `import` を使いましょう。
import fs from ‘node:fs’;
(※ Node.jsの標準モジュールを読み込む際は、明示的に `node:` プレフィックスをつけるのが現代のベストプラクティスです)
トラブル②:ESMでインポートした変数を直接書き換えようとする
import { count } from ‘./counter.mjs’;
// やってはいけない!
count = 10; // TypeError: Assignment to constant variable.
ESMで `import` した変数は、読み取り専用(Read-only)のバインディングとして扱われます。
「ライブバインディング=外から書き換えられる」と勘違いしやすいのですが、あくまで「モジュール内部で起きた変更が外から見える」のであって、外側から勝手に元の変数を書き換えてよいわけではありません。変数を変更したい場合は、必ずエクスポートされた「関数(セッター)」経由で行う必要があります。
—
5. まとめ:今日から使えるアーキテクチャの視点
今回は、CommonJSとESMにおける変数の見え方の違いを、Node.jsの内部実装やメモリの仕組みを交えて解説しました。
- CommonJS(`require`)
- 変数は値の「コピー(スナップショット)」として渡される。
- 柔軟だが、モジュール間の状態の同期には工夫が必要。
- ESM(`import` / `export`)
- 変数は「ライブバインディング(参照の紐付け)」として共有される。
- モジュール内部の変化がリアルタイムに伝わり、モダンで堅牢な設計が可能。
JavaScriptのランタイムやV8エンジンが裏側でどうメモリを扱い、どうスコープを管理しているのかを意識できるようになると、コードを書く手が一段と楽しく、そして確実になります。
ここをクリアしたあなたなら、もうモジュールのスコープで迷うことはありません。自信を持って、次のモダンなコードを書き進めていきましょう!