Skip to content

モジュール

ES モジュールは、importexport によってファイルどうしを静的に結び付ける仕組みです。<script> の逐次読み込みや関数呼び出しによる動的な読み込みと違い、モジュールの依存関係はコードを実行する前に構文から確定できます。この静的な性質があるからこそ、エンジンは「どのファイルが必要か」をコードを一行も走らせないうちに把握し、名前の対応付けを実行前に済ませ、循環した依存すら破綻なく扱えます。この章では、モジュールの処理を 解決・読み込み / リンク / 評価 の三フェーズに分ける設計と、その分離が生む live binding・循環参照・トップレベル await といった挙動を、エンジン内部でどう実現するかという観点から扱います。最後に、モジュールのコンパイルで陥りやすい、束縛スロットの生成順序にまつわる罠を取り上げます。

なぜ三フェーズに分けるのか

一つのモジュールを動かすには、少なくとも次のことをしなければなりません。

  • そのモジュールが import している他のモジュールをすべて集める(依存グラフの構築)。
  • 各モジュールの import を、対応する export 元の実体に結び付ける(名前の解決)。
  • 各モジュールの本体コードを、依存されている側から順に実行する。

素朴には「本体を実行しながら、import に出会うたびにその場で対象を読み込んで実行する」という設計も考えられます。しかしこれには致命的な弱点があります。第一に、循環した依存があると「相手を実行し終えるまで自分が進めない」状態が両者で起き、デッドロックに陥ります。第二に、実行を始めてからでないと必要なモジュールが分からないため、読み込みエラーやエクスポート名の不一致といった問題が、コードが半分走った後になって初めて発覚します。

ES モジュールはこれを避けるため、処理を三つのフェーズに明確に分離します。各フェーズは「グラフ全体に対して」順に適用され、前のフェーズが完了してから次に進みます。

flowchart TD
    A["フェーズ1: 解決・読み込み<br/>import をたどってグラフ全体を集める"] --> B["フェーズ2: リンク<br/>各モジュールの環境と束縛スロットを作り<br/>import を export 元へ接続する"]
    B --> C["フェーズ3: 評価<br/>依存順に本体を一度ずつ実行する"]

分離の要点は、リンクが評価に先行することです。リンクを終えた時点で、グラフ内のすべてのモジュールについて、環境(束縛を保持する入れ物)とすべての束縛スロットが用意され、importexport の対応が接続済みになっています。まだ本体は一行も実行していないので、スロットの中身(値)は空(未初期化)ですが、スロットという「箱」は全部そろっている状態です。この「箱は先にすべて用意し、中身は後から順に埋める」という順序こそが、循環参照を破綻させずに扱える鍵になります(後述)。

三フェーズの入出力を整理すると次のようになります。

フェーズ 入力 主な処理 出力
解決・読み込み エントリモジュールの指定子 import を静的に走査し、指定子を解決して各モジュールソースを取得・パースし、依存を再帰的にたどる すべてのモジュールを含む依存グラフ
リンク 依存グラフ 各モジュールの環境を生成し束縛スロットを確保、各 importexport 元のスロットへ接続、エクスポート名の整合を検証 すべての束縛が接続済み(値は未初期化)のグラフ
評価 リンク済みグラフ 依存されている側から順に、各本体を一度だけ実行しスロットに値を格納 実行済みで値の埋まったグラフ

フェーズ1: 解決・読み込み

最初のフェーズは、エントリとなるモジュールから出発し、その import 宣言に書かれたモジュール指定子("./a.js" のような文字列)を手がかりに、必要なモジュールを再帰的に集めることです。

ここで効いてくるのが、import / export構文的にトップレベルにしか書けない静的な宣言であるという言語設計です。指定子は必ず文字列リテラルで、実行時の計算結果ではありません。そのためエンジンは、モジュールをパースした直後、本体を実行する前に、そのモジュールの依存先の一覧を完全に確定できます。依存先をパースすれば、そのまた依存先が分かる。これを到達できるモジュールがなくなるまで繰り返すと、依存グラフ全体が手に入ります。

指定子から実際のモジュール実体への解決(ファイルパスの解決や取得)は、エンジン本体ではなくホスト環境(ブラウザ・サーバランタイムなど)の責務です。エンジンは「この指定子を解決して読み込んでほしい」とホストに要求し、ホストが返したモジュールソースを受け取ってパースします。同じ指定子が複数箇所から import されても、実体は一度だけ読み込んでキャッシュし、グラフ内で共有します。これにより、後のフェーズで「同じモジュールを二度評価する」ことが原理的に起きません。

なお、import(...) 式による動的インポートは、この静的な読み込みとは別枠です。動的インポートは実行時に指定子を評価してグラフの読み込みを起動し、結果を Promise で返します。静的な import 宣言がグラフを事前確定させるのに対し、動的インポートは評価フェーズの途中から新たな読み込みを始められる非同期の入り口だと捉えると整理しやすくなります。

フェーズ2: リンク

グラフがそろったら、次は各モジュールの環境を作り、名前を接続します。ここが ES モジュール実装の中核です。

各モジュールは、自分のトップレベル束縛(import した名前・export する変数や関数・モジュール内のローカル変数)を保持する環境を一つ持ちます。リンクフェーズでは、この環境を生成し、束縛ごとにスロットを確保します。重要なのは、import した名前のスロットを、値のコピーではなく、エクスポート元モジュールの該当スロットへの間接参照として設定することです。

たとえばモジュール B が import { x } from "./a.js" と書いているなら、リンク時に B の環境の x は、A の環境にある x のスロットを指すように接続されます。この時点では A も B もまだ実行されておらず、A の x には値が入っていません。それでも「B の x は A の x のことだ」という対応関係だけは確定します。

この接続を張る際、エクスポート名の解決を行います。export { x } のような直接のエクスポートに加え、export { y } from "./c.js" のような再エクスポートや export * from ... を経由する場合、エンジンはエクスポート名をたどってスロットの本当の持ち主を突き止めます。この解決の過程で、存在しない名前を import している、あるいは export * の連鎖で同じ名前が複数元から曖昧に供給されている、といった不整合はここで検出され、評価に入る前にエラーになります。「本体を実行してみたら名前が無かった」ではなく、リンク時に静的に弾けるのが利点です。

リンクはグラフを深さ優先でたどり、依存先を先にリンクしてから自分をリンクします。循環がある場合でも、既にリンク中(処理中)のモジュールに再び到達したら、そのモジュールの環境は既に生成済みなので接続対象として使えます。ここでも「箱は先に、中身は後で」の原則が効いています。

live binding をどう実装するか

import された名前が「値のコピー」ではなく「エクスポート元の束縛そのものへの生きたビュー」である、という性質を live binding と呼びます。エクスポート元がその変数を後から書き換えれば、import 側が次にその名前を読んだときには新しい値が見える。これは CommonJS の require が返り値オブジェクトのプロパティ値をコピーで受け取るのとは決定的に異なる挙動です。

live binding は、リンク時に張った「スロットへの間接参照」によって自然に実現されます。import した名前を読む操作は、自分の環境のスロットを読むのではなく、接続先(エクスポート元)のスロットを読む操作にコンパイルされます。エクスポート元が変数を書き換えれば、書き換わるのはそのスロット自身なので、import 側は同じスロットを読み直すだけで最新値を得ます。

// エクスポート元 A
export let count = 0;
export function inc() { count++; }   // A 自身のスロット count を書き換える

// インポート側 B
import { count, inc } from "./a.js";
console.log(count);   // A のスロットを読む → 0
inc();                // A のスロット count が 1 になる
console.log(count);   // 再び A のスロットを読む → 1(コピーではないので更新が見える)

もしこれが値コピーであれば、Bcount は最初に読んだ 0 のまま固定され、inc() の効果は見えません。live binding とコピーの違いを整理すると次のようになります。

観点 live binding(ES モジュール) 値コピー(素朴な受け渡し)
import した名前の実体 エクスポート元スロットへの間接参照 受け取った時点の値の複製
元の変数が後で変わったら 次に読むと新しい値が見える 変わらない(複製したまま)
import 側からの再代入 不可(読み取り専用の束縛) 可(自分の複製なので)
循環参照との相性 良い(未初期化でも箱は接続済み) 悪い(コピー時点の値に依存)
実装 スロットへの間接読み取りへコンパイル 単純な代入

なお import した束縛はインポート側からは再代入できない読み取り専用です。これは「複数のインポート元が同じ変数を勝手に書き換える」混乱を避けるための制約で、書き換えられるのはエクスポート元だけです。

フェーズ3: 評価

すべての接続が済んだら、最後に本体コードを実行します。評価もリンクと同じく依存グラフを深さ優先でたどり、依存されている側(葉に近い側)から順に、各モジュールの本体を一度だけ実行します。実行によって、それまで空だった束縛スロットに値が格納されていきます。

「一度だけ」を保証するために、各モジュールは評価済みかどうかの状態を持ちます。共有されたモジュールが複数箇所から依存されていても、最初にたどり着いたときに一度評価し、以降は評価済みとしてスキップします。これにより、グラフ上で何回参照されようとモジュール本体の副作用は一回しか起きません。

依存順に評価するのは、あるモジュールの本体が走るときには、そのモジュールが import しているスロットには既に値が入っていてほしいからです。B が A に依存するなら A を先に評価しておけば、B のトップレベルコードが import した名前を使う時点で、その値は初期化済みです。循環がない限り、この順序は常に成立します。

トップレベル await

モジュール本体は、関数で包まなくても直接 await を書けます(トップレベル await)。これは表現力を上げる一方、評価フェーズの前提を一つ崩します。await を含むモジュールの本体は、途中で中断して後で再開する非同期な処理になるため、「本体を実行する」= 同期的に最後まで走り切る、とは限らなくなるのです。

評価フェーズはこれを織り込んで設計する必要があります。あるモジュールがトップレベル await で中断すると、そのモジュールに依存する側の評価も、依存先の完了を待ってから進めなければなりません。実装としては、各モジュールの評価を Promise 的な完了通知と結び付け、依存グラフ上で「依存先すべてが完了したら自分の残りを進める」という非同期の順序制御を行います。グラフの評価全体も一つの Promise を返し、すべてのモジュールが評価し終わったときに解決します。

具体的には、非同期モジュール(自身がトップレベル await を持つ、または依存先に非同期モジュールを含む)は、同期的に走れる区間だけを進めていったん中断し、待っている await が解決したら続きを再開します。この「どこまで進んだか」の管理と、「自分を待っている親モジュールへの完了通知」の伝播が、非同期評価の実装上の核心です。同期的なモジュールだけのグラフなら、この機構は単に「即座に完了する Promise」として振る舞い、従来どおりの深さ優先の同期評価に一致します。

トップレベル await の導入で、評価フェーズは「同期的な深さ優先実行」から「依存関係を尊重した非同期のスケジューリング」へと一般化された、と捉えると全体像がつかめます。マイクロタスクとの関係は前章「イベントループとマイクロタスク」の枠組みの上に載ります。

循環参照

モジュール A が B を import し、B も A を import する — こうした循環は、大きなコードベースでは意図せず生じます。三フェーズの分離は、この循環の多くを破綻なく成立させます。

鍵は繰り返し述べてきた「リンクで箱を全部作ってから評価する」順序です。リンクフェーズはグラフ全体を対象にするので、循環していても A と B の環境・スロット・相互の接続はすべて評価前に完成します。したがって評価フェーズで A の本体が「B からインポートした名前」に触れるとき、その名前のスロット自体は必ず存在します。

危ういのは中身です。循環があると、評価順をどう選んでも「相手をまだ評価し終える前に自分が動き出す」瞬間が必ず生じます。たとえば A → B の順で評価が始まり、B の本体が A のある名前を使おうとしたとき、その名前が A のまだ実行されていない行で初期化される変数だと、スロットは存在するが値が未初期化、という状態に出くわします。

この「未初期化スロットの読み取り」への反応は、束縛の種類で分かれます。

  • let / const で宣言されエクスポートされる束縛は、初期化前に読むと TDZ(Temporal Dead Zone)エラーになります。スロットは接続済みでも中身が入る前だからです。これは通常の関数内 let の TDZ と同じ仕組みが、モジュール境界をまたいで働くものです。
  • function 宣言でエクスポートされる関数は、本体の実行開始時に巻き上げ(hoisting)られて初期化されるため、循環相手から早い段階で呼んでも既に使えることが多い。循環するモジュール群が互いの関数を呼び合う設計が比較的うまくいくのはこのためです。

つまり循環そのものは壊れず、「初期化が済む前にその値へアクセスする」ことだけが危険、という整理になります。実務上は、循環相手からトップレベルで即座に相手の値を使うのを避け、関数の中(=実際に呼ばれる、より遅いタイミング)で参照するようにすると、初期化の順序ずれを回避できます。live binding であることもここで効きます。最初は未初期化でも、後で相手の初期化が済めば、同じスロットを読み直すだけで正しい値が見えるからです。

モジュール名前空間オブジェクト

import * as ns from "./a.js" と書くと、ns は A のすべてのエクスポートをプロパティとして持つモジュール名前空間オブジェクトになります。これは通常のオブジェクトに見えて、いくつかの点で特殊(exotic)な振る舞いをします。

  • プロパティは live binding: ns.x の読み取りは、通常のプロパティアクセスではなく、A の x スロットへの間接読み取りとして働きます。A が x を書き換えれば ns.x も変わります。名前空間オブジェクトは値のスナップショットではなく、エクスポート集合への生きたビューです。
  • 書き換え不可: 名前空間オブジェクトのプロパティへの代入や、プロパティの追加・削除はできません。エクスポートされた束縛は読み取り専用で、その集合も固定です。
  • キーの順序が規定される: エクスポート名は文字列キーとしてソートされた順で列挙されます。通常のオブジェクトの「挿入順」とは異なる、決定的な順序です。
  • プロトタイプは null: 通常のオブジェクトのようなプロトタイプチェーンを持ちません。
  • Symbol.toStringTag"Module": 名前空間オブジェクトであることを示す固定のタグを持ちます。

エンジンから見れば、名前空間オブジェクトは「エクスポートスロット群への読み取り専用ビューを、オブジェクトの外観で提供する特殊オブジェクト」です。各プロパティ取得を対応するスロット読み取りに委譲し、存在しないキーには undefined ではなく「無い」として振る舞い、変更操作はすべて失敗させる、という独自の内部メソッドで実装します。

実装上の罠: 束縛スロットの生成順序

モジュールのコンパイルには、実装者を高い確率で捕まえる、順序に起因する罠があります。import した名前の束縛スロットを、その名前を参照しうるコードをコンパイルする前に、モジュールのスコープへ確立しておく」ことを怠ると、参照解決が壊れます。

問題の構図はこうです。モジュールのトップレベルには、import 宣言のほかに、巻き上げられる function 宣言が同居できます。エンジンがモジュールをコンパイルするとき、名前の参照(識別子)を「どのスコープのどのスロットを指すか」に解決します。もし巻き上げ対象の関数宣言を先にコンパイルし、その本体内の識別子参照を、まだ import 束縛がスコープに登録されていない時点で解決してしまうと、その関数が import した名前を参照していた場合、コンパイラはそれをモジュールスコープに見つけられません。行き場を失った参照は、多くの実装でグローバル探索へフォールバックし、実行時に「その名前は定義されていない」というエラーを投げます。

厄介なのは、同じ名前を参照していても、書き方によって症状が出たり出なかったりすることです。

  • 巻き上げられる function 宣言の中から import 名を参照 → コンパイル順序次第で解決に失敗しうる(罠が顕在化する)。
  • トップレベルの直書きコードや、アロー関数式の中から同じ import 名を参照 → こちらは import 束縛が確立されたに処理されるため、正しく解決される。

つまり「アロー関数だと動くのに、同じ内容を関数宣言に書き直すと落ちる」という、一見不可解な現象として表面化します。原因は言語仕様ではなく、コンパイラ内部で「束縛スロットを作る処理」と「そのスロットを参照するコードを解決する処理」の順序が逆転していることにあります。

(誤った順序)
1. function 宣言を巻き上げてコンパイル
     → 本体内の import 名の参照を解決 …… まだ import 束縛が無い!
       → グローバルへフォールバック → 実行時に未定義エラー
2. import 束縛をスコープへ登録   ← 遅すぎる

(正しい順序)
1. import 束縛スロットをモジュールスコープへ先に確立
2. その後で function 宣言・トップレベルコードをコンパイル
     → import 名の参照が正しくスロットへ解決される

根本対策は、モジュールをコンパイルする際、何かを参照しうるコードに手を付ける前に、import によって導入される束縛(と、その他のトップレベル束縛)をスコープへ先に宣言してしまうことです。リンクフェーズが「箱を先に全部作る」のと同じ発想を、一つのモジュール内のコンパイル順序にも適用する、と言い換えられます。スロットという受け皿が先に存在すれば、後からコンパイルされる関数宣言・トップレベルコード・アロー関数のいずれから参照されても、一貫して同じスロットへ解決されます。

この罠が根深いのは、テストのすり抜けやすさにあります。多くのモジュールでは import 名をトップレベルやアロー関数から使うため正しく動いてしまい、「巻き上げ関数宣言の中から import 名を参照する」という特定の組み合わせでのみ露見します。準拠性テストでも、この形を突く項目に当たるまで発覚しないことがあります。対策としては、コンパイル時のスコープ確立順序を設計として固定し、「参照解決より前にすべてのトップレベル束縛を登録する」ことを不変条件として守るのが確実です。

まとめ

  • ES モジュールの処理は 解決・読み込み / リンク / 評価 の三フェーズに分離される。import が静的宣言であるため、依存グラフは実行前に確定できる。
  • リンクが評価に先行するのが要。リンクで全モジュールの環境・束縛スロット・importexport の接続を作り(箱を先に用意し)、評価で本体を依存順に一度ずつ実行して中身を埋める。
  • live binding は、import した名前をエクスポート元スロットへの間接読み取りとして実装することで実現される。値コピーと違い、元の変更が後から見え、import 側からは再代入できない。
  • 循環参照の多くは三フェーズ分離により成立する。スロットは評価前に必ず存在し、危ういのは「初期化前に中身を読む」ことだけ。let/const は TDZ エラー、function は巻き上げにより早期に使える。
  • トップレベル await は本体を非同期にし、評価フェーズを「依存順の同期実行」から「依存を尊重した非同期スケジューリング」へ一般化する。
  • モジュール名前空間オブジェクトは、エクスポート集合への読み取り専用の生きたビューで、書き換え不可・キーはソート順・プロトタイプ null など特殊な振る舞いを持つ。
  • コンパイルの罠として、import 束縛スロットは、それを参照しうるコード(特に巻き上げ function 宣言)より先にスコープへ確立しないと、参照がグローバルへフォールバックして未定義エラーになる。参照解決の前に全トップレベル束縛を登録することを不変条件にする。

参考文献