Skip to content

ブラウザ(レンダリングパイプライン)

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   (描画コマンド化)


                                          描画コマンド一覧 ─▶ 画面
レンダリングパイプライン。HTML と CSS は別々にパースされ、style で合流する。以降 layout → paint と一方向に流れ、各段は前段の出力だけを入力に取る。だから段ごとに切り離して理解・テストできる。SSR(mini-next)が『木→文字列』だったのに対し、ここは『文字列→木→…→画面』

先に押さえることが3つある。

  1. 段階に分ける: parse → style → layout → paint。各段は前段の出力だけを入力に取る。だから単体で理解・テストできる
  2. スタイルはカスケード + 継承: 詳細度の高い規則が勝ち、color などは子(テキスト含む)へ受け継ぐ
  3. レイアウトは箱の入れ子: ブロックは親幅を満たし、子は縦に積む。位置は「親のどこに、前の兄弟の下に」で決まる

① HTMLパース: 文字列 → DOM木

SSR の逆。文字列を先頭から食べ進む再帰下降パーサで木を組む。< を覗いて要素かテキストかを決める:

ts
// 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 の単純セレクタのみ:

ts
// 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数] のタプルで表し、比較する。後で同じプロパティが競合したとき、この強さで勝敗を決める:

ts
// 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 → スタイル木

各要素に、マッチした宣言を貼り付ける。同じプロパティが複数当たったら詳細度の高い規則が後勝ち(カスケード):

ts
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 を親に指定すると、子や中のテキストにも伝わる。一方 backgroundmargin は箱固有なので伝わらない。継承対象だけを子へ渡す:

ts
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, 幅, 高さ)が決まる。ブロックレイアウトは単純で強力な規則に従う。ブロックは親の幅を満たし、子は上から下へ積む:

ts
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 に渡す。ここでは背景の矩形とテキストだけを出す:

ts
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/browserrender(html, css, width) をそのまま呼んでいる。左に入力の HTML/CSS と、パイプラインが吐いた描画コマンド一覧。右は、そのコマンドをそのまま矩形と文字として描いた結果だ。座標も色もパイプラインの計算値をそのまま使っている。サンプルを切り替えて、CSS の padding / margin / height がコマンドの座標にどう効くかを見てほしい。

デモbrowser(レンダリングパイプライン)描画コマンド 4
入力: HTML
<div class="card"><h1 class="t">記事タイトル</h1><p class="b">本文のプレビュー。箱を縦に積んで描く。</p></div>
入力: CSS
.card{background:#eef2ff;padding:12px} .t{background:#c7d2fe;height:28px;color:#3730a3} .b{height:40px;color:#475569}
最終段: 描画コマンド(ディスプレイリスト)
矩形(0,0) 300×92 #eef2ff
矩形(12,12) 276×28 #c7d2fe
文字(12,12) "記事タイトル"
文字(12,40) "本文のプレビュー。箱を縦に積んで描く。"
描画結果(コマンドを実際に描く)
記事タイトル本文のプレビュー。箱を縦に積んで描く。
幅 300px・高さ 92px。矩形は背景、文字はテキストノード。パイプラインが計算した座標そのまま
HTML→DOM木 / CSS→規則 → style(カスケード+継承) → layout(縦積み) → paint(コマンド)左のコマンド一覧を、右でそのまま矩形と文字として描いている

これで、HTML と CSS の文字列から実際に「箱の絵」が出るところまで繋がった。ここまでがブラウザのレンダリングの背骨。

設計の観点: なぜ段に分け、なぜ再レイアウトが重いのか

  • 段に分ける理由: parse/style/layout/paint を分けると、各段を並列化・キャッシュ・部分再実行できる。実ブラウザは「スタイルだけ変わった」「レイアウトも変わる」を区別して、必要な段だけやり直す
  • reflow(再レイアウト)が高い: widthheight を変えると layout からやり直しになり、子孫全体の位置を再計算する。対して colorbackground だけなら paint からで済む。だから「レイアウトを触る変更」は重い
  • レイアウトスラッシング: JS で「スタイルを変える → 位置を読む → また変える」を繰り返すと、読むたびに強制同期レイアウトが走って大きく遅くなる。読みと書きをまとめろ、の理由がこれ
  • 合成(compositing): transformopacity のアニメは layout/paint を飛ばして合成だけで動かせる(GPU)。だから「位置を動かすなら left より transform」と言われる

メリット・デメリットと実例

入力 → 出力やり直しの引き金実ブラウザでの相当
parse文字列 → 木文書の変更HTMLパーサ、CSSパーサ
styleDOM × CSS → スタイル木class 変更、CSS 追加Style calculation
layoutスタイル木 → 位置width/height/位置系の変更Layout / reflow
paintレイアウト → コマンド色・背景の変更Paint
(合成)コマンド → 画面transform/opacityCompositing(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)と合成は範囲外
  • インクリメンタル更新なし: 毎回全段を通す。実ブラウザの「必要な段だけやり直す」最適化は無し

参考資料