Read OSS

WebCore: DOM、レンダリングパイプライン、Web IDL バインディング

上級

前提知識

  • 第2回:WTF、メモリ管理、コアプリミティブ(Ref/RefPtr の理解が必須)
  • DOM と CSS の基礎知識(要素の階層構造、セレクタ、ボックスモデル)
  • JavaScript が DOM とやり取りする仕組みの基本的な理解

WebCore: DOM、レンダリングパイプライン、Web IDL バインディング

WebCore は WebKit の中核を担うコンポーネントです。Sources.txt に記載されているだけで 5,270 以上のソースファイルを持ち、HTML のパース、DOM 操作、CSS スタイルの解決、レイアウト、描画、メディア、ネットワーク API など、Web プラットフォーム標準のほとんどを実装しています。WebCore は、抽象的な Web 標準を具体的なレンダリング処理へと変換するレイヤーです。

この記事では、HTML のソーステキストから画面のピクセルへと至るパイプライン全体を追い、ユニークな CSS セレクタ JIT コンパイラを詳しく見ていきます。また、まったく異なるメモリ管理モデルを持つ JavaScript と C++ の DOM オブジェクトを連携させるバインディングシステムについても解説します。

DOM ツリー:Node、Element、Document

DOM は C++ オブジェクトのツリーとして表現されており、Document をルートとします。クラス階層は DOM 仕様に忠実に従っています。

classDiagram
    class EventTarget {
        +addEventListener()
        +removeEventListener()
        +dispatchEvent()
    }
    class Node {
        -TypeBitFields m_typeBitFields
        -ContainerNode* m_parentNode
        -Node* m_previousSibling
        -Node* m_nextSibling
        +nodeType() NodeType
        +parentNode() ContainerNode*
        +nodeName() String
    }
    class ContainerNode {
        -Node* m_firstChild
        -Node* m_lastChild
        +appendChild()
        +removeChild()
        +insertBefore()
    }
    class Element {
        +tagName() String
        +getAttribute()
        +setAttribute()
    }
    class Document {
        +createElement()
        +getElementById()
        +body() HTMLElement*
    }
    EventTarget <|-- Node
    Node <|-- ContainerNode
    ContainerNode <|-- Element
    ContainerNode <|-- Document
    Element <|-- HTMLElement
    Element <|-- SVGElement

Node は DOM ツリー上のすべてのオブジェクトのベースクラスです。EventTarget(イベントのディスパッチ用)と CanMakeCheckedPtr(ノードへの安全なポインタ参照用)を継承しています。ツリー構造は生ポインタで実装されており、m_parentNodem_previousSiblingm_nextSibling が各親ノードの子を侵入型の双方向連結リストとして構成します。

NodeWTF_MAKE_PREFERABLY_COMPACT_TZONE_ALLOCATED(Node) を使用している点にも注目してください。第2回で説明したとおり、すべての DOM ノードは型別に分離されたヒープに割り当てられます。また、static constexpr bool usesBuiltinTypeDescriptorTZoneCategory = true の宣言により、Node ファミリー全体がビルトイン TZone カテゴリに組み込まれます。

Document は WebKit 最大級のクラスのひとつです。DOM ツリーのルートとして機能するだけでなく、要素を生成するファクトリとして、また数十のサブシステム(スタイル解決、スクリプト実行、フォーカス管理、フルスクリーンなど)のコーディネーターとしても機能します。

ヒント: DOM 操作を追うときは ContainerNode を起点にしましょう。ツリーを変更するメソッド(appendChildremoveChildinsertBefore)はここに定義されています。ミューテーションは JavaScript のイベントハンドラをトリガーする可能性があるため、第2回で紹介した protectedThis パターンが最も頻繁に登場するのもこの場所です。

レンダリングパイプライン:DOM からピクセルへ

レンダリングパイプライン全体は、DOM ツリーを視覚的な出力へと変換する複数のフェーズで構成されています。

flowchart LR
    HTML["HTML Source"] --> PARSE["HTML Parser"]
    PARSE --> DOM["DOM Tree<br/>(Node hierarchy)"]
    DOM --> STYLE["Style Resolution<br/>(CSS matching)"]
    STYLE --> RT["Render Tree<br/>(RenderObject hierarchy)"]
    RT --> LAYOUT["Layout<br/>(geometry calculation)"]
    LAYOUT --> PAINT["Paint<br/>(display list generation)"]
    PAINT --> COMP["Compositing<br/>(GPU layers)"]
    COMP --> PIXELS["Pixels on Screen"]

各フェーズにはそれぞれ固有のツリーまたはデータ構造があります:

  1. パース — HTML パーサーはトークン化とツリー構築によって DOM を生成します。WebKit のパーサーは HTML5 仕様に準拠しており、暗黙的なタグや foster parenting といった特殊なケースにも対応しています。

  2. スタイル解決 — CSS セレクタが DOM 要素に対してマッチングされ、computed style が決定されます。後述する CSS JIT が動作するのもこのフェーズです。

  3. レンダーツリーの構築RenderObject のインスタンスからなる並列ツリーが構築されます。すべての DOM ノードが RenderObject を持つわけではなく、たとえば display: none の要素はスキップされます。RenderObject の階層は DOM 構造ではなく、視覚的な構造を反映します。

  4. レイアウト — 各 RenderObject が CSS ボックスモデルに基づいて、幾何学的な位置とサイズを計算します。複雑なページにおいて最も計算コストの高いフェーズです。

  5. ペインティング — RenderObject は自身をディスプレイリストに描画し、それが GPU バックレイヤーにコンポジットされます。

Page クラスがこれらすべてを統括します。Page は Web ページを表し、メインフレーム、ページレベルの設定、WebCore と埋め込みレイヤーを接続するさまざまなクライアントインターフェースを保持します。

パフォーマンスにとって、スタイルの無効化は非常に重要な仕組みです。DOM が変更されても、WebKit はページ全体を再レンダリングしません。代わりに、影響を受けるノードをスタイル再計算が必要な状態としてマークし、必要に応じてレイアウト、さらに再描画へとつながりますが、処理は影響を受けたサブツリーのみに限定されます。この増分的なアプローチがあるからこそ、DOM が絶えず変化するモダンな Web アプリでもレスポンシブな動作が実現できるのです。

CSS スタイル解決と CSS JIT

WebKit には、ブラウザエンジンの世界でもほかに例を見ないユニークな機能があります。CSS セレクタ向けの JIT コンパイラです。SelectorCompiler は、頻繁に使用される CSS セレクタをネイティブのマシンコードにコンパイルします。

なぜこれが重要なのでしょうか。CSS セレクタのマッチングは Web レンダリングの中で最もホットなパスのひとつです。DOM 上のすべての要素に対して、エンジンはすべての CSS ルールのセレクタが一致するかどうかをチェックしなければなりません。1,000 個の要素と 500 の CSS ルールがあるページでは、スタイル解決の1パスで最大 500,000 回ものセレクタ評価が発生します。

flowchart TD
    CSS["CSS Rules"] --> PARSE_SEL["Parse Selectors"]
    PARSE_SEL --> INTERP{"First N matches"}
    INTERP -->|"interpreted"| SLOW["C++ selector matching"]
    INTERP -->|"hot selector detected"| JIT["SelectorCompiler"]
    JIT --> NATIVE["Native machine code"]
    NATIVE --> FAST["Direct register-based matching"]
    
    SLOW --> RESULT["Match / No Match"]
    FAST --> RESULT

CSS JIT は #if ENABLE(CSS_SELECTOR_JIT) でガードされており、WebKit のアセンブラインフラストラクチャ(JSC の JIT ティアと同じ MacroAssembler)を使用して、レジスタベースの要素データを直接操作するセレクタマッチングコードを生成します。セレクタの構造を解釈的にたどる代わりに、JIT はタグ名、クラスリスト、属性値を CPU 命令で直接チェックするコードを出力します。

パフォーマンスクリティカルなパスのために特化したインフラストラクチャを惜しみなく構築するという WebKit の姿勢を象徴する好例です。CSS セレクタを JIT コンパイルするブラウザエンジンは WebKit だけです。

Web IDL バインディング:JavaScript と C++ を橋渡しする

WebKit における最も興味深いアーキテクチャ上の課題のひとつが、根本的に異なる2つのメモリ管理システムの橋渡しです。第2回で詳しく見たように、WebCore は参照カウントを使用します。一方、JavaScriptCore はガベージコレクションを採用しています。JavaScript のコードが document.getElementById('foo') を呼び出すとき、この2つの世界が連携しなければなりません。

この橋渡しを担うのが Web IDL バインディングです。Web IDL は Web API を定義する仕様記述言語で、WebKit では .idl ファイルが C++ の実装と同じ場所に配置されています:

Source/WebCore/dom/Document.idl     → defines the Document interface
Source/WebCore/dom/Document.h/.cpp  → C++ implementation

Perl スクリプト CodeGeneratorJS.pm がこれらの .idl ファイルを読み込み、バインディングレイヤーのクラスである JSDocument.hJSDocument.cpp を生成します。生成されたクラスには常に JS というプレフィックスが付くため、バインディングクラスは見分けやすくなっています。

flowchart LR
    IDL["Document.idl"] --> GEN["CodeGeneratorJS.pm"]
    GEN --> JSH["JSDocument.h"]
    GEN --> JSCPP["JSDocument.cpp"]
    
    subgraph Runtime
        JSO["JSDocument<br/>(GC-managed JS object)"] -->|"wraps"| CPP["Document<br/>(RefCounted C++ object)"]
    end
    
    JSH --> JSO
    JSCPP --> JSO

DOMWrapperWorld は JS ラッパーと C++ オブジェクトのマッピングを管理します。DOMObjectWrapperMapHashMap<void*, JSC::Weak<JSC::JSObject>> として定義)を保持し、C++ オブジェクトのポインタから対応する JS ラッパーオブジェクトへのマッピングを維持します。

難しいのはライフサイクルの管理です。JSDocument という JS ラッパーは C++ の Document への Ref を保持し、それを生き続けさせます。しかし JS ラッパー自体は JSC のガベージコレクタによって管理されます。ラッパーへの JS 参照がすべて解放されると、GC がそれを回収するかもしれません。しかし C++ の Document はまだ生きている可能性があります(レンダーツリーに保持されているかもしれない)。その後 JavaScript が同じ Document にアクセスしようとした場合、新しい JSDocument ラッパーを作成するか、あるいはより理想的には、ラッパーマップから既存のラッパーを見つけ出す必要があります。

DOMWrapperWorld には、enum で定義された3種類があります:

  • Normal — メインページの JavaScript ワールド。
  • User — ブラウザ拡張機能やユーザースクリプト用。
  • Internal — WebKit 自身の JavaScript(メディアコントロールなど)用。

各ワールドは独自のラッパーセットを持つため、ページの JavaScript が拡張機能によって注入されたオブジェクトを見ることはなく、その逆も成り立ちます。

プラットフォーム抽象化:ChromeClient と FrameLoaderClient

WebCore はプラットフォームに依存しないよう設計されており、macOS の API を直接呼び出すことはありません。代わりに抽象クライアントインターフェースを定義し、埋め込みレイヤーがそれを実装する仕組みになっています。

最も重要なのが ChromeClient です。アラートダイアログの表示、ウィンドウのリサイズ、スクロールの要求、新しいウィンドウの作成、フルスクリーンの管理、フォーカスの制御など、「クロム」(ブラウザの UI)が行うべき操作をすべて扱う巨大な抽象クラスです。

classDiagram
    class ChromeClient {
        <<abstract>>
        +createWindow()
        +runJavaScriptAlert()
        +setScrollPosition()
        +invalidateRootView()
        +contentsSizeChanged()
    }
    class WebChromeClient {
        -WebPage& m_page
        +createWindow()
        +runJavaScriptAlert()
    }
    class LegacyChromeClient {
        -WebView* m_webView
    }
    ChromeClient <|-- WebChromeClient : "WebKit2 impl"
    ChromeClient <|-- LegacyChromeClient : "WebKitLegacy impl"

WebKit2 のマルチプロセスアーキテクチャでは、WebChromeClient(WebContent プロセス内)がこれらのメソッドを実装する際に IPC メッセージを UI プロセスへ送信し、実際の UI 操作はそちら側で行われます。WebKitLegacy(シングルプロセス)の場合は、Cocoa や GTK の API を直接呼び出す実装になっています。

このパターンは FrameLoaderClient(ナビゲーションポリシー、リソースの読み込み)、EditorClient(テキスト編集、スペルチェック)など、さまざまな場所で繰り返されています。このデザインにより、WebCore はサンドボックス化された WebContent プロセス上で動いているのか、シングルプロセスのアプリ上で動いているのかを意識することなく機能できます。

次回予告

WebCore が DOM からピクセルまでの Web 標準をどのように実装しているかを見てきました。第4回では WebKit2 レイヤーに移り、マルチプロセスアーキテクチャを詳しく掘り下げます。取り上げるのは、カスタム IPC フレームワーク、.messages.in によるコード生成システム、そしてプロセス間でオブジェクトをミラーリングするプロキシパターンです。WebKit のセキュリティモデルに取り組んでいる方や、クロスプロセスの問題をデバッグする方にとって、IPC の仕組みを理解することは欠かせません。