最新のウェブブラウザの詳細(パート 3)

Mariko Kosaka

レンダラ プロセスの内部の仕組み

この記事は、ブラウザの仕組みを探る 4 部構成のブログシリーズのパート 3 です。前回、マルチプロセス アーキテクチャナビゲーション フローについて説明しました。この記事では、レンダラ プロセス内で何が行われるかについて説明します。

レンダラ プロセスは、ウェブ パフォーマンスの多くの側面に影響します。レンダラ プロセス内では多くの処理が行われるため、この投稿では一般的な概要のみを説明します。詳しくは、ウェブの基礎のパフォーマンス セクションで、その他のリソースをご覧ください。

レンダラ プロセスがウェブ コンテンツを処理する

レンダラ プロセスは、タブ内で発生するすべての処理を担当します。レンダラ プロセスでは、メインスレッドがユーザーに送信するコードのほとんどを処理します。ウェブワーカーまたは Service Worker を使用している場合、JavaScript の一部がワーカー スレッドによって処理されることがあります。コンポジタ スレッドとラスター スレッドもレンダラ プロセス内で実行され、ページを効率的かつスムーズにレンダリングします。

レンダラ プロセスのコアジョブは、HTML、CSS、JavaScript をユーザーが操作できるウェブページに変換することです。

レンダラ プロセス
図 1: メインスレッド、ワーカー スレッド、コンポジタ スレッド、ラスター スレッドを含むレンダラ プロセス

解析

DOM の作成

レンダラ プロセスがナビゲーションの commit メッセージを受信して HTML データを受信し始めると、メインスレッドはテキスト文字列(HTML)を解析し、それをDocument Object Model(DOM)に変換します。

DOM は、ページのブラウザ内部表現であり、ウェブ デベロッパーが JavaScript を介して操作できるデータ構造と API でもあります。

HTML ドキュメントを DOM に解析することは、HTML 標準で定義されています。ブラウザに HTML をフィードしてもエラーはスローされないことに気付いたかもしれません。たとえば、閉じ </p> タグがない HTML は有効です。Hi! <b>I'm <i>Chrome</b>!</i> などの誤ったマークアップ(b タグが i タグの前に閉じられている)は、Hi! <b>I'm <i>Chrome</i></b><i>!</i> と記述した場合と同じように処理されます。これは、HTML 仕様がそのようなエラーを適切に処理するように設計されているためです。これらの処理方法について詳しくは、HTML 仕様の「An introduction to error handling and strange cases in the parser」セクションをご覧ください。

サブリソースの読み込み

ウェブサイトでは通常、画像、CSS、JavaScript などの外部リソースを使用します。これらのファイルは、ネットワークまたはキャッシュから読み込む必要があります。メインスレッドは、DOM を構築するために解析中に見つけたときに、それらを 1 つずつリクエストできますが、速度を上げるために「プリロード スキャナ」が同時に実行されます。HTML ドキュメントに <img><link> などの要素が含まれている場合、プリロード スキャナは HTML パーサーによって生成されたトークンをプレビューし、ブラウザ プロセスのネットワーク スレッドにリクエストを送信します。

DOM
図 2: メインスレッドが HTML を解析して DOM ツリーを構築している様子

JavaScript が解析をブロックする

HTML パーサーは <script> タグを見つけると、HTML ドキュメントの解析を一時停止し、JavaScript コードを読み込み、解析して実行する必要があります。なぜなら、JavaScript は document.write() などの機能を使用してドキュメントの形状を変更し、DOM 構造全体を変更できるためです(HTML 仕様の解析モデルの概要にわかりやすい図があります)。そのため、HTML パーサーは、JavaScript が実行されるまで待ってから、HTML ドキュメントの解析を再開する必要があります。JavaScript の実行で何が起こるかについては、V8 チームによる講演とブログ投稿をご覧ください。

リソースの読み込み方法に関するブラウザへのヒント

ウェブ デベロッパーがリソースを適切に読み込むためにブラウザにヒントを送信する方法はたくさんあります。JavaScript で document.write() を使用していない場合は、<script> タグに async 属性または defer 属性を追加できます。ブラウザは JavaScript コードを非同期で読み込んで実行し、解析をブロックしません。適切な場合は、JavaScript モジュールを使用することもできます。<link rel="preload"> は、現在のナビゲーションにリソースが絶対に必要であり、できるだけ早くダウンロードしたいことをブラウザに通知する方法です。詳しくは、リソースの優先順位付け - ブラウザを活用するをご覧ください。

スタイルの計算

ページの要素は CSS でスタイル設定できるため、DOM だけではページの外観を把握できません。メインスレッドは CSS を解析し、各 DOM ノードの計算済みスタイルを決定します。これは、CSS セレクタに基づいて各要素に適用されるスタイルの種類に関する情報です。この情報は、DevTools の computed セクションで確認できます。

計算済みスタイル
図 3: メインスレッドが CSS を解析して計算スタイルを追加する

CSS を指定していなくても、各 DOM ノードには計算されたスタイルがあります。<h1> タグは <h2> タグよりも大きく表示され、各要素に余白が定義されます。これは、ブラウザにデフォルトのスタイルシートが存在するためです。Chrome のデフォルトの CSS を確認するには、ソースコードをこちらで確認してください。

レイアウト

レンダラ プロセスはドキュメントの構造と各ノードのスタイルを認識しましたが、ページをレンダリングするには不十分です。電話で友人に絵を説明しようとしているとします。「大きな赤い円と小さな青い正方形がある」という情報だけでは、絵が正確にどのようなものかを友人が把握することはできません。

人間の FAX 機のゲーム
図 4: 絵画の前に立っている人物と、別の人物に接続された電話回線

レイアウトは、要素のジオメトリを見つけるプロセスです。メインスレッドは DOM と計算されたスタイルを走査し、x 座標、y 座標、境界ボックスのサイズなどの情報を含むレイアウト ツリーを作成します。レイアウト ツリーは DOM ツリーと似ていますが、ページに表示される内容に関連する情報のみが含まれます。display: none が適用されている場合、その要素はレイアウト ツリーに含まれません(ただし、visibility: hidden を含む要素はレイアウト ツリーに含まれます)。同様に、p::before{content:"Hi!"} などのコンテンツを含む疑似要素が適用されている場合、DOM に含まれていなくても、その要素はレイアウト ツリーに含まれます。

レイアウト
図 5: 計算されたスタイルを使用して DOM ツリーを走査し、レイアウト ツリーを生成するメインスレッド
図 6: 改行の変更により移動した段落のボックス レイアウト

ページのレイアウトを決定するのは難しい作業です。上から下へのブロックフローなど、最もシンプルなページ レイアウトでも、フォントサイズと改行位置を検討する必要があります。これらは段落のサイズと形状に影響し、次の段落の配置に影響します。

CSS では、要素を片側に浮かせて配置したり、オーバーフロー アイテムをマスクしたり、書字方向を変更したりできます。このレイアウト ステージには大きなタスクがあります。Chrome では、エンジニアのチーム全体がレイアウトに取り組んでいます。彼らの取り組みの詳細を確認するには、BlinkOn カンファレンスのいくつかの講演を視聴することをおすすめします。

Paint

描画ゲーム
図 7: キャンバスの前で絵筆を持ち、最初に円を描くか四角を描くか迷っている人物

DOM、スタイル、レイアウトがあっても、ページをレンダリングするには不十分です。絵画を再現しようとしているとします。要素のサイズ、形状、位置はわかっても、ペイントする順序を判断する必要があります。

たとえば、特定の要素に z-index が設定されている場合、HTML に記述されている要素の順序でペイントすると、レンダリングが正しく行われません。

z-index エラー
図 8: HTML マークアップの順序で表示されるページ要素。z インデックスが考慮されていないため、画像が正しくレンダリングされない結果になっています

このペイント ステップで、メインスレッドはレイアウト ツリーを走査してペイント レコードを作成します。ペイント レコードは、「まず背景、次にテキスト、次に長方形」など、ペイント プロセスのメモです。JavaScript を使用して <canvas> 要素に描画したことがある場合は、このプロセスに慣れているかもしれません。

ペイント レコード
図 9: メインスレッドがレイアウト ツリーを走査してペイント レコードを生成する

レンダリング パイプラインの更新に費用がかかる

図 10: DOM+Style、Layout、Paint の各ツリーが生成される順序

レンダリング パイプラインで理解しておくべき最も重要なことは、各ステップで前の操作の結果を使用して新しいデータが作成されることです。たとえば、レイアウト ツリーが変更された場合は、ドキュメントの該当する部分のペイント順序を再生成する必要があります。

要素をアニメーション化している場合、ブラウザはフレームごとにこれらのオペレーションを実行する必要があります。Google のディスプレイのほとんどは、1 秒あたり 60 回(60 fps)画面を更新します。フレームごとに画面上のオブジェクトを移動すると、人間の目にはアニメーションがスムーズに見えます。ただし、アニメーションが途中のフレームをスキップすると、ページが「ぎくしゃく」と表示されます。

フレームが欠落しているジャンク
図 11: タイムライン上のアニメーション フレーム

レンダリング オペレーションが画面の更新に追いついている場合でも、これらの計算はメインスレッドで実行されるため、アプリが JavaScript を実行しているときにブロックされる可能性があります。

JavaScript による jage jank
図 12: タイムライン上のアニメーション フレーム。1 つのフレームが JavaScript によってブロックされています

JavaScript オペレーションを小さなチャンクに分割し、requestAnimationFrame() を使用してフレームごとに実行するようにスケジュールできます。このトピックについて詳しくは、JavaScript の実行を最適化するをご覧ください。メインスレッドのブロックを回避するために、JavaScript をウェブワーカーで実行することもできます。

アニメーション フレームをリクエストする
図 13: アニメーション フレームを含むタイムラインで実行される JavaScript の小さなチャンク

合成

ページを描画する方法

図 14: ナイーブなラスタリング プロセスのアニメーション

ブラウザはドキュメントの構造、各要素のスタイル、ページのジオメトリ、ペイント順序を把握したうえで、どのようにページを描画するのでしょうか。この情報を画面上のピクセルにすることをラスタライズといいます。

これを処理する単純な方法としては、ビューポート内の部分をラスター化する方法があります。ユーザーがページをスクロールすると、ラスター化されたフレームが移動し、不足している部分をラスター化して補完します。これは、Chrome が最初にリリースされたときの、ラスタライズの処理方法です。ただし、最新のブラウザでは、合成と呼ばれるより高度なプロセスが実行されます。

合成とは

図 15: 合成プロセスのアニメーション

コンポジットは、ページの一部をレイヤに分割し、個別にラスタライズして、コンポジタ スレッドと呼ばれる別のスレッドでページとして合成する手法です。スクロールが発生した場合、レイヤはすでにラスタライズされているため、新しいフレームを合成する必要があります。アニメーションは、レイヤを移動して新しいフレームを合成することで、同様の方法で実現できます。

ウェブサイトがレイヤにどのように分割されているかを確認するには、DevTools の [レイヤ] パネルを使用します。

レイヤに分割する

どの要素をどのレイヤに配置する必要があるかを把握するために、メインスレッドはレイアウト ツリーを走査してレイヤ ツリーを作成します(この部分は、DevTools のパフォーマンス パネルで「レイヤ ツリーを更新」と呼ばれます)。ページの特定の部分(スライドイン サイドメニューなど)が個別のレイヤとして認識されない場合は、CSS で will-change 属性を使用してブラウザにヒントを提供できます。

レイヤツリー
図 16: レイアウト ツリーを走査してレイヤ ツリーを生成するメインスレッド

すべての要素にレイヤを適用したくなりますが、過剰な数のレイヤで合成すると、フレームごとにページの小さな部分をラスタライズするよりもオペレーションが遅くなる可能性があるため、アプリケーションのレンダリング パフォーマンスを測定することが重要です。オントピックの詳細については、コンポーザ 専用のプロパティに固執し、レイヤ数を管理するをご覧ください。

メインスレッドからラスター化と合成をオフにする

レイヤツリーが作成され、ペイント順序が決定されると、メインスレッドはコンポジタ スレッドにその情報を commit します。コンポジタ スレッドは、各レイヤをラスタライズします。レイヤはページ全体のように大きくなるため、コンポジタ スレッドはレイヤをタイルに分割し、各タイルをラスター スレッドに送信します。ラスター スレッドは、各タイルをラスタ化し、GPU メモリに保存します。

ラスター
図 17: ラスター スレッドがタイルのビットマップを作成して GPU に送信する

コンポジタ スレッドは、ビューポート内(またはその近く)のオブジェクトを最初にラスタ化できるように、さまざまなラスタ スレッドに優先度を設定できます。レイヤには、ズームイン操作などを処理するために、さまざまな解像度に対応した複数のタイリングもあります。

タイルがラスタ化されると、コンポジタ スレッドはドロー クアッドと呼ばれるタイルの情報を収集してコンポジタ フレームを作成します。

四角形を描画する メモリ内のタイル位置や、ページ合成を考慮したタイル描画位置などの情報が含まれます。
コンポーザ フレーム ページのフレームを表す描画四角形のコレクション。

コンポジタ フレームは、IPC を介してブラウザ プロセスに送信されます。この時点で、ブラウザ UI の変更の場合は UI スレッドから、拡張機能の場合は他のレンダラ プロセスから、別のコンポーズ フレームを追加できます。これらのコンポジタ フレームは GPU に送信され、画面に表示されます。スクロール イベントが届くと、コンポーザ スレッドは別のコンポーザ フレームを作成して GPU に送信します。

composit
図 18: コンポジット フレームを作成するコンポジタ スレッド。フレームがブラウザ プロセスに送信され、次に GPU に送信されます

合成のメリットは、メインスレッドを介さずに行えることです。コンポジタ スレッドは、スタイルの計算や JavaScript の実行を待つ必要はありません。そのため、スムーズなパフォーマンスを得るには、アニメーションのみをコンポーズすることが最適とされています。レイアウトまたはペイントを再度計算する必要がある場合は、メインスレッドを関与させる必要があります。

まとめ

この投稿では、解析から合成までのレンダリング パイプラインについて説明しました。これで、ウェブサイトのパフォーマンスの最適化について詳しく学ぶことができます。

このシリーズの次の投稿(最終回)では、コンポジタ スレッドについて詳しく説明し、mouse moveclick などのユーザー入力が届いたときに何が起こるかを確認します。

投稿はいかがでしたか?今後の記事についてご質問やご提案がございましたら、以下のコメント欄または Twitter の @kosamari までお寄せください。

次へ: コンポジタへの入力の開始