ブラウザ(レンダリングパイプライン)
HTML 文字列と CSS 文字列から「画面のどこに何を描くか」を計算する、ブラウザのレンダリングエンジンの背骨を最小構成で作る。処理は parse → style → layout → paint の一方通行で、各段は前段の出力だけを入力に取るので単体でテストできる。スタイルは詳細度の高い規則が勝つカスケードと、color などを子へ渡す継承で決まる。
この章で作るもの
mini-next の SSR は「仮想DOM木 → HTML文字列」だった。ブラウザはその逆から始める。受け取った HTML文字列を DOM 木に戻し、CSS を当て、各要素の位置と大きさを計算し、最後に「ここに この色の矩形」「ここに この文字」という描画コマンドにする。URL を入れてから画面が出るまでの、レンダリング部分の核を取り出す。
パイプラインは5段の一方通行:
HTML文字列 ──parse──▶ DOM木 ─────────┐
├─ style ─▶ スタイル木
CSS文字列 ──parse──▶ 規則(セレクタ+宣言)┘ (カスケード+継承)
│
layout (位置と大きさ)
│
▼
レイアウト木
│
paint (描画コマンド化)
│
▼
描画コマンド一覧 ─▶ 画面先に押さえることが3つある。
- 段階に分ける: parse → style → layout → paint。各段は前段の出力だけを入力に取る。だから単体で理解・テストできる
- スタイルはカスケード + 継承: 詳細度の高い規則が勝ち、color などは子(テキスト含む)へ受け継ぐ
- レイアウトは箱の入れ子: ブロックは親幅を満たし、子は縦に積む。位置は「親のどこに、前の兄弟の下に」で決まる
① HTMLパース: 文字列 → DOM木
SSR の逆。文字列を先頭から食べ進む再帰下降パーサで木を組む。< を覗いて要素かテキストかを決める:
// HTMLパーサ: HTML文字列を DOM 木にする。SSR(mini-next)の renderToString の逆向き。
// 再帰下降パーサ——「今の位置」を持ち、先頭を覗いて要素かテキストかを決めて食べ進む。
// #region dom{ts}
export interface Element {
kind: "element";
tag: string;
attrs: Record<string, string>;
children: DomNode[];
}
export interface Text {
kind: "text";
text: string;
}
export type DomNode = Element | Text;
export const elem = (tag: string, attrs: Record<string, string>, children: DomNode[]): Element => ({ kind: "element", tag, attrs, children });
export const text = (t: string): Text => ({ kind: "text", text: t });
// #endregion dom{ts}
// #region parser{ts}
// 入力文字列と現在位置を持つだけの素朴なパーサ。
class Parser {
private pos = 0;
constructor(private readonly input: string) {}
// 1ノードを読む: '<' で始まれば要素、それ以外はテキスト。
parseNode(): DomNode {
return this.peek() === "<" ? this.parseElement() : this.parseText();
}
private parseElement(): Element {
this.expect("<");
const tag = this.parseName();
const attrs = this.parseAttributes();
this.expect(">");
const children = this.parseNodes(); // 閉じタグまで子を読む
this.expect("</");
this.parseName();
this.expect(">");
return elem(tag, attrs, children);
}
// 閉じタグ '</' か入力末尾までノードを読み続ける。
parseNodes(): DomNode[] {
const nodes: DomNode[] = [];
while (true) {
this.skipWhitespace();
if (this.eof() || this.startsWith("</")) break;
nodes.push(this.parseNode());
}
return nodes;
}
private parseText(): Text {
let s = "";
while (!this.eof() && this.peek() !== "<") s += this.consume();
return text(s.trim());
}
private parseAttributes(): Record<string, string> {
const attrs: Record<string, string> = {};
while (true) {
this.skipWhitespace();
if (this.peek() === ">" || this.eof()) break;
const name = this.parseName();
this.expect("=");
const quote = this.consume(); // ' か "
let value = "";
while (!this.eof() && this.peek() !== quote) value += this.consume();
this.expect(quote);
attrs[name] = value;
}
return attrs;
}
private parseName(): string {
let s = "";
while (!this.eof() && /[a-zA-Z0-9-]/.test(this.peek())) s += this.consume();
return s;
}
// #endregion parser{ts}
private peek(): string {
return this.input[this.pos] ?? "";
}
private consume(): string {
return this.input[this.pos++] ?? "";
}
private startsWith(s: string): boolean {
return this.input.startsWith(s, this.pos);
}
private expect(s: string): void {
if (!this.startsWith(s)) throw new Error(`HTML parse: 位置 ${this.pos} で "${s}" を期待したが "${this.input.slice(this.pos, this.pos + 6)}"`);
this.pos += s.length;
}
private skipWhitespace(): void {
while (!this.eof() && /\s/.test(this.peek())) this.pos++;
}
private eof(): boolean {
return this.pos >= this.input.length;
}
}
// 単一ルート要素を前提にパースする。複数ルートなら暗黙の <html> で包む。
export function parseHTML(input: string): DomNode {
const parser = new Parser(input.trim());
const nodes = parser.parseNodes();
if (nodes.length === 1) return nodes[0] as DomNode;
return elem("html", {}, nodes);
}parseElement が「開きタグ → 属性 → 子(閉じタグまで) → 閉じタグ」を順に食べ、子の parseNodes が中で再び parseNode を呼ぶ。この再帰が入れ子構造を作る。閉じタグが対応しなければ expect が例外を投げる(実ブラウザは寛容に回復するが、ここは投げる)。
② CSSパース: 文字列 → 規則
セレクタ { 宣言 } を規則の配列にする。セレクタは tag / .class / #id の単純セレクタのみ:
// CSSパーサ: スタイルシート文字列を規則(セレクタ + 宣言)の配列にする。
// セレクタは tag / .class / #id の単純セレクタのみ。組み合わせ(子孫結合子など)は扱わない。
// #region types{ts}
export interface Selector {
tag?: string;
id?: string;
classes: string[];
}
export interface Declaration {
name: string;
value: string;
}
export interface Rule {
selectors: Selector[];
declarations: Declaration[];
}
export interface Stylesheet {
rules: Rule[];
}
// 詳細度(specificity): id, class, tag の個数のタプル。大きいほど優先。
export type Specificity = [number, number, number];
export function specificity(sel: Selector): Specificity {
return [sel.id ? 1 : 0, sel.classes.length, sel.tag ? 1 : 0];
}
// #endregion types{ts}
// #region parse{ts}
export function parseCSS(input: string): Stylesheet {
const rules: Rule[] = [];
// 規則を "}" で分割する素朴な方式(ネストや @media は無い前提)。
for (const chunk of input.split("}")) {
const [head, body] = chunk.split("{");
if (head === undefined || body === undefined) continue; // 末尾の空白など
const selectors = parseSelectors(head);
const declarations = parseDeclarations(body);
if (selectors.length > 0) rules.push({ selectors, declarations });
}
return { rules };
}
// "h1, .note, #main" → セレクタ配列。詳細度の高い順に並べておく。
function parseSelectors(input: string): Selector[] {
const selectors = input
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(parseSimpleSelector);
selectors.sort((a, b) => cmpSpecificity(specificity(b), specificity(a)));
return selectors;
}
// "div.note#main" のような単純セレクタを分解する。
function parseSimpleSelector(input: string): Selector {
const sel: Selector = { classes: [] };
// .class / #id / tag を先頭から順に取り出す
const tokens = input.match(/[.#]?[a-zA-Z0-9-]+/g) ?? [];
for (const tok of tokens) {
if (tok.startsWith(".")) sel.classes.push(tok.slice(1));
else if (tok.startsWith("#")) sel.id = tok.slice(1);
else sel.tag = tok;
}
return sel;
}
function parseDeclarations(input: string): Declaration[] {
return input
.split(";")
.map((d) => d.trim())
.filter(Boolean)
.map((d) => {
const i = d.indexOf(":");
return { name: d.slice(0, i).trim(), value: d.slice(i + 1).trim() };
});
}
// #endregion parse{ts}
export function cmpSpecificity(a: Specificity, b: Specificity): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
}決め手は**詳細度(specificity)**だ。#id は class より、class は tag より強い。これを [id数, class数, tag数] のタプルで表し、比較する。後で同じプロパティが競合したとき、この強さで勝敗を決める:
// CSSパーサ: スタイルシート文字列を規則(セレクタ + 宣言)の配列にする。
// セレクタは tag / .class / #id の単純セレクタのみ。組み合わせ(子孫結合子など)は扱わない。
// #region types{ts}
export interface Selector {
tag?: string;
id?: string;
classes: string[];
}
export interface Declaration {
name: string;
value: string;
}
export interface Rule {
selectors: Selector[];
declarations: Declaration[];
}
export interface Stylesheet {
rules: Rule[];
}
// 詳細度(specificity): id, class, tag の個数のタプル。大きいほど優先。
export type Specificity = [number, number, number];
export function specificity(sel: Selector): Specificity {
return [sel.id ? 1 : 0, sel.classes.length, sel.tag ? 1 : 0];
}
// #endregion types{ts}
// #region parse{ts}
export function parseCSS(input: string): Stylesheet {
const rules: Rule[] = [];
// 規則を "}" で分割する素朴な方式(ネストや @media は無い前提)。
for (const chunk of input.split("}")) {
const [head, body] = chunk.split("{");
if (head === undefined || body === undefined) continue; // 末尾の空白など
const selectors = parseSelectors(head);
const declarations = parseDeclarations(body);
if (selectors.length > 0) rules.push({ selectors, declarations });
}
return { rules };
}
// "h1, .note, #main" → セレクタ配列。詳細度の高い順に並べておく。
function parseSelectors(input: string): Selector[] {
const selectors = input
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(parseSimpleSelector);
selectors.sort((a, b) => cmpSpecificity(specificity(b), specificity(a)));
return selectors;
}
// "div.note#main" のような単純セレクタを分解する。
function parseSimpleSelector(input: string): Selector {
const sel: Selector = { classes: [] };
// .class / #id / tag を先頭から順に取り出す
const tokens = input.match(/[.#]?[a-zA-Z0-9-]+/g) ?? [];
for (const tok of tokens) {
if (tok.startsWith(".")) sel.classes.push(tok.slice(1));
else if (tok.startsWith("#")) sel.id = tok.slice(1);
else sel.tag = tok;
}
return sel;
}
function parseDeclarations(input: string): Declaration[] {
return input
.split(";")
.map((d) => d.trim())
.filter(Boolean)
.map((d) => {
const i = d.indexOf(":");
return { name: d.slice(0, i).trim(), value: d.slice(i + 1).trim() };
});
}
// #endregion parse{ts}
export function cmpSpecificity(a: Specificity, b: Specificity): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
}③ スタイル計算: DOM × CSS → スタイル木
各要素に、マッチした宣言を貼り付ける。同じプロパティが複数当たったら詳細度の高い規則が後勝ち(カスケード):
import { type DomNode, type Element } from "./html";
import { type Stylesheet, type Selector, type Declaration, specificity, cmpSpecificity } from "./css";
// スタイル計算: DOM木の各要素に、マッチした CSS 宣言を貼り付けて「スタイル木」を作る。
// 同じプロパティが複数当たったら、詳細度の高い規則が勝つ(カスケード)。
// #region types{ts}
export interface StyledNode {
node: DomNode;
specified: Record<string, string>; // 確定したプロパティ
children: StyledNode[];
}
// #endregion types{ts}
// #region match{ts}
// セレクタが要素にマッチするか。tag/id/class がすべて合致すれば真。
function matches(el: Element, sel: Selector): boolean {
if (sel.tag && sel.tag !== el.tag) return false;
if (sel.id && sel.id !== el.attrs.id) return false;
const classList = (el.attrs.class ?? "").split(/\s+/).filter(Boolean);
return sel.classes.every((c) => classList.includes(c));
}
// 要素に当たる宣言をすべて集め、詳細度順に適用して確定値を作る。
function specifiedValues(el: Element, sheet: Stylesheet): Record<string, string> {
const matched: { spec: ReturnType<typeof specificity>; decls: Declaration[] }[] = [];
for (const rule of sheet.rules) {
// 規則内で最も詳細度の高いマッチセレクタを、その規則の詳細度とする
const best = rule.selectors.filter((s) => matches(el, s)).sort((a, b) => cmpSpecificity(specificity(b), specificity(a)))[0];
if (best) matched.push({ spec: specificity(best), decls: rule.declarations });
}
// 詳細度の低い順に適用 → 高い規則が後勝ちで上書きする(カスケード)
matched.sort((a, b) => cmpSpecificity(a.spec, b.spec));
const values: Record<string, string> = {};
for (const m of matched) for (const d of m.decls) values[d.name] = d.value;
return values;
}
// #endregion match{ts}
// #region tree{ts}
// 子へ受け継ぐプロパティ(継承)。color や font 系はテキストにも伝わる(box系は伝わらない)。
const INHERITED = ["color", "font-size", "font-family"];
export function styleTree(root: DomNode, sheet: Stylesheet, inherited: Record<string, string> = {}): StyledNode {
const own = root.kind === "element" ? specifiedValues(root, sheet) : {};
// 継承値を土台に、自分の指定で上書きする(自分の指定が勝つ)
const specified = { ...inherited, ...own };
// 子に渡す継承値: 継承対象プロパティだけを抜き出す
const forChildren: Record<string, string> = {};
for (const p of INHERITED) if (specified[p] !== undefined) forChildren[p] = specified[p] as string;
const children = root.kind === "element" ? root.children.map((c) => styleTree(c, sheet, forChildren)) : [];
return { node: root, specified, children };
}
// display の既定はテキストなら inline、要素なら block(簡略)。none は描かない。
export function display(styled: StyledNode): "block" | "inline" | "none" {
const d = styled.specified.display;
if (d === "none") return "none";
if (d === "inline") return "inline";
return styled.node.kind === "text" ? "inline" : "block";
}
// #endregion tree{ts}もう一つが継承。color を親に指定すると、子や中のテキストにも伝わる。一方 background や margin は箱固有なので伝わらない。継承対象だけを子へ渡す:
import { type DomNode, type Element } from "./html";
import { type Stylesheet, type Selector, type Declaration, specificity, cmpSpecificity } from "./css";
// スタイル計算: DOM木の各要素に、マッチした CSS 宣言を貼り付けて「スタイル木」を作る。
// 同じプロパティが複数当たったら、詳細度の高い規則が勝つ(カスケード)。
// #region types{ts}
export interface StyledNode {
node: DomNode;
specified: Record<string, string>; // 確定したプロパティ
children: StyledNode[];
}
// #endregion types{ts}
// #region match{ts}
// セレクタが要素にマッチするか。tag/id/class がすべて合致すれば真。
function matches(el: Element, sel: Selector): boolean {
if (sel.tag && sel.tag !== el.tag) return false;
if (sel.id && sel.id !== el.attrs.id) return false;
const classList = (el.attrs.class ?? "").split(/\s+/).filter(Boolean);
return sel.classes.every((c) => classList.includes(c));
}
// 要素に当たる宣言をすべて集め、詳細度順に適用して確定値を作る。
function specifiedValues(el: Element, sheet: Stylesheet): Record<string, string> {
const matched: { spec: ReturnType<typeof specificity>; decls: Declaration[] }[] = [];
for (const rule of sheet.rules) {
// 規則内で最も詳細度の高いマッチセレクタを、その規則の詳細度とする
const best = rule.selectors.filter((s) => matches(el, s)).sort((a, b) => cmpSpecificity(specificity(b), specificity(a)))[0];
if (best) matched.push({ spec: specificity(best), decls: rule.declarations });
}
// 詳細度の低い順に適用 → 高い規則が後勝ちで上書きする(カスケード)
matched.sort((a, b) => cmpSpecificity(a.spec, b.spec));
const values: Record<string, string> = {};
for (const m of matched) for (const d of m.decls) values[d.name] = d.value;
return values;
}
// #endregion match{ts}
// #region tree{ts}
// 子へ受け継ぐプロパティ(継承)。color や font 系はテキストにも伝わる(box系は伝わらない)。
const INHERITED = ["color", "font-size", "font-family"];
export function styleTree(root: DomNode, sheet: Stylesheet, inherited: Record<string, string> = {}): StyledNode {
const own = root.kind === "element" ? specifiedValues(root, sheet) : {};
// 継承値を土台に、自分の指定で上書きする(自分の指定が勝つ)
const specified = { ...inherited, ...own };
// 子に渡す継承値: 継承対象プロパティだけを抜き出す
const forChildren: Record<string, string> = {};
for (const p of INHERITED) if (specified[p] !== undefined) forChildren[p] = specified[p] as string;
const children = root.kind === "element" ? root.children.map((c) => styleTree(c, sheet, forChildren)) : [];
return { node: root, specified, children };
}
// display の既定はテキストなら inline、要素なら block(簡略)。none は描かない。
export function display(styled: StyledNode): "block" | "inline" | "none" {
const d = styled.specified.display;
if (d === "none") return "none";
if (d === "inline") return "inline";
return styled.node.kind === "text" ? "inline" : "block";
}
// #endregion tree{ts}④ レイアウト: スタイル木 → 位置と大きさ
ここで初めて数値(x, y, 幅, 高さ)が決まる。ブロックレイアウトは単純で強力な規則に従う。ブロックは親の幅を満たし、子は上から下へ積む:
import { type StyledNode, display } from "./style";
// レイアウト: スタイル木から、各ボックスの位置(x,y)と大きさ(width,height)を計算する。
// ここではブロックレイアウトだけを扱う——子は上から下へ縦に積む。
// 簡易ボックスモデル: margin / padding は "10px" のような単一px値を四辺に適用する。
// #region types{ts}
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export interface LayoutBox {
rect: Rect; // border-box(padding込み・margin除く)
styled: StyledNode;
children: LayoutBox[];
}
// #endregion types{ts}
const LINE_HEIGHT = 18; // テキスト1行の高さ(簡略)
// "120px" / "120" → 120。数値でなければ undefined。
function px(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
const n = parseFloat(value);
return Number.isNaN(n) ? undefined : n;
}
const prop = (s: StyledNode, name: string): number => px(s.specified[name]) ?? 0;
// #region layout{ts}
// 入口: ルートを、幅 containerWidth・原点(0,0) の中にレイアウトする。
export function layout(root: StyledNode, containerWidth: number): LayoutBox {
return layoutBlock(root, { x: 0, y: 0, width: containerWidth, height: 0 }, 0);
}
// content: 親のコンテンツ領域(padding の内側)。offsetY: その中での縦の開始位置。
function layoutBlock(styled: StyledNode, content: Rect, offsetY: number): LayoutBox {
const margin = prop(styled, "margin");
const padding = prop(styled, "padding");
// 幅: 明示 width があればそれ、無ければ親コンテンツ幅から左右marginを引いて満たす
const width = px(styled.specified.width) ?? content.width - 2 * margin;
const x = content.x + margin;
const y = content.y + offsetY + margin;
// 自分のコンテンツ領域(padding の内側)。子はここに積む。
const innerX = x + padding;
const innerY = y + padding;
const innerW = width - 2 * padding;
const children: LayoutBox[] = [];
let childOffset = 0;
for (const child of styled.children) {
if (display(child) === "none") continue; // display:none は箱を作らない
const box = layoutBlock(child, { x: innerX, y: innerY, width: innerW, height: 0 }, childOffset);
const childMargin = prop(child, "margin");
childOffset += box.rect.height + 2 * childMargin; // 次の子は前の子の下へ
children.push(box);
}
// 高さ: 明示 height、無ければ子の合計。子も無ければテキストなら1行、空なら0。
const explicit = px(styled.specified.height);
const contentHeight = children.length > 0 ? childOffset : leafHeight(styled);
const height = (explicit ?? contentHeight) + 2 * padding;
return { rect: { x, y, width, height }, styled, children };
}
// 葉ボックスの高さ: 中身のあるテキストなら1行分、それ以外は0。
function leafHeight(styled: StyledNode): number {
const n = styled.node;
return n.kind === "text" && n.text.length > 0 ? LINE_HEIGHT : 0;
}
// #endregion layout{ts}- 幅: 明示
widthがあればそれ、無ければ「親のコンテンツ幅 − 左右 margin」で満たす - 位置:
xは親のコンテンツ左端 + margin、yは親のコンテンツ上端 + 前の兄弟までの高さ + margin - 高さ: 明示
height、無ければ子の合計。葉のテキストは1行分 - padding は内側(自分の高さを増やし子を内に寄せる)、margin は外側(隣との隙間)
「幅は親から下りてきて、高さは子から上がってくる」。この向きの違いがブロックレイアウトの勘所だ。
⑤ ペイント: レイアウト → 描画コマンド
最後に、位置の決まった箱を「描画コマンドの一覧(ディスプレイリスト)」にする。実ブラウザはこのリストを GPU に渡す。ここでは背景の矩形とテキストだけを出す:
import { type LayoutBox } from "./layout";
// ペイント: レイアウト木を「描画コマンドの一覧(ディスプレイリスト)」に変換する。
// 実際のブラウザはこのリストを GPU に渡す。ここでは背景色の矩形とテキストだけを出す。
// #region types{ts}
export type DisplayCommand =
| { type: "rect"; x: number; y: number; width: number; height: number; color: string }
| { type: "text"; x: number; y: number; text: string; color: string };
// #endregion types{ts}
// #region paint{ts}
export function paint(root: LayoutBox): DisplayCommand[] {
const list: DisplayCommand[] = [];
paintBox(root, list);
return list;
}
function paintBox(box: LayoutBox, list: DisplayCommand[]): void {
// 1. 背景: background 指定があれば矩形を積む(親→子の順=子が上に来る)
const bg = box.styled.specified.background ?? box.styled.specified["background-color"];
if (bg) {
const { x, y, width, height } = box.rect;
list.push({ type: "rect", x, y, width, height, color: bg });
}
// 2. テキスト: テキストノードなら文字を積む
const node = box.styled.node;
if (node.kind === "text" && node.text.length > 0) {
list.push({ type: "text", x: box.rect.x, y: box.rect.y, text: node.text, color: box.styled.specified.color ?? "#000" });
}
// 3. 子を再帰的に描く(後に積んだものが手前)
for (const child of box.children) paintBox(child, list);
}
// #endregion paint{ts}親を先に、子を後に積むのが要だ。後のコマンドが手前に描かれるので、親の背景の上に子が正しく重なる。
動かす
下のデモは、frontend/browser の render(html, css, width) をそのまま呼んでいる。左に入力の HTML/CSS と、パイプラインが吐いた描画コマンド一覧。右は、そのコマンドをそのまま矩形と文字として描いた結果だ。座標も色もパイプラインの計算値をそのまま使っている。サンプルを切り替えて、CSS の padding / margin / height がコマンドの座標にどう効くかを見てほしい。
<div class="card"><h1 class="t">記事タイトル</h1><p class="b">本文のプレビュー。箱を縦に積んで描く。</p></div>.card{background:#eef2ff;padding:12px} .t{background:#c7d2fe;height:28px;color:#3730a3} .b{height:40px;color:#475569}これで、HTML と CSS の文字列から実際に「箱の絵」が出るところまで繋がった。ここまでがブラウザのレンダリングの背骨。
設計の観点: なぜ段に分け、なぜ再レイアウトが重いのか
- 段に分ける理由: parse/style/layout/paint を分けると、各段を並列化・キャッシュ・部分再実行できる。実ブラウザは「スタイルだけ変わった」「レイアウトも変わる」を区別して、必要な段だけやり直す
- reflow(再レイアウト)が高い:
widthやheightを変えると layout からやり直しになり、子孫全体の位置を再計算する。対してcolorやbackgroundだけなら paint からで済む。だから「レイアウトを触る変更」は重い - レイアウトスラッシング: JS で「スタイルを変える → 位置を読む → また変える」を繰り返すと、読むたびに強制同期レイアウトが走って大きく遅くなる。読みと書きをまとめろ、の理由がこれ
- 合成(compositing):
transformやopacityのアニメは layout/paint を飛ばして合成だけで動かせる(GPU)。だから「位置を動かすならleftよりtransform」と言われる
メリット・デメリットと実例
| 段 | 入力 → 出力 | やり直しの引き金 | 実ブラウザでの相当 |
|---|---|---|---|
| parse | 文字列 → 木 | 文書の変更 | HTMLパーサ、CSSパーサ |
| style | DOM × CSS → スタイル木 | class 変更、CSS 追加 | Style calculation |
| layout | スタイル木 → 位置 | width/height/位置系の変更 | Layout / reflow |
| paint | レイアウト → コマンド | 色・背景の変更 | Paint |
| (合成) | コマンド → 画面 | transform/opacity | Compositing(GPU) |
裏どり:
- Blink(Chrome) / WebKit(Safari) / Gecko(Firefox): いずれもこの parse→style→layout→paint→composite の骨格を持つ。各段が桁違いに作り込まれている(インクリメンタル更新、複数スレッド、GPU 合成)
- robinson: Matt Brubeck の教育用エンジン。この章の下敷きで、Rust で同じ5段を最小実装している
- React / Vue の「仮想DOM」との関係: あれはこのパイプラインの手前にあたり、「DOM をどう変えるか」を決める層。DOM が変われば、その先でブラウザがこのパイプラインを回す
簡略化したこと
- インラインレイアウトなし: テキストの行分割・折り返し、inline 要素の横並びは無し。テキストは1行の箱として扱う
- ボックスモデルは単一px:
margin:10pxの四辺一律のみ。10px 20px・auto・border 幅は無し - セレクタは単純セレクタのみ: 子孫結合子・擬似クラス・属性セレクタは無し
- 単位・色を解釈しない:
#f00/redをそのまま描画コマンドに載せる。em/%等の相対単位は無し - 実描画・合成なし: paint はコマンド一覧まで。ラスタライズ(canvas/GPU)と合成は範囲外
- インクリメンタル更新なし: 毎回全段を通す。実ブラウザの「必要な段だけやり直す」最適化は無し
参考資料
- Matt Brubeck, Let's build a browser engine!(robinson) — この章の下敷き
- Tali Garsiel, How Browsers Work — レンダリングパイプラインの定番解説
- Render-tree Construction, Layout, and Paint(Critical Rendering Path)
- 実装: frontend/browser