JavaScript上級 #7 モジュールシステムの深掘り
基礎 #7 モジュール でES Modulesの使い方を見ました。今回の記事はその内側 — どう動作するか、CommonJSとどこが違うか、循環参照で何が起こるかを整理します。JavaScript上級シリーズの最後の記事です。
二つのモジュールシステム — ESM vs CommonJS #
JavaScriptエコシステムには二つのモジュールシステムが共存します。
| ES Modules (ESM) | CommonJS (CJS) | |
|---|---|---|
| 文法 | import / export | require / module.exports |
| ロード | 静的 (パース時) | 動的 (実行時) |
| 実行 | 非同期 | 同期 |
| 用途 | ブラウザ、モダンNode | 古いNode |
| 標準 | ECMAScript | Node 自体 |
ESMはモダンなJavaScriptの標準ですが、Nodeのエコシステムは長らくCommonJSでした。その名残が今も残っており、二つのシステムが相互作用するときに微妙な落とし穴があります。
CommonJS — 古いNodeのモジュール #
// math.js
function add(a, b) { return a + b; }
module.exports = { add };
// main.js
const { add } = require('./math');
add(2, 3); // 5
require は関数呼び出しです。実行時に同期的にモジュールを読み込んで実行し、module.exports を返します。
if (someCondition) {
const lib = require('./optional'); // 条件に応じてロード
}これが可能なのは同期 + 動的だからこそ — コードのどこからでも、どんな条件でも呼び出せます。
ESM — 静的 + 非同期 #
// math.js
export function add(a, b) { return a + b; }
// main.js
import { add } from './math.js';
add(2, 3); // 5
import は関数ではなく宣言です。実行時の動作ではなくパース時に解釈されます。
静的という意味 #
if (someCondition) {
import { lib } from './optional.js'; // ✗ SyntaxError
}import 宣言はモジュール最上位にのみ、条件付きでは使えません。
条件付きロードが必要なら — 基礎 #7 で見た dynamic import が答えです。
if (someCondition) {
const { lib } = await import('./optional.js');
}これは関数呼び出しの形なのでどこからでも可能です。Promiseを返します。
ESM の二つのステージ — パース + 実行 #
ESMモジュールがロードされるとき、次の順序で処理が進みます。
- パース —
importを読み込んで依存グラフを構築 - リンキング — すべてのモジュールのexportを対応するimportに接続
- 実行 — 上から順にコードを実行
この分離のおかげで import がホイスティングされます。
console.log(add(2, 3)); // OK — import がホイスティングされる
import { add } from './math.js';CommonJSの require は関数呼び出しなので — 呼ばれる前にはモジュールがロードされません。ESMはパース時にすべてのimportが処理されるので、コードの位置に関係なくモジュールが常に準備されています。
Live Binding — ESM の export は生きている #
ESMの最大の違いの一つ。import は値のコピーではなく、生きている参照です。
export let count = 0;
export function increment() {
count++;
}import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 ← export された値が更新される
count は0のコピーではなく、counter.jsの中のcount変数に対する生きている参照です。counterの中で更新されるとmainでも見えます。
CommonJSはこのようには動作しません。
let count = 0;
function increment() {
count++;
}
module.exports = { count, increment };const { count, increment } = require('./counter');
console.log(count); // 0
increment();
console.log(count); // 0 ← require 時点の値が固定されている
CJSは module.exports オブジェクトがexportされた時点でその時点の値がデストラクチャリングでコピーされます。以降の変化は見えません。
この違いが二つのシステムのコードを混ぜるときに微妙なバグを生みます。
モジュールは一度だけ実行される #
同じモジュールを複数の箇所からimportしても一度だけ実行されます。
console.log('logger モジュールロード');
export const logger = { log: (m) => console.log(m) };// a.js
import { logger } from './logger.js';
// b.js
import { logger } from './logger.js';
// main.js
import './a.js';
import './b.js';
// 'logger モジュールロード' が一度だけ出力される
この動作がモジュール内シングルトンパターンの基盤です。一度作ったオブジェクトを複数の箇所で共有するときに安全に動作します。
循環参照 — 二つのモジュールが互いに import #
二つのモジュールが互いを参照するとどうなるでしょうか。
import { b } from './b.js';
export const a = 'A';
console.log('a.js ロード:', b);import { a } from './a.js';
export const b = 'B';
console.log('b.js ロード:', a);ESMはこのような循環を処理できるよう設計されています。ただし時点によって値が空のことがあります。
a.js ロード: B ← b が先に終わったので値がある
b.js ロード: undefined ← a の export がまだ評価されていないESMの動作ステージを順に追うと次のようになります。
- main が a.js を import — a.js のパース開始
- a.js が b.js を import — b.js のパース開始
- b.js が a.js を import — すでにロード中なのでそのまま進む (a の binding は空)
- b.js の本文実行 —
console.log('b.js ロード:', a)— a がまだ undefined - b.js 終了、a.js の本文実行 — b はすでに ‘B’
ここが核心です。循環参照時、importした変数は生きている参照なので後で埋まることがあるが、モジュール本文の実行時点では空のことがある。
この落とし穴を避ける最も良い方法は循環参照を作らないことです。発生したら依存構造を見直してください。一つのモジュールを二つに分けるか、共通の依存を抜き出すのが普通の答えです。
CommonJS の循環参照 — より危険 #
CJSは動作自体が違います。
// a.js
const { b } = require('./b.js');
console.log('a.js:', b);
module.exports.a = 'A';
// b.js
const { a } = require('./a.js');
console.log('b.js:', a);
module.exports.b = 'B';CJSの require はモジュールが部分的に実行された状態でも、そこまでの module.exports を返します。
b.js: undefined ← a.js が最初の行までしか実行されず module.exports が空
a.js: { b: 'B' }ESMにも似た落とし穴がありますが、CJSは同期実行なので目に見える値のレベルで食い違います。ESMのlive bindingが微妙により友好的です。
Tree Shaking — ESM だけの利点 #
バンドラ (Vite、webpack、Rollup) が使われていないexportを成果物から取り除く最適化。静的解析が可能なESMでのみよく動作します。
// math.js
export function add() { /* ... */ }
export function subtract() { /* ... */ }
export function multiply() { /* ... */ }
// main.js
import { add } from './math.js';
add(2, 3);
// ビルド結果から subtract、multiply が除外される
CJSは require が動的なので、どのexportが実際に使われるかをビルド時に把握しづらいです。ESMで書かれたライブラリがバンドルサイズに友好的な理由です。
Node の ESM/CJS 混用 #
Node 18+ では二つのシステムをどちらもサポートしますが、一つのプロジェクト内で混ぜると微妙です。
package.json の type フィールド
#
{
"type": "module"
}type: "module" なら .js がESMとして解釈されます。なければCJS。
拡張子で区別 #
.mjs— 常に ESM.cjs— 常に CJS.js—package.jsonのtypeに従う
一方から他方を import #
ESMからCJSモジュールをimportするのはほぼ可能です (default import)。
import lodash from 'lodash'; // CJS パッケージ
CJSからESMを使うのはより面倒です — 通常dynamic importが必要です。
async function load() {
const { default: chalk } = await import('chalk');
}chalk のようなESM-onlyのライブラリが増えて、古いCJSコードベースがしばしば詰まる場面です。
import.meta — モジュール自身の情報 #
ESMモジュールは自分自身の情報を持ちます。
console.log(import.meta.url); // モジュール自身の URL
// Vite のような環境
console.log(import.meta.env); // 環境変数
CJSには __filename、__dirname がありますが — ESMでは import.meta.url で同じ情報を作って使います。
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);Node 20+ では import.meta.dirname、import.meta.filename を直接提供します。
まとめ #
今回の記事で整理した内容。
- ESMは静的 + 非同期、CJSは動的 + 同期
- ESMのimportはホイスティングされる (パース時に処理)
- ESMはlive binding — exportが更新されればimportにも見える
- CJSはrequire時点の値をコピー
- 同じモジュールは一度だけ実行 (シングルトン基盤)
- 循環参照 — ESMも時点によって空のことがある、可能なら避ける
- Tree shakingはESMの静的構造のおかげで可能
- Nodeで二つのシステム混用 —
type: "module"/.mjs/.cjs import.metaでモジュール自身の情報
上級シリーズを終えて #
7編で扱ったJavaScriptの内部動作を振り返ります。
- クロージャとスコープ — 関数が環境を引きずる (#1)
- this バインディング — 呼び出し方が決定 (#2)
- プロトタイプチェーン — クラスの本当の正体 (#3)
- イベントループ — マイクロ vs マクロタスク (#4)
- メモリモデル — 到達可能性とGC (#5)
- SymbolとProxy — メタプログラミングツール (#6)
- モジュールシステム — ESM vs CJS、循環参照 (今回)
ここまで掴めば — JavaScriptがなぜそう動作するかについてのほぼすべての質問に答えられるようになります。次の実践シリーズでは、これらすべてのツールを束ねてバニラJavaScriptで動的なWebページを作る場面へ向かいます。ライブラリなしでDOM・イベント・fetch・ローカルストレージを直接扱いながら、小さなアプリを一つビルドします。