- 物理機リージョン/AZ
- ハイパーバイザVM exit で捕まえる
- VMゲストOS
- コンテナnamespace / cgroup
- プロセスアプリ / DB今ここ
アプリ層『モジュール — import は何をしているか』は「依存グラフ全体を解決する」とだけ述べ、「この続きは次のレッスンで扱います」と締めていました。その解決の中身をグラフの言葉で開きます。
まず結論
import を書くと、コードが1行も走る前にモジュールの依存がグラフとして深さ優先で辿られます。ECMAScript 仕様は自分の言葉で depth-first search (DFS) traversal と書き、循環部分を strongly connected components (SCCs) と呼んでいます。
だから「ESM は循環依存でエラーになる」は誤りです。仕様は循環を許しており、実機でも関数宣言だけを跨ぐ循環は最後まで走り切ります。落ちるのは循環そのものではなく、初期化前の束縛を読んだときです。
a.mjs と b.mjs が互いに import し合う循環を作って node で実行すると、何が起きるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
仕様が自分で「深さ優先探索」と書いている
アプリ層『モジュール — import は何をしているか』では、ESM が評価の前に依存グラフ全体を解決することまで見ました。開いていないのはその解決が何をしているかです。
答えは仕様の本文にそのまま書かれています。引くのは ECMAScript® 2027 Language Specification, Draft ECMA-262 / September 8, 2026(以下、本稿の逐語はすべてこの版から)。
depth-first search (DFS) traversal と strongly connected components (SCCs)。どちらもグラフ理論の語で、比喩ではなく仕様の本文です。import を書いた瞬間に、あなたはグラフの頂点と辺を書いています。
依存が木ではなくグラフなのは2つの理由からです。同じモジュールが複数から参照されれば合流し、互いに参照し合えば循環する。合流があるから訪問済みを覚える必要があり、循環があるから成分としてまとめる必要があります。
- ・step 8 は `Append module to stack`、step 13.b は `Let requiredModule be the last element of stack / Remove the last element of stack`
- ・末尾に入れて末尾から取る = LIFO。前の回で見た取り出し規則が、仕様の条文で動いている
前の回で見た「LIFO で取り出すと深さ優先になる」が、仕様の中で実際に使われています。
奥から先に終わるのが、目で見える
巡回の形は実機の出力に出ます。root が mid1 と mid2 を、両方が leaf を import する菱形です。
=== 5. DFS evaluation order. graph: root -> mid1 -> leaf, root -> mid2 -> leaf ===--- node d_root.mjs ---EVAL: leafEVAL: mid1EVAL: mid2EVAL: rootEVAL: root bodyexit=0
読み取れることが2つあります。順序が後行順です。root の本文が走る前に依存が全部終わっており、奥へ降りきってから戻りながら片付ける DFS の形が出ています。
もう1つ。EVAL: leaf が1回しか出ていません。 2箇所から import されているのに評価は1回。合流があるグラフでは、訪問済みを覚えないと同じ頂点を何度も処理します。仕様の step 2 と同型の門番が、評価側にも効いています。
ECMAScript 仕様が InnerModuleLinking の前書きで、依存の辿り方を表すのに使っている語はどれですか。
循環は禁止されていない。落ちるのは別の理由
循環があると素朴な再帰は無限に降ります。仕様の手当ては step 2 の1行、既に linking なら何もせず戻るです。
そのうえで仕様は、循環したモジュール群をまとめて扱うと決めています。16.2.1.6.2 Example Cyclic Module Record Graphs は、A と B が循環する例をこう説明します。
this is by design(これは設計上そうしている)。循環は事故ではなく、仕様が名前を付けて扱っている構造です。 型の名前自体が Cyclic Module Record で、can participate in dependency cycles と定義されています。
したがって 「ESM は循環依存でエラーになる」は誤りです。 では実機で ReferenceError を見た人は何を見たのか。
同じ循環が、落ちたり落ちなかったりする
同じ形の循環を2通り組みました。違うのは、跨いで読むものが export let の値か関数宣言かだけです。
=== 2. ESM cycle: node main.mjs ===b.mjs startingfile:///w/b.mjs:4console.log('in b.mjs, aDone =', aDone);^ReferenceError: Cannot access 'aDone' before initializationat file:///w/b.mjs:4:34at ModuleJob.run (node:internal/modules/esm/module_job:561:25)(以下略)exit=1=== 4. ESM cycle with function declarations (hoisted): node main2.mjs ===b2 startb2 calls aFn: ab2 donea2 starta2 calls bFn: ba2 donemain2 startmain2 endexit=0=== 1. CommonJS cycle: node main.js ===main startinga startingb startingin b, a.done = falseb donein a, b.done = truea donein main, a.done = true, b.done = trueexit=0
真ん中の実行では b2 calls aFn: a と、循環の相手の関数を正常に呼べています。 最後まで走り切って exit=0。リンクは成功しており、循環は弾かれていません。
上が落ちたのは、まだ初期化されていない束縛を読んだからです。Cannot access 'aDone' before initialization — 名前が無いのではなく、まだ値が入っていない。原因は TDZ(初期化前アクセス)です。なお b.mjs starting が先に出ている点も DFS の証拠です。
一番下は同じ循環を CommonJS で組んだもの(入力は Node.js v24 docs の Cycles 節のコードそのまま。出力は docs の予告と1バイト違わず一致)。未完成の姿を渡したまま、エラーも警告も出さず exit=0 です。
循環した ESM で `ReferenceError: Cannot access 'aDone' before initialization` が出たとき、原因の説明として正確なのはどれですか。
ESM の循環: 止まる
Link 段で DFS が辿り、循環した A と B は SCC として一緒に linked になります。リンクは成功しているので、落ちるとすれば Evaluate 段です。初期化前の束縛を読んだ時点で `ReferenceError` になり、実機は exit=1 でした(Node v24.21.0)。
CommonJS の循環: 止まらない
require に到達した時点で相手を読みますが、相手はまだ途中です。docs は `In order to prevent an infinite loop, an unfinished copy of the a.js exports object is returned to the b.js module.` と規定します。未完成のオブジェクトを受け取ったまま b は進み、exit=0 で終わりました。
同じ「互いに import し合う」構造なのに、片方は exit=1 で止まり、片方は exit=0 で最後まで走ります。どちらが安全でしょうか。
CommonJS 側の `in b, a.done = false` という行に注目してください。この時点で a は終わっていません。この値は、その後どこへ行くでしょうか。
到達可能性は推移的に伝わる
グラフがあると、もう1つ言えることがあります。ある出発点から辿り着けるかです。
automatically removes unreachable code — 到達不能なコードを取り除く。既習が引いたのは定義の1文目で、新しいのは unreachable の側です。条件節も落とせません。esbuild の実装は ESM に依存し、CommonJS では効きません。
芋づるで消える — 到達可能性が推移的であること
esbuild 0.25.10 で確かめます。lib.js に used / unusedA / unusedB の3関数を置き、entry.js は used だけを import。unusedB は unusedA を呼びます。
(() => {// lib.jsfunction used() {return "USED";}// entry.jsconsole.log(used());})();
unusedA も unusedB も消えました。 unusedB が unusedA を呼ぶので unusedA には「呼び出し元がある」ように見えますが、unusedB 自体がどこからも到達されないため、両方まとめて到達不能になります。到達可能性は推移的です。仕様が Link を transitively resolving all module dependencies と書いたときの推移と同じ語です。
同じ形を CommonJS で書くと結果が変わります。
var require_lib = __commonJS({"lib.cjs"(exports) {exports.used = function() {return "USED";};exports.unusedA = function() {return "UNUSED_A";};}});// entry-cjs.jsvar { used } = require_lib();console.log(used());
unusedA が丸ごと残り、中身は __commonJS というラッパー関数に包まれています。どの名前が使われるか実行するまで確定しないので、残して実行時に解決するしかないという形です。
この章のまとめ
importを書くことは、依存グラフの頂点と辺を書くこと。合流があるので木ではない。- ECMAScript 仕様は Link 段の巡回を
depth-first search (DFS) traversalと自ら書き、循環部分をstrongly connected components (SCCs)と呼ぶ(Draft ECMA-262 / September 8, 2026)。 - 仕様の
stackは末尾に append し末尾から remove する。前の回の LIFO が、条文の中で深さ優先を作っている。 - 「ESM は循環依存でエラーになる」は誤り。 関数宣言だけを跨ぐ循環は実機で exit=0。原因は循環ではなく TDZ(初期化前アクセス)。
- エラーは3段。名前不在=
SyntaxError(Link 段)/ 初期化前=ReferenceError(Evaluate 段)/ CommonJS の循環=エラーなし・exit=0。3つ目が一番危ない。 - tree shaking は到達可能性の判定で、到達不能は推移的に伝わる。ただし名の由来は依存グラフではなく構文木だと Rollup が明記する。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
InnerModuleLinking のステップは `Append module to stack` と `Let requiredModule be the last element of stack / Remove the last element of stack` を使います。この使い方は何ですか。
用語集(5語)
- 依存グラフ
- モジュールを頂点、import を辺とみなした有向グラフ。複数から参照されると木でなくなり、互いに参照し合うと循環ができる。
- 深さ優先探索 (DFS)
- 辿れるところまで奥へ降り、行き止まりで1つ戻る探索。ECMAScript 仕様は Link 段の巡回をこの語で説明する。
- 強連結成分 (SCC)
- 有向グラフで互いに行き来できる頂点の集まり。ESM では循環したモジュール群がこれで、仕様はその全体が一緒に linked へ移ると規定する。
- 到達可能性 (reachability)
- ある出発点から辺を辿って辿り着けるか。tree shaking は到達不能な宣言を落とす処理で、推移的に伝わる。
- TDZ(初期化前アクセス)
- 束縛は作られているが、まだ値が入っていない区間。ここを読むと `ReferenceError` になる。循環した ESM が落ちる原因はこちら。