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

バイト列がDOMになる — 閉じ忘れても表示される理由

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

ここから先はブラウザの内側です。ネットワークの仕事は終わり、手元にはバイト列だけがあります。

まず結論

HTML パーサが壊れた HTML でも木を作れるのは、実装が親切だからではありません。HTML Standard が parse error を定義したうえで、その後の処理まで規定しているからです。

`<h1>Hello</h1>` の1行しか無いファイルでも、DOM には html・head・body・h1 の4要素ができます。「無ければ作る」が仕様に書いてあるからです。

ただし、できた木が書いたとおりとは限りません。回復規則には「開いている要素を閉じる」「空の要素を挿入する」も含まれるからです。

? 読む前に予測

`<h1>Hello</h1>` だけが書かれたファイルをブラウザで開いたとき、DOM の要素は何個になるでしょうか。

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

抽象原理

「エラーでも表示される」の正体

前のレッスン『サーバは何を返すか — Content-Type が無いと何が起きるか』で応答のバイト列が届きました。ここからはブラウザの内側で、残るのは「このバイト列を DOM という木に変える」作業だけです。

その作業をするのが HTML パーサです。多くの人が「ブラウザは壊れた HTML を大目に見てくれる」と理解していますが、HTML Standard の書き方は違います。

規則の中心にあるのが insertion mode です。

The insertion mode is a state variable that controls the primary operation of the tree construction stage.

パーサはトークン(開始タグ・終了タグ・テキスト等)を1つずつ受け取りますが、同じトークンでもモードが違えば処理が変わります。各モードの規則の末尾には Anything else という受け皿があり、html や head が補われるのは、そこに「無ければ作る」と書いてあるからです。

具体実物

`<h1>Hello</h1>` の1行を追う

通し例題の index.html は <h1>Hello</h1> の1行だけで、DOCTYPE も html も head も body もありません。この開始タグの扱いを仕様の逐語で追います。

開始状態は "initial" で、DOCTYPE の場所にタグが来たので Anything else に落ちます。

If the document is not an iframe srcdoc document, then this is a parse error; if the parser cannot change the mode flag is false, set the Document to quirks mode. In any case, switch the insertion mode to "before html", then reprocess the token.

2つのことが起きます。parse error になり、Document が quirks mode に設定されること。そして reprocess the token —— トークンは消費されず次のモードで再び処理されます。

続く "before html" の Anything else は Create an html element whose node document is the Document object. Append it to the Document object.、"before head" は Insert an HTML element for a "head" start tag token with no attributes.、"in head" は Pop the current node (which will be the head element) off the stack of open elements.、"after head" は Insert an HTML element for a "body" start tag token with no attributes.。いずれも末尾に「同じトークンを再処理する」とあります。

Chrome 152 / DevTools Consoleコマンド出力
> new DOMParser().parseFromString("<h1>Hello</h1>", "text/html").documentElement.outerHTML
'<html><head></head><body><h1>Hello</h1></body></html>'
 
> new DOMParser().parseFromString("<h1>Hello</h1>", "text/html").compatMode
'BackCompat'
 
> new DOMParser().parseFromString("<!DOCTYPE html><h1>Hello</h1>", "text/html").compatMode
'CSS1Compat'

要素は4個 —— html・head・body・h1 で、3個は書いていません。compatMode は BackCompat、DOCTYPE を足すと CSS1Compat に変わり、仕様の言う quirks mode がここに現れます(quirks mode になる、というところまでが本レッスンの範囲です)。

なお、パースの前に「バイト列をどの文字集合として読むか」が決まっている必要があります。前のレッスンで見た Content-Type の charset は encoding sniffing algorithm の4段目 If the transport layer specifies a character encoding, and it is supported, return that encoding with the confidence certain. にあたり、上に BOM とユーザー上書き、下に prescan・親文書・履歴・自動判別が続きます。どれも当たらなければ最終段階 return an implementation-defined or user-specified default character encoding, with the confidence tentative. です。「既定は UTF-8」とは書かれていません(locale 別の推奨表では ja が Shift_JIS)。通し例題には BOM も meta charset も無いのでここまで落ちます。

✓ ここまでの確認

`<h1>Hello`(終了タグを書き忘れた)と `<h1>Hello</h1>` は、同じ DOM になりますか。

抽象原理

回復は「元に戻す」ことではない

「無ければ作る」を一般化すると、回復規則は3種類です。

  1. 足りないものを作る —— html・head・body の挿入。</p> に対応する p が無いときの空の p の挿入。
  2. 開いているものを閉じる —— <div> 開始タグで p を閉じる。パース終了時に stack を全部 pop する。
  3. 無視する —— 対応する要素が scope に無い終了タグを捨てる。

どれも「書き手の意図を推し量る」動作ではなく、トークンの並びと現在の状態から機械的に決まる手順です。だから同じ入力なら同じ木になり、逆に書いた構造と違う木ができることも決定論的に起きます。

閉じ忘れが問題にならないのは2番目の規則が効くからです。"in body" の end-of-file トークンの規則は、stack に指定外の要素が残っていれば parse error と定め Stop parsing. と書きます。続く §13.2.7 の終了手順の4番目が Pop all the nodes off the stack of open elements. —— 閉じ忘れた <h1> は EOF でまとめて閉じられます。

この DOM がどう画面になるかは別の仕組みです。パースは HTML 仕様が規定しており、その出力である DOM が Style 段の入力になります。描画側は、ブラウザ編『画面が出るまでの12段 — 5段モデルはどこから来たのか』で扱います。

具体実物

対比: 構文が確定したら動かせない世界

同じ「壊れた入力」でも逆の扱いになる例が、第41週『なぜ入力がコードになるのか — インジェクションの原理』で扱う SQL のプレースホルダです。

あのレッスンの言い方では、「構文は1の時点で確定しています」 —— 問い合わせ文の構造を先に全部定義し、値は後から渡します。だから後から渡した値は構文を作り直せません。危険の正体は特定の記号ではなく、「値が構文になれる位置」 にあることだと説明されます。

裏返すと、文字列を組み立てた場合は値が構文の位置に入り込み、成立しない並びが来ればその問い合わせは実行されずにエラーになります。構文が確定しない限り先へ進まないからです。

HTML は逆で、<p>a<div>b</div>c</p> のように入れ子が破綻してもパーサは止まらず木を出します。ただし出てくる木は書いたものとは違います。

Chrome 152 / DevTools Consoleコマンド出力
> new DOMParser().parseFromString("<p>a<div>b</div>c</p>", "text/html").documentElement.outerHTML
'<html><head></head><body><p>a</p><div>b</div>c<p></p></body></html>'

<div> は <p> の中に入っていません。"in body" の div 開始タグの規則が If the stack of open elements has a p element in button scope, then close a p element. だからです。c は p の外、body 直下に置かれます。最後の </p> は対応する p がもう無いので this is a parse error; insert an HTML element for a "p" start tag token with no attributes. —— 空の <p></p> が末尾に増えます。c が「2つ目の p に入る」のではありません。

✓ ここまでの確認

`<p>a<div>b</div>c</p>` をパースすると、テキスト `c` はどこに置かれますか。

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

HTML: 誤りのあとの処理まで規定されている

HTML Standard は構文的に正しいかどうかにかかわらずパース規則を定めています。parse error は印であって停止命令ではなく、そのあとに何をするかが仕様本文そのものです。ただし木は書いた構造と違うことがあります。

壊れた入力とその結果(Chrome 152 実測)表
入力 結果の木 止まったか
<h1>Hello <h1>Hello</h1>(閉じたのと同じ) 止まらない
<p>a<div>b</div>c</p> <p>a</p><div>b</div>c<p></p> 止まらない
具体例 B

SQL のプレースホルダ: 構文が先に確定する(第41週)

問い合わせ文の構造を先に全部定義し、値は後から渡します。第41週の言い方では「構文は1の時点で確定しています」。値は確定済みの構文の穴を埋めるだけです。

値が構文になれるか表
組み立て方 値の位置 構文が成立しないとき
文字列で連結 構文になれる位置 実行されずエラー
プレースホルダ 穴を埋めるだけ 値は構文になれない

同じ「壊れた入力」なのに、HTML パーサは木を出して進み、SQL は実行されずに止まります。この違いは何が決めているのでしょうか。

実装がどちらも同じくらい真面目だとしたら、差はどこから来るでしょうか。

抽象原理

図解: 1個のトークンが5回再処理される

<h1> 開始タグ1個が insertion mode を順に押し上げます。各段の Anything else が「無ければ作る」を実行し、同じトークンを次の段へ渡します。

`<h1>Hello</h1>` の最初のトークンが辿る insertion mode(DOCTYPE 無しの場合)
initialDOCTYPE が来ない → parse error、Document を quirks mode に× before html へ / reprocess
before htmlCreate an html element … Append it to the Document object○ html 生成 / reprocess
before headInsert an HTML element for a "head" start tag token○ head 生成 / reprocess
in headPop the current node (which will be the head element)○ head を閉じる / reprocess
after headInsert an HTML element for a "body" start tag token○ body 生成 / reprocess
in bodyここで初めて h1 が挿入される○ html・head・body・h1 の4要素
↓
書いたのは h1 だけ。html・head・body は各段の Anything else が作った
  • ・トークンは消費されず、各段の最後に reprocess the token と書かれている
  • ・DOCTYPE があれば initial は parse error にならず、quirks mode にもならない

この章のまとめ

  • HTML パーサが壊れた入力で止まらないのは、仕様が parse error のあとの処理まで規定しているから(The error handling for parse errors is well-defined)。「寛容だから」ではない。
  • ただし同じ文に may abort the parser とあり、止まらないことは必須要件ではない。
  • <h1>Hello</h1> の1行から4要素ができるのは、insertion mode ごとの Anything else が「無ければ作る」と定めているから。
  • DOCTYPE が無いと "initial" で parse error となり Document は quirks mode(実測では compatMode が "BackCompat")。
  • 閉じ忘れは EOF で Stop parsing となり、Pop all the nodes off the stack of open elements で閉じられる。閉じた場合と同じ木になる。
  • 回復規則は「元に戻す」ではない。 <p>a<div>b</div>c</p> は <p>a</p><div>b</div>c<p></p> になる。動くが木は書いたとおりではない。
  • 文字エンコーディングは BOM → ユーザー上書き → transport layer → prescan → 親文書 → 履歴 → 自動判別 → 既定の順。最終段階は implementation-defined(ja の推奨は Shift_JIS)。
  • 次のレッスン『DOMがピクセルになる — 描画は「DOM完成」を待たない』で、この木がどう画面になるかを見ます。関連する演習は既存クイズの「フロントエンド」と「ブラウザ・エンジン」にあります。

自分の現場で確かめる

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

環境:

理解度チェック

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

問題 1 / 3正解: 0

HTML パーサが構文の誤りで止まらないことについて、HTML Standard の記述に最も忠実な説明はどれですか。

用語集(4語)
parse error
パース手順の特定の地点に付けられた名前。誤りの印であって停止命令ではない。error handling は well-defined だが、UA は最初の parse error でパーサを中断してもよい。
insertion mode
仕様の定義は `a state variable that controls the primary operation of the tree construction stage`。"initial" から "before html"・"before head"・"in head"・"after head"・"in body" などへ移る。
stack of open elements
まだ閉じられていない要素を積んでおく仕組み。パース終了時に残りが全部 pop されるため、閉じ忘れた要素もそこで閉じられる。
quirks mode
Document が取りうる状態のひとつ。DOCTYPE が無い場合、"initial" の Anything else でこの状態に設定される。`document.compatMode` が `"BackCompat"` になることで見える。