Dev Study
← 1行のHTMLが画面に出るまで
レッスン 8 / 8·約 9 分·導線 1行のHTMLが画面に出るまで

JSが割り込む — <script> がパーサを止める理由

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力今ここ
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理
  7. DB保存

URLからピクセルまでの線はここが終点。イベントループとJITはブラウザ編で扱います。

まず結論

`async` も `defer` も無い外部の古典スクリプトは `document.write()` でパーサの入力ストリームを書き換えられるので、パーサは取得と評価が終わるまで先を読めません。仕様は `blocking parsing until these are both complete` と書いています。

この「止まる」は入力ストリームの話で、イベントループが止まる話ではありません。DOM を待つ根拠は `defer`・`DOMContentLoaded`・末尾配置などの仕様上の順序で、遅延の値ではありません。

? 読む前に予測

`<head>` の `<script>` から `document.getElementById("out")` を呼びます。`<body>` には `id="out"` の要素があります。何が返るでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

なぜ途中で止まらなければならないのか

6本目『バイト列がDOMになる — 閉じ忘れても表示される理由』で、パーサが文字を上から順に読んで木を組み立てることを見ました。その途中に <script> が現れると、パーサは読むのをやめて評価します。理由は入力を書き換えられるからです。

仕様はこの状態を次のように述べます。

If neither attribute is present, then the script is fetched and evaluated immediately, blocking parsing until these are both complete.

「parser-blocking スクリプト」は仕様の定義語ではありません。仕様の名前は Document の状態変数で、綴りも違います。

Each Document has a pending parsing-blocking script, which is a script element or null, initially null.

parsing-blocking であって parser-blocking ではない。一次ソースは綴りごと写すほうが安全です。

具体実物

head の script から getElementById を呼ぶと null になる

通し例題の index.html は <h1>Hello</h1> の1行だけでした。ここに要素と、<head> 相当の位置のスクリプトを足します。

パーサが上から読むときの、スクリプト評価時点での木追跡ログ
読んだところ そのとき木にあるもの
<html><head> html, head
<script>…</script> html, head, script ← ここで評価
</head><body> (未読)
<div id="out"></div> (未読)

評価されるのは3行目の時点です。<body> 以降は未読なので document.getElementById("out") は null を返します。「全部読んでからJSが動く」という順序ではありません。

属性を付ければ変わりますが(比較は後半の図)、仕様が3モードを並べる箇所の主語は src を持つ外部の古典スクリプトです。src の無いインラインには defer も async も効かず、分岐の末尾は Otherwise, immediately execute the script element el, even if other scripts are already executing. です。

複数あるときの順序も決まっています。defer 付きは「パース完了時に実行するスクリプトのリスト」に入って先頭から順に、async は「できるだけ早く実行するスクリプトの集合」に入り、各々が取得でき次第実行されます。集合に順序の保証はありません。defer は順序を保ち、async は保ちません。

✓ ここまでの確認

外部の古典スクリプト(`src` あり・`async` も `defer` も無し)について、仕様に合うのはどれですか。

抽象原理

「止まる」のは入力ストリームであって、イベントループではない

パーサが止まっている間、イベントループ自体は回っています。仕様が書いているのは、トークナイザを動かすタスクを走らせないという限定です。

Block the tokenizer for this instance of the HTML parser, such that the event loop will not run tasks that invoke the tokenizer.

取得待ちの表現も spin the event loop until … で、回したまま条件の成立を待ちます。止まるのは「入力ストリームを読み進める仕事」であって実行モデル全体ではありません。イベントループの構造は、ブラウザ編『イベントループ — タスクは集合、マイクロタスクは待ち行列』で扱います。

この区別が効くのが setTimeout で待つ修正です。setTimeout(init, 100) は timer task source にタスクを1つ積むだけで、パーサのタスクとの順序を仕様は決めません。

This API does not guarantee that timers will run exactly on schedule. Delays due to CPU load, other tasks, etc, are to be expected.

「最小4ms」も一律ではなく、If nestingLevel is greater than 5, and timeout is less than 4, then set timeout to 4. のときだけです。

defer と DOMContentLoaded の前後も、しばしば逆に説明されます。パース終了時の手順は、要素をすべて pop したあとで defer/module のリストを順に評価し、そのあとで DOMContentLoaded をキューします。defer のほうが前です。type="module" も同じリストに入りますが The defer attribute has no effect on module scripts. と明記されており、「module は defer と同じ」ではなく既定でパース完了後の評価になり defer は効かないが正確です。

具体実物

innerHTML は、同じパーサをもう一度呼んでいる

木を作るもう一つの経路が innerHTML です。同じHTMLパーサを呼び出しています。定義も HTML Standard 本体にあります。

Let fragment be the result of invoking the fragment parsing algorithm steps with target and compliantString. Replace all with fragment within target.

この手順が HTML fragment parsing algorithm を呼び、結果で子を置き換えます。だから6本目の回復規則がそのまま効きます。Chrome 152 の実測です。

Chrome 152 / macOS の Console(2026-09-16)コマンド出力
div.innerHTML = "<h1>Hello" → div.innerHTML は <h1>Hello</h1>
div.innerHTML = "<p>a<div>b</div>c</p>"
→ div.innerHTML は <p>a</p><div>b</div>c<p></p>

閉じ忘れた <h1> が閉じられ、<p> の中の <div> で p が閉じられて末尾に空の <p></p> が残る。文書のパースと同じ結果です。ここから、文字列を渡すときの性質が出ます。

el.innerHTML = "<h1>" + name + "</h1>";
el.textContent = name;

1行目は name を結合してから解析します。name の中身がタグとして読まれる位置にあるということです。2行目は解析を経ず、構造を先に作って値を入れるだけです。第41週『なぜ入力がコードになるのか — インジェクションの原理』の言い方を借りれば、危険の正体は特定の記号ではなく値が構文になれる位置にあることです。

なお innerHTML で入れた <script> は評価されません(When inserted using the innerHTML and outerHTML attributes, they do not execute at all.)。parser scripting mode の既定が Inert であることと対応します。ただし「安全になる」わけではありません。

✓ ここまでの確認

`el.innerHTML = "<h1>" + name + "</h1>"` と `el.textContent = name` の違いは、パーサの観点ではどれですか。

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

innerHTML への結合 と textContent

`innerHTML` の文字列は文書と同じ規則で木になり、結合した `name` はその解析対象の一部です。`textContent` は解析を経ず文字として入ります。

値はどの段階で入るか表
書き方 値が入る段階 値は構文になれるか
el.innerHTML = "<h1>"+name+"</h1>" 解析の前 なれる
el.textContent = name 解析を経ない なれない
具体例 B

SQLの文字列連結 と プレースホルダ(第41週)

第41週では、問い合わせ文を組み立てて渡す書き方と、SQLコードを先に全部定義してパラメータを後から渡す書き方が対比されます。後者では構文が先に確定します。

構文が確定する順番表
書き方 構文が確定する時点 値は構文になれるか
連結して渡す 値を混ぜたあと なれる
先に定義し値を後で 値を渡す前 なれない

HTMLの木を作る話と、データベースへの問い合わせの話。2つに共通する原理は何でしょうか。

どちらのペアでも、値が入るのと構文が決まるのとの前後が入れ替わっています。

抽象原理

図で見る、3つの置き方

パーサの進行と <script> の関係(外部の古典スクリプト / defer / async)
属性なしその場で取得・評価。両方終わるまでパースを阻止× 先のタグは木に無い
defer取得は並行。評価はパース完了後、記述順○ 木は完成済み。DOMContentLoaded より前
async取得は並行。取得でき次第すぐ評価○ パース完了前もありうる。順序は不定
setTimeouttimer task source に積むだけ× パース完了との順序は保証されない
↓
DOM を待つ根拠は defer・DOMContentLoaded・末尾配置など仕様上の順序。遅延の値は根拠にならない
  • ・「パースを阻止する」はトークナイザを呼ぶタスクの話。イベントループは回っている
  • ・module script は async が無ければパース完了後の評価。defer 属性は効果を持たない

この章のまとめ

  • パースが進まないのは、document.write() がパーサの入力ストリームを書き換えられるから。DOMに足すのではない。
  • 仕様の語は blocking parsing until these are both complete と pending parsing-blocking script。「parser-blocking」は定義語ではない。
  • 止まるのはトークナイザを呼ぶタスクで、イベントループは回っている(spin the event loop until …)。
  • defer はパース完了後に記述順、async は取得でき次第で順序は不定。type="module" は async が無ければパース完了後で、defer は効果を持たない。
  • パース終了手順は、要素の pop → defer/module の評価 → DOMContentLoaded。defer のほうが前。
  • setTimeout の遅延値は順序の根拠にならない。4msのクランプも入れ子5超かつ指定値4未満のときだけ。
  • innerHTML は同じHTMLパーサを呼ぶので回復も同じ。結合してから解析する書き方では、値が構文になれる位置にある。

この先の学びにどう繋がるか

URLを打ってからピクセルが出るまでの線は、ここで終わりです。タスクの選ばれ方と描画の条件は、ブラウザ編『イベントループ — タスクは集合、マイクロタスクは待ち行列』と『画面が出るまでの12段 — 5段モデルはどこから来たのか』で扱います。手を動かすなら既存クイズ群「フロントエンド」、innerHTML にどこまで許すかは「ブラウザのアクセス制御」です。

自分の現場で確かめる

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

環境:

理解度チェック

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

問題 1 / 3正解: 0

古典スクリプトの評価が終わるまでパーサが先へ進めないのは、なぜですか。

用語集(4語)
pending parsing-blocking script
Document の状態変数で、値は script 要素か null(初期値 null)。`async` も `defer` も無い外部古典スクリプトがここに入り(スクリプトをブロックするスタイルシートがあればインラインの古典スクリプトも)、片付くまでトークナイザを動かすタスクが走らない。「parser-blocking」は定義語ではない。
入力ストリームと insertion point
パーサが読んでいる文字の並びが入力ストリーム。insertion point は `document.write()` の内容が入る位置(初期値 undefined)。`document.write()` は DOM ではなくここに文字列を入れる。
fragment parsing algorithm steps
要素または DocumentFragment と文字列から HTML fragment parsing algorithm を呼ぶ手順。`innerHTML` の setter はこれで fragment を作り Replace all で子を置き換える。既定の parser scripting mode は Inert。
list of scripts that will execute when the document has finished parsing
`defer` 付きの外部古典スクリプトと module script が入る Document 上のリスト。パース終了時にこの順で評価され、`DOMContentLoaded` より前。