TL

ESモジュールの解決・リンク・評価フェーズ

なぜ import した値が後から更新されても反映されるのか、循環依存でも壊れにくいのか。3フェーズに分けて追えば、ESモジュールの挙動が原理から腑に落ちます。

応用JavaScriptESモジュールモジュールブラウザ仕様最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.ESモジュールの読み込みは construction(解決とフェッチでグラフ構築)・instantiation(リンクで束縛を結線)・evaluation(本体を実行)の3フェーズに分かれ、いずれも import が静的に決まることが前提になっている。
  • 2.import した束縛は値のコピーではなくライブバインディング。エクスポート側で再代入すれば import 側も即座に新しい値を見る。const 同様、import 側からの再代入は不可。
  • 3.循環依存は instantiation で束縛だけ先に結線するため import の宣言自体は成立するが、評価順しだいで初期化前アクセス(一時的死角)になりうる。トップレベル await があるとグラフは非同期評価になる。

import は「実行前」に決まっている

ESモジュール(ESM)の最大の特徴は、依存関係が 静的 だということです。import 文はファイルの先頭でしか書けず、パスは文字列リテラルでなければならず、条件分岐の中に置くこともできません。これは制約ではなく設計です。コードを 1行も実行する前に 依存グラフ全体を確定できるからこそ、束縛の事前検証・循環の安全な扱い・並行フェッチが可能になります。基礎は JavaScript を前提に、本記事は仕様(ECMA-262 と HTML 統合仕様)が定める 3つのフェーズ を内部レベルで解きほぐします。

その3フェーズとは、construction(構築)・instantiation(リンク)・evaluation(評価)です。この順序は厳密で、後戻りしません。各フェーズが何を保証し、何を保証しないかを押さえることが、ライブバインディングや循環依存の挙動を読み解く鍵になります。

フェーズやること実行されるコード結果として得るもの
1. Construction解決(resolve)とフェッチでモジュールグラフを構築なし全モジュールの記録(Module Record)
2. Instantiation束縛(binding)を結線しスコープを作るなしimport/export を繋いだライブバインディング
3. Evaluation各モジュール本体を依存順に1回だけ実行あり実際の値・副作用

フェーズ1:Construction(解決とグラフ構築)

最初のフェーズは、エントリポイントから出発して 到達可能な全モジュールを集める 作業です。手順はモジュールごとに「解決(module resolution)→ フェッチ → パース → そのモジュールの import を再帰的に解決」を繰り返します。

  • 解決import x from './a.js' の指定子(specifier)を、実際に取得できる URL やファイルパスへ変換する。ブラウザでは相対・絶対 URL と import maps、Node.js では package.jsonexports フィールドや拡張子解決が関わる。
  • フェッチ&パース:取得したソースを モジュールゴール としてパースする。この時点で構文エラーや、同名 import の重複といった そのモジュール単体で判定できる早期エラー が検出される。一方、参照先に実在するエクスポートがあるかの照合は相手モジュールが必要なので、次のリンク段階に持ち越される。
  • 重複排除:同じ解決済みキーのモジュールは モジュールマップ に登録され、二度フェッチ・二度パースされない。これが後述する「循環してもループしない」理由の土台です。

このフェーズで得られるのは Module Record(各モジュールの依存リスト・import/export エントリを持つ内部レコード)の集合であり、それらが辺で結ばれた 有向グラフ です。コードはまだ1行も走りません。

グラフのキーは“解決後”の識別子

重複排除の基準は「ソースに書いた文字列」ではなく 解決後の正規化済みキー(多くは絶対 URL)です。./a.js../dir/a.js が同じファイルを指すなら、グラフ上では1ノードに統合されます。だからモジュールのトップレベルは、どこから何回 import されても 正味1回だけ 評価されます。

フェーズ2:Instantiation(リンクと束縛の結線)

グラフが揃ったら、次は リンク(linking/instantiation) です。ここでも本体コードは実行されませんが、各モジュールに モジュール環境レコード(スコープ) を作り、importexport結線 します。

ポイントは、import が 値ではなく束縛(binding)への参照 として繋がれることです。import { count } from './counter.js' は、counter.jscount という束縛そのものを指すスロットを作ります。値のコピーは取りません。この「束縛を直接指す」仕組みが、次節の ライブバインディング の正体です。

リンクは深さ優先で進み、循環があってもループしません。すでに instantiate 済みのモジュールに再到達したら、その束縛をそのまま繋ぐだけだからです。全モジュールの全 import 束縛が結線され終わって初めて、フェーズ3へ進みます。

export していない名前を import するとここで失敗

存在しないエクスポートの import は、評価より前のこのリンク段階でエラー になります(実行時例外ではない)。export 側にその名前がなければ束縛を結線できないからです。これが ESM と CommonJS の決定的な差で、require() は実行時にオブジェクトのプロパティを引くため、タイポは undefined として黙って通ってしまいます。

ライブバインディング:コピーではなく「窓」

ESM の import は、エクスポート元の変数を 覗く窓 です。エクスポート側で値が変われば、import 側が次に読んだとき 新しい値が見えます

// counter.js
export let count = 0;
export function inc() { count++; }   // モジュール内で再代入

// main.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 ← コピーなら 0 のまま。ライブバインディングだから 1

CommonJS の const { count } = require('./counter')その瞬間の値をコピー するので、後から inc() しても count は 0 のままです。ESM は束縛そのものを共有するため、再代入が透過的に伝わります。

import した束縛は読み取り専用

ライブバインディングは「エクスポート側の更新が伝わる」一方通行です。import 側から count = 5 のように 再代入すると構文/実行エラー になります。import された束縛は const 相当の不変参照であり、書き換える権利を持つのは その値を export したモジュールだけ です。状態を共有したいなら、値ではなくオブジェクトを export し、そのプロパティを更新する設計にします。

フェーズ3:Evaluation と循環依存

最後のフェーズで、ようやく各モジュールの 本体が依存順(おおむね深さ優先のポストオーダー)で1回だけ実行 されます。依存先が先に評価され、その副作用と値が確定してから依存元が走るのが基本です。

問題は 循環依存 のときです。A が B を import し、B も A を import する場合、評価順のどこかで「まだ評価が終わっていないモジュールの束縛」を読むことが起こりえます。束縛は instantiation で結線済みなので 参照は存在 しますが、その変数の初期化(=トップレベルの代入)がまだ走っていなければ、let/const一時的死角(TDZ) に当たって ReferenceError になります。

// a.js
import { b } from './b.js';
export const a = 'A';
console.log('a.js が見る b =', b);   // b.js が先に評価済みなら 'B'

// b.js
import { a } from './a.js';
export const b = 'B';
console.log('b.js が見る a =', a);   // ❌ a はまだ未初期化 → ReferenceError (TDZ)

エントリが a.js の場合、評価は依存先 b.js から始まります。b.jsa を読む時点では a.js の本体はまだ走っておらず a は未初期化です。関数の中 で参照していれば、実際に呼ばれる頃には初期化が済んでいるため安全に動きます。循環自体は禁止ではなく、トップレベルで相手の値を即時に読むか、関数越しに遅延して読むか が成否を分けます。

循環を関数で“遅延”させる

循環が避けられないとき、相手のエクスポートを トップレベルで即評価しない のが定石です。import { x } from './m.js' した x を、モジュール読込時ではなく 関数が呼ばれた時点 で参照すれば、その頃には双方の初期化が完了しています。束縛は結線済みなので、遅延して読むだけで TDZ を回避できます。

トップレベル await があるとき

モジュール本体の最上位で await を書けるのが トップレベル await です。これがグラフに1つでもあると、evaluation フェーズは 非同期 になります。仕様上、トップレベル await を含むモジュール(および、それを依存に持つモジュール)は Promise を返す評価 として扱われ、その await が解決するまで 依存元の評価が待たされます

  • 待っている間も 他の独立した枝は並行して評価 を進められるため、無関係なモジュールまで一斉に止まるわけではありません。
  • 依存チェーン上では順序が保たれます。A が B(トップレベル await あり)に依存するなら、B の await 完了後に A 本体が走ることが保証されます。
  • 副作用として、トップレベル await を持つモジュールを依存に加えると、それを使う側の初期化完了タイミングが 観測可能に遅延 します。

トップレベル await はイベントループのマイクロタスクと連動して進みます。await の続きが マイクロタスクとして再開 される仕組みは イベントループの内部構造 と同じ原理で、モジュール評価もその上で動いていると理解すると整合します。

まとめ

まとめ

ESモジュールは construction(解決・フェッチでグラフ構築)→ instantiation(束縛を結線)→ evaluation(本体を依存順に1回実行) の3フェーズで読み込まれます。import が静的に決まるからグラフを事前確定でき、束縛は値コピーではなく ライブバインディング として共有され、エクスポート側の更新が透過的に伝わります(import 側からの再代入は不可)。循環依存 は束縛の結線まではフェーズ2で済むため参照は成立しますが、トップレベルで相手の値を即時に読むと TDZ に当たりうるので、関数越しの遅延参照で回避します。トップレベル await があるとグラフは非同期評価になり、依存順を保ったまま完了が遅延します。なぜ ESM がこの形に落ち着いたかは Web標準の策定プロセスと仕様の系統 を、束縛がエンジン内でどう最適化されるかは JavaScriptエンジンの内部 と合わせて読むと立体的に掴めます。

Web/フロントエンド Article

ESモジュールの解決・リンク・評価フェーズを実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

JavaScript

比較で見る軸

難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5

導入後に効く点

import した束縛は値のコピーではなくライブバインディング。エクスポート側で再代入すれば import 側も即座に新しい値を見る。const 同様、import 側からの再代入は不可。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
Web/フロントエンド
タグ数
5

判断チェックリスト

  • 自社の用途が「JavaScript / ESモジュール」に近いか確認する。
  • 強みである「ESモジュールの読み込みは construction(解決とフェッチでグラフ構築)・instantiation(リンクで束縛を結線)・evaluation(本体を実行)の3フェーズに分かれ、いずれも import が静的に決まることが前提になっている。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

JavaScriptESモジュールモジュールブラウザ仕様JavaScriptESモジュールモジュール
参考: 公式情報