HTMLパーサの構築アルゴリズムと挿入モード
壊れたタグでもブラウザが必ず同じDOMを組む理由を仕様から理解でき、閉じ忘れの自動補正やdocument.writeの落とし穴を避けられる。トークナイズと挿入モード遷移を解説。
- 1.HTMLパースはバイト列→トークナイザ→木構築の2段で、トークナイザは状態機械でタグやテキストをトークン化し、木構築は「挿入モード」という状態でDOMノードを積み上げる。XMLと違い、不正な入力でも仕様が定める手順で必ずDOMが完成する(致命的エラーで停止しない)。
- 2.閉じ忘れや誤った入れ子は、開いた要素を積む「オープン要素スタック」と、リンクや強調を復元する「アクティブ整形要素のリスト」によって補正される。`<table>` 内の不正タグは foster parenting(前方への追い出し)で表の外へ移される、といった挙動はすべて挿入モードの遷移として規定されている。
- 3.document.write はパース中の入力ストリームへ直接文字列を差し込む再入処理で、トークナイザを途中から再起動させる。閉じタグの不整合や外部スクリプトとの併用でDOMが壊れやすく、現代では投機的パースやプリロードスキャナを無効化するため強く非推奨。
なぜ「壊れたHTML」でも表示できるのか
<b><i>太字斜体</b></i> のように入れ子が交差していても、ブラウザは止まらず、しかもどのブラウザでもほぼ同じDOMを作ります。これは寛容さの偶然ではなく、HTML仕様がバイト列から1つのDOMツリーを構築する手順を完全に定義しているからです。XML が整形式(well-formed)でない入力を致命的エラーで拒否するのに対し、HTMLパーサに「パースエラーで停止」という概念はほぼなく、すべての入力に対して結果のDOMが一意に決まります。基礎の文法は HTML(Webページの構造) で扱っていますが、本稿はその一段下――トークナイズと木構築のアルゴリズム――を仕様から解きほぐします。
パースは大きく2段のパイプラインです。
バイト列
→ [文字エンコーディング決定] → 文字ストリーム
→ [トークナイザ(状態機械)] → トークン列(開始/終了タグ・文字・コメント・DOCTYPE・EOF)
→ [木構築(挿入モード)] → DOMツリー
トークナイザと木構築は並行して動く点が重要です。トークナイザが1個のトークンを出すたびに木構築がそれを処理し、木構築の結果がトークナイザの状態に影響を与えることすらあります(後述の <script> がその典型)。
トークナイザは状態機械
トークナイザは入力文字を1文字ずつ読み、現在の状態に応じて遷移する有限状態機械です。代表的な状態だけ挙げます。
| 状態 | きっかけ | やること |
|---|---|---|
| Data | 通常テキスト中 | 「<」でタグ開始へ、「&」で文字参照へ |
| Tag open | 「<」を読んだ直後 | 英字なら開始タグ、「/」なら終了タグへ |
| Tag name | タグ名を読む | 空白/「>」/「/」まで名前を蓄積 |
| RAWTEXT | style / iframe など | 中身を生テキスト扱い(タグも文字参照も解釈しない) |
| Script data | script 要素の中身 | </script> 以外はすべて文字データ |
ここで効いてくるのがコンテンツモデルごとの専用状態です。<script> や <style> の中身は RAWTEXT / Script data 状態で読まれ、< を見てもタグとして解釈しません。だから <script> 内に if (a < b) と書いてもタグ開始になりません。終了は対応する終了タグ(</script>)でのみ起こります。これは木構築側が「この要素に入ったらトークナイザの状態を切り替えよ」と指示する、木構築→トークナイザの逆方向の作用の一例です。
Script data 状態は </script> を見た瞬間に抜けます。そのため文字列リテラルの中であっても "</script>" と書くとそこでスクリプトが途切れ、残りがページ本文として誤解釈されます。回避には "<\/script>" のようにスラッシュをエスケープします。これはJSの文法ではなく、HTMLトークナイザの状態遷移が原因である点に注意してください。
木構築の心臓部「挿入モード」
トークン列を受け取る木構築フェーズは、**挿入モード(insertion mode)**という状態を持ちます。「いま文書のどの局面にいるか」を表し、同じ開始タグでもモードによって扱いが変わります。主な遷移はこうです。
initial … DOCTYPE を処理
→ before html
→ before head
→ in head … <title> <meta> <link> など
→ after head
→ in body … 本文の大半をここで処理
→ after body → after after body → 完了
たとえば <title> は in head モードでは要素として作られますが、本来現れない場所に来たトークンはモード規定の補正を受けます。in body で <head> 開始タグが来れば無視され、<body> 開始タグが二重に来れば既存 body への属性マージに化けます。「省略しても動く」のは、パーサがモードごとに暗黙の開始・終了タグを自動挿入するからです。<html> <head> <body> を一切書かなくてもDOMにこれらが現れるのは、この自動補完の結果です。
オープン要素スタックと整形要素の復元
入れ子の補正を担う中核データ構造が2つあります。
- オープン要素のスタック: 開始タグを処理するたびに対応要素を積み、終了タグや暗黙終了で降ろす。「いまの挿入先(カレントノード)」はこのスタックの先頭です。
- アクティブ整形要素のリスト:
biaemなどの整形要素を別途記録し、交差した閉じ方をされても見た目を復元するために使う。
この2つが連携して、冒頭の <b><i>太字斜体</b></i> を扱います。</b> が来た時点で i がまだ開いているため、仕様の adoption agency algorithm(養子縁組アルゴリズム) が起動し、b を閉じつつ i を再度開き直して、<b><i>太字斜体</i></b><i></i> 相当の整った木に組み替えます。これが「どのブラウザでも同じ壊れ方をする」正体です。
このアルゴリズムは、整形要素(強調やリンク)の入れ子が壊れたときに、スタックとアクティブ整形要素リストをたどって要素を「複製・付け替え」し、ツリーの一貫性を保ちます。複雑さで知られますが、目的は明確で「ユーザーが意図したであろう装飾の範囲を、DOMの木構造として破綻なく再現する」ことにあります。手で交差させた装飾タグがブラウザ間で同じ結果になるのはこの規定のおかげです。
table の foster parenting
挿入モードが最も独特に振る舞うのが表です。in table モードでは、<tr> <td> 以外の本文要素(たとえば直書きの <div> やテキスト)は表の格子に置けません。仕様はこれを捨てずに、foster parenting(里親付け) で処理します。
<table>
ここに直接書いたテキストや <div>
→ table の「直前」へ追い出されて挿入される(表の外側へ)
<tr><td>セル</td></tr>
</table>
つまり表内に紛れ込んだ不正コンテンツは、table 要素の手前の兄弟として木に挿入されます。レイアウト崩れの「表の上に謎のテキストが出る」現象の多くはこれです。さらに in table では暗黙に <tbody> が挿入されるため、<table><tr> と書いてもDOM上は table > tbody > tr になります。querySelector('table > tr') が外す典型原因がこれで、内部表現の詳細は DOMツリーの内部表現とノード操作のコスト と合わせると腑に落ちます。
script とパースの停止・再開
<script> 開始タグに当たると、木構築は要素を作りトークナイザを Script data 状態へ切り替えます。</script> で中身が確定すると、ここでパースが一時停止し、スクリプトが(async/defer でなければ)即時実行されます。実行が終わってからパースが再開します。defer/async/type=module による停止挙動の違いは レンダリングブロックとパース阻害 に詳しく、本稿はその下層で「なぜ同期スクリプトがパースを止めるのか」を押さえる立場です。理由は単純で、スクリプトが次の瞬間に document.write でトークンストリームへ文字を注入し得るため、パーサは続きを読む前に実行結果を待たねばならないからです。
document.write とパーサ再入の危険
document.write(s) は、実行中のスクリプトを呼び出したそのパーサの入力ストリームへ、文字列 s を割り込ませるAPI です。トークナイザは挿入された文字を「いま読んでいる位置の続き」として再入的に処理します。これが多くの破綻を生みます。
| 状況 | 起きること | 結果 |
|---|---|---|
| パース中に document.write | 現在位置へ文字を注入し続きをトークン化 | うまくいけば挿入されるが順序が脆い |
| タグをまたいで write | 開きと閉じが別呼び出しに分断 | 意図しない入れ子・要素崩壊 |
| ページ読み込み完了後に write | 暗黙に document.open が走る | 既存DOMを全消去して白紙化 |
| async/外部スクリプトから write | 対象パーサが存在しない | 無視されるか別文書を開く |
最も危険なのは読み込み完了後の document.write です。アクティブなパーサがない状態で呼ぶと、仕様上まず document.open() が暗黙に走り、現在のDOMをすべて破棄してから書き込みます。広告タグやレガシーなスニペットがこれを踏み、ページが丸ごと消える事故が起こります。
document.write はパーサ再入を前提とする時代遅れのAPIで、現代ブラウザの投機的パース/プリロードスキャナを無効化します。先読みでリソース取得を始める最適化が、ストリームが後から書き換わる可能性のせいで止まるためです(先読みの仕組みは 投機的パース処理とリソース優先度)。一部ブラウザはモバイル低速回線で外部スクリプトを差し込む document.write の実行自体を拒否します。DOM操作は appendChild / insertAdjacentHTML などのDOM APIで行うべきです。
element.innerHTML = s も内部的にフラグメントパースを起動し、本稿のトークナイズ+木構築を文脈付きで実行します。そのため s に <img src=x onerror=...> のような文字列を渡せばイベント属性が生きてDOM-based XSSになります(sink/source の分類は DOM-based XSSのsink/source分類と緩和原理)。信頼できない値を innerHTML に入れない、入れるなら無害化する、が原則です。
まとめ
HTMLパースはトークナイザ(状態機械)→木構築(挿入モード)の2段並行パイプラインで、XMLと違い不正入力でも一意のDOMが必ず完成します。トークナイザは Script data / RAWTEXT などコンテンツモデルごとの状態を持ち、<script> 内の < をタグと見なしません。木構築は挿入モードで局面を表し、<html> <head> <body> や <tbody> を暗黙補完します。入れ子の崩れはオープン要素スタックとアクティブ整形要素リスト+adoption agency algorithm で補正され、表内の不正コンテンツは foster parenting で表の外へ追い出されます。<script> はパースを止めて即時実行され、document.write はそのパーサへ文字を再入注入する危険なAPIで、完了後に呼ぶとDOMを白紙化し、投機的パースも無効化するため新規コードでは避けます。先読み最適化は 投機的パース処理とリソース優先度、生成されたツリーの内部構造は DOMツリーの内部表現とノード操作のコスト と合わせて押さえてください。
Web/フロントエンド Article
HTMLパーサの構築アルゴリズムと挿入モードを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
HTML
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
閉じ忘れや誤った入れ子は、開いた要素を積む「オープン要素スタック」と、リンクや強調を復元する「アクティブ整形要素のリスト」によって補正される。`<table>` 内の不正タグは foster parenting(前方への追い出し)で表の外へ移される、といった挙動はすべて挿入モードの遷移として規定されている。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「HTML / ブラウザ」に近いか確認する。
- 強みである「HTMLパースはバイト列→トークナイザ→木構築の2段で、トークナイザは状態機械でタグやテキストをトークン化し、木構築は「挿入モード」という状態でDOMノードを積み上げる。XMLと違い、不正な入力でも仕様が定める手順で必ずDOMが完成する(致命的エラーで停止しない)。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。