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

Mariko Kosaka

レンダラ プロセスの内部動作

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

レンダラ プロセスはウェブ パフォーマンスの多くの側面に関係します。レンダラ プロセスでは多くのことが行われるため、この投稿では一般的な概要のみにとどめます。さらに詳しい情報については、ウェブの基礎 - パフォーマンスのセクションにさらに多くのリソースがあります。

レンダラ プロセスによるウェブ コンテンツの処理

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

レンダラ プロセスの主な役割は、HTML、CSS、JavaScript を、ユーザーが操作できるウェブページにすることです。

レンダラ プロセス
図 1: メインスレッド、ワーカー スレッド、コンポジタ スレッド、ラスター スレッドを内部に有するレンダラ プロセス

解析

DOM の構築

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

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

HTML ドキュメントを DOM に解析する方法は、HTML 規格で定義されています。ブラウザに HTML をフィードしてもエラーはスローされないことに気づいたかもしれません。たとえば、終了タグ </p> がないことは有効な HTML です。Hi! <b>I'm <i>Chrome</b>!</i>(i タグの前に b タグが閉じている)のような誤ったマークアップは、Hi! <b>I'm <i>Chrome</i></b><i>!</i> と記述した場合と同様に扱われます。これは、HTML 仕様がこれらのエラーを適切に処理するように設計されているためです。この処理の詳細については、HTML 仕様の「パーサーにおけるエラー処理の概要と異常なケース」セクションをご覧ください。

サブリソースを読み込んでいます

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

DOM
図 2: HTML を解析して DOM ツリーを作成するメインスレッド

JavaScript では、コードの解析、

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

リソースの読み込み方法をブラウザに伝えるヒント

ウェブ デベロッパーは、リソースをうまく読み込むために、ブラウザにヒントを送信するさまざまな方法があります。JavaScript で document.write() が使用されていない場合は、async 属性または defer 属性を <script> タグに追加できます。その後、ブラウザは 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 内になくてもレイアウト ツリーに含まれます。

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

ページのレイアウトを決めるのは、困難な作業です。上から下へのブロックフローのような最も単純なページ レイアウトでも、フォントの大きさと改行位置を考慮する必要があります。これらは段落のサイズと形に影響し、次に段落を配置する場所に影響します。

CSS では、要素を片側にフローティングさせたり、オーバーフロー アイテムをマスクしたり、記述方向を変更したりできます。ご想像のとおり、このレイアウト ステージには大変なタスクがあります。Chrome では、エンジニア チーム全体がレイアウトの作成に取り組み、彼らの作品の詳細については、BlinkOn Conference の講演がいくつか録画されており、非常に興味深いものとなっています。

Paint

お絵描きゲーム
図 7: キャンバスの前で絵筆を手に持っている人物が、先に円と正方形のどちらを描くべきか考えている

DOM、スタイル、レイアウトだけでは、ページをレンダリングできません。絵画を再現するとします要素のサイズ、形状、位置は把握していますが、描画する順序を決める必要があります。

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

Z-Index エラー
図 8: ページ要素が HTML マークアップの順序で表示され、Z-Index が考慮されないために画像が正しくレンダリングされていない

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

ペイント レコード
図 9: レイアウト ツリーをたどり、ペイント レコードを生成するメインスレッド

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

図 10: DOM+ スタイル、レイアウト、ペイントツリーの生成順

レンダリング パイプラインで把握すべき最も重要なことは、各ステップで、前のオペレーションの結果を使用して新しいデータが作成されることです。たとえば、レイアウト ツリーに何かが変更された場合、ドキュメントの影響を受ける部分に対してペイント順序を再生成する必要があります。

要素をアニメーション化する場合、ブラウザはすべてのフレーム間でこれらの処理を実行する必要があります。ほとんどのディスプレイでは、画面が 1 秒間に 60 回(60 fps)更新されます。フレームごとに画面上を移動すると、アニメーションが滑らかに表示されます。ただし、アニメーションの中間にフレームがない場合、ページは「ジャンク」しているように見えます。

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

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

JavaScript による jage ジャンク
図 12: タイムライン上のアニメーション フレーム(JavaScript によって 1 つのフレームがブロックされる)

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

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

合成

ページをどのように描画しますか?

図 14: 単純なラスター処理のアニメーション

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

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

合成とは

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

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

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

レイヤへの分割

どの要素をどのレイヤに配置する必要があるかを特定するために、メインスレッドはレイアウト ツリーを順に確認してレイヤツリーを作成します(この部分は DevTools のパフォーマンス パネルで「Update Layer Tree」と呼ばれます)。ページ内の別レイヤにすべき部分(スライドイン サイドメニューなど)でレイヤが表示されない場合は、CSS で will-change 属性を使用してブラウザに伝えることができます。

レイヤツリー
図 16: レイヤツリーを生成するレイアウト ツリーをたどるメインスレッド

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

メインスレッドからのラスターと合成

レイヤツリーが作成されてペイントの順序が決定されると、メインスレッドはその情報をコンポジタ スレッドに commit します。その後、コンポジタ スレッドが各レイヤをラスタライズします。レイヤはページ全体の長さほど大きくなる場合があるため、コンポジタ スレッドがレイヤをタイルに分割し、各タイルをラスター スレッドに送信します。ラスター スレッドは各タイルをラスタライズして GPU メモリに格納します。

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

ビューポート内(または付近)のものを最初にラスターできるように、コンポジタ スレッドが異なるラスター スレッドに優先順位を付けることができます。レイヤには、ズームイン操作などを処理するための、さまざまな解像度の複数のタイルもあります。

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

クワッドを描画する メモリ内のタイルの場所や、ページ合成を考慮したタイルを描画するページ内の場所などの情報が含まれます。
コンポジタ フレーム ページのフレームを表す描画クワッドのコレクション。

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

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

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

まとめ

この投稿では、解析から合成までのレンダリング パイプラインの状況について確認しました。これでウェブサイトのパフォーマンス最適化について 理解を深めていただければ幸いです。

このシリーズの次の投稿で最後の投稿では、コンポジタ スレッドについて詳しく説明し、mouse moveclick などのユーザー入力を受け取ったときにどうなるかを確認します。

この投稿はお楽しみいただけましたか?今後の投稿についてご質問やご提案がありましたら、以下のコメント セクションまたは Twitter の @kosamari からお寄せください。

次のステップ: 入力がコンポジタに届く