Dev Study
← データ構造とアルゴリズムの土台
レッスン 9 / 13·約 9 分·第11期 データ構造とアルゴリズムの土台

依存はグラフ — import が解決される形

アプリと DB の下に何が積まれているか(左が下、右が上)
  1. 物理機リージョン/AZ
  2. ハイパーバイザVM exit で捕まえる
  3. VMゲストOS
  4. コンテナnamespace / cgroup
  5. プロセスアプリ / 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つの理由からです。同じモジュールが複数から参照されれば合流し、互いに参照し合えば循環する。合流があるから訪問済みを覚える必要があり、循環があるから成分としてまとめる必要があります。

InnerModuleLinking が stack を使って辿る形(仕様のステップ番号)
step 2: 既に linking / linked なら即 return循環で無限に降りないための門番
そうでなければ奥へ降りる
step 4-8: status を linking にし、[[DFSAncestorIndex]] を置き、stack の末尾に append
step 9: 依存ごとに再帰
step 9.c.iii: 依存がまだ linking なら [[DFSAncestorIndex]] を min で引き下げる同じ循環の仲間だと分かる
戻ってきたところで
step 13: [[DFSAncestorIndex]] = moduleIndex なら、stack の末尾から自分まで取り出して linked にするSCC がまとめて linked へ
  • ・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 する菱形です。

$ node d_root.mjs(Docker node:24-alpine / Node v24.21.0 / --network=none / 1回実行)コマンド出力
=== 5. DFS evaluation order. graph: root -> mid1 -> leaf, root -> mid2 -> leaf ===
--- node d_root.mjs ---
EVAL: leaf
EVAL: mid1
EVAL: mid2
EVAL: root
EVAL: root body
exit=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 の値か関数宣言かだけです。

$ node main.mjs / $ node main2.mjs(Docker node:24-alpine / Node v24.21.0 / 各1回)コマンド出力
=== 2. ESM cycle: node main.mjs ===
b.mjs starting
file:///w/b.mjs:4
console.log('in b.mjs, aDone =', aDone);
^
 
ReferenceError: Cannot access 'aDone' before initialization
at file:///w/b.mjs:4:34
at ModuleJob.run (node:internal/modules/esm/module_job:561:25)
(以下略)
exit=1
 
=== 4. ESM cycle with function declarations (hoisted): node main2.mjs ===
b2 start
b2 calls aFn: a
b2 done
a2 start
a2 calls bFn: b
a2 done
main2 start
main2 end
exit=0
 
=== 1. CommonJS cycle: node main.js ===
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true
exit=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` が出たとき、原因の説明として正確なのはどれですか。

⇄ 2つを見比べる
具体例 A

ESM の循環: 止まる

Link 段で DFS が辿り、循環した A と B は SCC として一緒に linked になります。リンクは成功しているので、落ちるとすれば Evaluate 段です。初期化前の束縛を読んだ時点で `ReferenceError` になり、実機は exit=1 でした(Node v24.21.0)。

具体例 B

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 を呼びます。

$ esbuild --bundle entry.js(Docker node:24-alpine / esbuild 0.25.10 / 1回実行)コマンド出力
(() => {
// lib.js
function used() {
return "USED";
}
 
// entry.js
console.log(used());
})();

unusedA も unusedB も消えました。 unusedB が unusedA を呼ぶので unusedA には「呼び出し元がある」ように見えますが、unusedB 自体がどこからも到達されないため、両方まとめて到達不能になります。到達可能性は推移的です。仕様が Link を transitively resolving all module dependencies と書いたときの推移と同じ語です。

同じ形を CommonJS で書くと結果が変わります。

$ esbuild --bundle entry-cjs.js(同環境。抜粋)コマンド出力
var require_lib = __commonJS({
"lib.cjs"(exports) {
exports.used = function() {
return "USED";
};
exports.unusedA = function() {
return "UNUSED_A";
};
}
});
 
// entry-cjs.js
var { 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 が明記する。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

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 が落ちる原因はこちら。