← コース一覧
Rust で小さなブラウザを作る
URL を受け取って HTTP で HTML を取り、字句解析 → DOM → CSS → レイアウト → 描画と変換して文字の画面に出し、JavaScript で DOM を書き換えて描き直すところまでを、Rust の標準ライブラリだけで作るハンズオンです。各段は WHATWG / W3C / RFC / ECMAScript の仕様と同じ名前で作り、各レッスンに「1 ファイルで動く版」と、その段が本物のブラウザ(Chromium / Firefox)のどの部品にあたるか、そしてその考え方が日々の開発のどこに出てくるかを付けています。作っていない機能(HTTPS・キャッシュ・XMLHttpRequest・イベントループ・GC・保存・サイト分離)も、本物のどこでどう動いているかを 1 回分で説明しています。完成版は cargo test が 78 本通り、example.com を実際に描けます。
公式ドキュメント: HTML Standard — 13.2 Parsing HTML documents ↗用語集(236語)A. 地図と準備
- 1何を作るか — ブラウザは「変換の列」でできているこのコースで作るのは、URL を受け取って文字の画面に Web ページを描き、JavaScript でその中身を書き換えられる小さなブラウザです。Rust の標準ライブラリだけで、テストとコメントを含めて約 4,600 行です。
- 2作るもの・作らないもの — 本物との差を先に知っておくこのブラウザは「本物と同じ骨格を、仕様の名前のまま、動く最小の形で」作ります。逆に言うと、本物のブラウザの大部分は作りません。どこを削ったかを先に知っておくと、動かしたときの「あれ、効かない」を設計の限界として読めます。
- 3本物のブラウザではどこにあたるか — Chromium と Firefox の部品との対応表このコースの各段には、本物のブラウザに同じ役の部品があります。名前は違っても、仕様の節名で探せば見つかります。ここでは Chromium(描画エンジン Blink、JavaScript エンジン V8、通信の //net)と Firefox(Gecko、SpiderMonkey、通信の Necko)の両方を、この実装のファイルと並べます。場所は 2026 年 10 月 14 日時点のソースで確かめたもので、Chromium は chromium.googlesource.com/chromium/src、Firefox は GitHub の mozilla-firefox/firefox、V8 は github.com/v8/v8 で読めます。
- 4プロジェクトの形 — src/ の 20 のモジュールと、1 ファイル版の対応完成版は cargo のふつうのプロジェクトで、src/ の下に段ごとのフォルダがあります。lib.rs がモジュールを束ね、main.rs がコマンドラインの入口です。モジュールは 20 個(フォルダごとの mod.rs を除く)で、各モジュールの先頭には「この部品は何で、仕様のどこに対応し、何を省いたか」をコメントで書いてあります。
B. URL を分解する
- 5実行可URL の部品 — scheme / host / port / path / query を左から切るURL の分解は、文字列を左から順に 4 回切るだけです。"://" の前が scheme、次の "/" か "?" までが authority(host と port)、authority を ":" で host と port に分け、残りを "?" で path と query に分けます。
- 6失敗を型にする — UrlError と Result で「どう壊れていたか」を返す分解に失敗したとき、呼び出し側が知りたいのは「失敗した」ことではなく「どう壊れていたか」です。参照実装は失敗を enum UrlError の 4 つ(MissingScheme / UnsupportedScheme / EmptyHost / InvalidPort)で返し、parse の戻り値は Result<Url, UrlError> です。
C. HTTP で取ってくる
- 7実行可リクエストの形 — 行末は CRLF、Host は必須、Connection: close で終わりが分かるHTTP/1.1 のリクエストは、ただのテキストです。1 行目が request-line(メソッド・request-target・バージョンを空白で区切る)、続けてヘッダーの行、空行、本文。GET には本文がありません。RFC 9112 の ABNF では HTTP-message = start-line CRLF *( field-line CRLF ) CRLF [ message-body ] です。
- 8レスポンスの分解 — status-line、field-line、そして本文の長さをどう決めるかレスポンスもテキストで、1 行目が status-line(HTTP-version SP status-code SP reason-phrase)、ヘッダーの行、空行、本文です。分解の難所は 1 つだけで、「本文はどこで終わるか」です。
- 9TCP で送って受け取る — 名前解決、接続、タイムアウト、閉じるまで読むfetch は 5 行の手順です。ホスト名を IP アドレスにし(名前解決)、TCP でつなぎ、リクエストの文字列を書き、サーバーが閉じるまで読み、前のレッスンの parse に渡す。Rust の std::net だけでできます。
D. HTML を字句解析する
- 10実行可状態機械という読み方 — 1 文字読んで、状態を変えるか、トークンを出すかHTML の字句解析は、正規表現でも、タグを探す split でもなく、状態機械で書きます。HTML Standard 13.2.5 は「実装は次の状態機械を使って HTML をトークン化するかのように振る舞わなければならない。状態機械は data state から始まる。ほとんどの状態は 1 文字を消費し、新しい状態へ切り替えて今の文字を再消費するか、新しい状態へ切り替えて次の文字を消費するか、同じ状態にとどまって次の文字を消費する」と定めています。
- 11実行可属性の 8 つの状態 — 引用符 3 種類と、値のない属性属性を読むには状態を 8 つ足します。BeforeAttributeName(タグ名のあとの空白を読んだ)、AttributeName(名前の途中)、AfterAttributeName(名前のあと)、BeforeAttributeValue(= のあと)、AttributeValue の 3 つ(二重引用符・一重引用符・引用符なし)、AfterAttributeValueQuoted(閉じ引用符のあと)。名前は HTML Standard 13.2.5.32〜13.2.5.39 と同じです。
- 12実行可自己終了タグと script の中身 — 「適切な終了タグ」が来るまでは全部文字<script> の中身にはタグに見える文字列が平気で入ります(if (a < b) や文字列の '</div>')。字句解析器はこれをタグにしてはいけません。HTML Standard はこのために script data という別の状態の組を用意していて、「適切な終了タグ(appropriate end tag)」つまり直前の開始タグと同じ名前の終了タグが来るまで、文字として流します。
- 13テストで固める — トークン列を丸ごと比べ、境界と壊れた入力を押さえる字句解析器は入力の文字列と出力のトークン列が決まっているので、テストが書きやすい部品です。参照実装のテストは 8 本で、それぞれ 1 つの性質を見ています。空文字なら Eof だけ。開始タグ・文字・終了タグ・Eof の順。タグ名は小文字になる。引用符 3 種類の属性。値のない属性と自己終了。単独の < は文字。script の中身はタグにならない。DOCTYPE とコメントは消える。
E. DOM ツリーを組み立てる
- 14実行可ノードを Rust で持つ — 親は Weak、子は Rc、付け外しは RefCellDOM は「親と子の順序付き集合を持つノードの木」です(DOM Standard 4.2)。これを Rust で持つときの壁は、子から親へも、親から子へも辿りたいのに、所有権は一方向にしか持てないことです。参照実装は、子は Rc<Node>(所有者が複数いてよい: 親と、JavaScript から掴んだ参照)、親は Weak<Node>(所有しない参照)、子のリストと親の参照は RefCell で包む(&self のまま付け外しできる内部可変性)、の 3 点で解いています。
- 15実行可挿入モードと開いている要素のスタック — トークンを木に置く場所の決め方構文解析器は、トークンを 1 つ受け取るたびに「今の要素の子として置くか、今の要素を閉じるか、無視するか」を決めます。その判断の土台が 2 つの状態です。挿入モード(insertion mode)は HTML Standard が「構文解析の主要な動作を制御する状態変数」と定めるもので、仕様には 23 個、この実装には 9 個あります。開いている要素のスタック(stack of open elements)は「最初は空。最後に追加したものが一番下(= 今の要素)」で、開始タグで積み、終了タグで下ろします。
- 16壊れた HTML を直す — 閉じ忘れ、迷子の終了タグ、無い html / head / bodyHTML の構文解析器は壊れた入力で止まりません。HTML Standard は「parse error」と呼ぶ状況のそれぞれについて、エラーを記録したうえで何をするかを定めています。だから <p>one<p>two は 2 つの兄弟の <p> になり、<p>a</b>b</p> の </b> は無視され、<p> だけの文書にも html / head / body ができます。
F. CSS を解釈する
- 17実行可CSS のトークン — 先頭の数文字で種類が決まるCSS の字句解析は HTML と違って状態機械ではなく、「先頭の 1〜3 文字を見て種類を決め、その種類に必要なだけ読む」形です(CSS Syntax Level 3 §4 の consume a token)。空白が続けば 1 つの <whitespace-token>、" か ' なら <string-token>、数字(または +/-/. に続く数字)なら数値、英字や _ や非 ASCII なら識別子、# に名前が続けば <hash-token>、@ に名前が続けば <at-keyword-token>、どれでもない 1 文字は <delim-token>。
- 18実行可CSSOM — ルールは prelude と宣言ブロック、読めないものは捨てて先へ進むCSS の構文解析の出力は CSSOM、「スタイルシート → ルール → セレクタと宣言」の木です。CSS Syntax Level 3 §5 の言葉では、qualified rule は「component value のリストからなる prelude と、宣言のリスト」を持ち、declaration は「名前(文字列)と、component value のリストからなる値」を持ちます。prelude とは "{" より前の部分で、スタイルルールではこれがセレクタです。
- 19カスケードと詳細度 — 当たった宣言を (A,B,C) と出現順で並べ、最後が勝つ1 つの要素に複数のルールが当たったとき、どの宣言が勝つかを決めるのがカスケードです。CSS Cascade Level 4 §6.1 の並べ替えは、出所と重要度(UA / ユーザー / 作者、!important)→ 文脈 → 詳細度 → 出現順、の順で比べ、「文書順で最後の宣言が勝つ」と定めています。この実装は !important と文脈を省き、「UA 既定 < 作者」「作者の中は詳細度 → 出現順」だけを実装しています。
- 20継承と初期値 — 宣言が無いとき、値はどこから来るかカスケードで宣言が 1 つも当たらなかったプロパティの値は、2 通りの決まり方をします。継承プロパティ(color、font-size など)なら「親要素におけるそのプロパティの計算値」(CSS Cascade Level 4 §7)。継承しないプロパティ(background-color、display、width など)なら、そのプロパティの初期値。これが defaulting です。
G. レイアウトして描く
- 21箱の木 — block・inline・text の 3 種類と、箱にならないものレイアウトの入力は DOM ではなく「箱の木(box tree)」です。要素は計算値の display に従って箱になり、display: none の要素は子孫ごと箱を作りません。CSS Display Level 3 の言葉では、block は block-level の箱、inline は inline-level の箱、none は「その要素と子孫は箱を生成しない」です。文字は親の計算値を引き継いだテキストの箱になります。
- 22位置と大きさ — block は縦に積み、inline は横に並べ、右端で折り返す通常フロー(normal flow)の規則は 2 つだけです。block の箱は縦に積まれ、幅は親いっぱい、高さは中身で決まる。inline の箱と文字は横に並び、右端に達したら次の行へ折り返す。CSS 2 §9 の visual formatting model のうち、float や position を使わないときの振る舞いがこれです。margin / padding / border は省いているので、箱の位置は直前の箱の終わりから始まります。
- 23表示リストと描画 — 「何をどこに描くか」の命令列にしてから描く本物のブラウザは、レイアウトの結果を直接画面に描きません。一度「何をどこに描くか」の命令列(表示リスト、display list)にしてから、それを GPU に渡します。描く対象(箱の木)と描く手段(画面・プリンタ・画像ファイル)を切り離すためです。この実装も同じ順で、paint が LayoutBox の木から DisplayItem の列を作り、render_ascii がそれを文字のキャンバスに描きます。
H. JavaScript を動かす
- 24実行可JavaScript の字句解析 — 識別子と予約語、数値、文字列、コメントJavaScript の字句解析は CSS と同じ「先頭の文字で種類を決めて読む」形です。ECMAScript §12 の字句文法のうち、この実装が扱うのは、区切り記号(+ - = ( ) { } ; . ,)、数値、文字列(" と ' の 2 種類と \n などのエスケープ)、識別子、予約語(var let const function return)、// の行コメントです。
- 25実行可構文解析と AST — 生成規則 1 つが関数 1 つ(再帰下降)構文解析は、トークンの列を抽象構文木(AST)にします。書き方は再帰下降で、ECMAScript の生成規則 1 つを関数 1 つにします。parse_statement は Statement、parse_variable_declaration は VariableStatement、parse_function_declaration は FunctionDeclaration、parse_assignment_expression は AssignmentExpression、parse_additive_expression は AdditiveExpression、parse_left_hand_side_expression は MemberExpression と CallExpression、parse_primary_expression は PrimaryExpression です。
- 26評価と環境 — 変数の表を外側へ辿り、関数呼び出しごとに新しい表を作る評価(インタプリタ)は AST を根から辿って値にします。数値リテラルは Number、文字列リテラルは Str、識別子は環境から値を引く、BinaryExpression は左右を評価して + か - を適用する、という素直な再帰です。値は RuntimeValue(Undefined / Null / Number / Str / Function / Builtin / Object)で表します。
- 27ブラウザ API — document.getElementById と textContent で DOM を書き換え、描き直すJavaScript が「ブラウザの言語」になるのは、document という名前で DOM に触れるからです。この実装では 3 つだけ用意しました。document.getElementById(id)、要素.textContent の読み書き、console.log(...)。これらは JavaScript の文法ではなく、評価器が特定のオブジェクトとプロパティ名の組に出会ったときに Rust 側で実行する組み込み(Builtin)です。
I. まとめ
- 28全部つなぐ — main.rs、ローカルサーバー、途中の木を覗くオプション完成版の main.rs は、URL かファイルを受け取り、Page::load に HTML を渡し、文字のキャンバスを表示します。Page::load の中は 5 段で、HTML → DOM(E 章)、DOM の <style> → CSSOM(F 章)、DOM の <script> → 実行(H 章)、DOM + CSSOM → 箱の木と座標(G 章)、箱の木 → 表示リスト(G 章)です。URL のときはその前に B 章と C 章が走ります。
- 29作っていない機能は本物のどこで動いているか — 通信・キャッシュ・XHR・時間・メモリ・保存・安全このコースで作ったのは『URL を受けて 1 回取り、変換して描き、script を 1 回走らせる』一本道です。本物のブラウザにはその周りに、通信を速く安全にする層、同じものを 2 度取らない層、時間を扱う層、メモリを回収する層、保存する層、守る層があります。この回は、それらが『どういう原理で』『本物のどこで』動いているかを、ソースの場所つきで書きます。実装はしていませんが、原理が分かれば自分で足す位置も分かります。
- 30どこに活かすか — 各段で身についたことと、日々の開発への写し方ブラウザを 1 つ作って手に入るのは、ブラウザを作る力ではなく、「Web の表側で起きていることを、どの段の仕事か分けて読む力」と、「文字列を木に、木を別の木に変える処理を設計する力」です。下の表に、段ごとに何を作ったか、それが日々の開発のどこに出てくるか、次に読むと深まる一次ソースを並べます。