ブラウザがWebページを表示する仕組みをまとめる
ふとWebアプリのパフォーマンスを確認したいと思い、Chrome DevToolsのPerformanceタブを見ます。ネットワークパネルを見ても、いろんなリソースを取得していることしかわかりませんし、メインスレッドのパネルを見ても、いろんな処理が実行されていることしかわかりません。パフォーマンス最適化の方法はわからなくても良いので、せめて、せめて何が起きているかくらいはなんとなくでも把握したいです。
調べてみると、ブラウザがWebページを表示する仕組みであるクリティカルレンダリングパスというものが重要だとわかりました。最適化の観点では、ページの初回読み込み時はもちろん、その後のユーザー操作時にWebアプリがどのくらいキビキビ動くかにも関わっていそうです。
この投稿は、ブラウザがWebページを表示する仕組みを自分の理解のためにまとめたものです。
クリティカルレンダリングパス
クリティカルレンダリングパスとは、HTMLやCSS、JavaScriptを画面上のピクセルに変換して表示する一連の処理です。クリティカルレンダリングパスには以下のステップが存在します。
- DOMの構築
- CSSOMの構築
- スタイル (レンダーツリーの構築)
- レイアウト
- ペイント + 合成
DOMの構築やCSSOMの構築をパースといい、それ以降の処理をレンダリングと呼びます。
クリティカルレンダリングパスを最適化することによって、ページの初回読み込みの短縮や、その後のユーザー操作に対する応答速度が向上します。クリティカルレンダリングパスは、WebサーバーからHTMLを受け取ってから最終的にWebページを画面に表示する一連の流れを表しているため、最適化で初回読み込みは短縮できます。さらに、ユーザー操作によって画面が更新されたときにもクリティカルレンダリングパス上のステップが実行されるため、応答性も向上します。
また、レンダリングはメインスレッドで実行されるため、この処理に時間がかかってしまうとJavaScriptの実行が遅延され、応答性が悪くなる可能性があります。
以下はクリティカルレンダリングパスの概要を表す図です。
JavaScriptの実行というのは、DOMやCSSOMを変更するJavaScriptのプログラムの実行を表しています。クリティカルレンダリングパスのステップには含まれていませんが、DOM構築中にHTMLにスクリプトタグが存在する場合やイベントの処理などで実行されることがあります。
ここから、クリティカルレンダリングパスの各ステップをまとめていきます。
1. DOMの構築
DOMの構築は、受け取ったHTMLからDOM(Document Object Model)と呼ばれるデータを構築するステップで、HTMLのパース処理です。DOMとはHTMLやXML文書を表す内部的なデータであり、プログラムからアクセスして操作することができます。ブラウザはWebサーバーなどからHTMLを受け取ると、それを解析してDOMを構築していきます。
基本的にDOMにスタイルの情報は無く、スタイルを担うデータは後述するCSSOMです。例外としてはHTMLのstyle属性で設定されたスタイル情報はDOMに保存されています。
DOMの構築は段階的に行うことができるため、部分的なHTMLからもDOMを構築することができます。ブラウザはHTMLをストリーミング方式で処理するため、部分的なデータが渡されることがあります。DOMの構築は段階的に行うことができるため、HTMLの断片を受け取るとすぐにDOMの構築を開始することができます。
DOMの例としては、以下のようなHTMLで、
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link href="style.css" rel="stylesheet" />
<title>Critical Path</title>
</head>
<body>
<p>Hello, <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg" /></div>
</body>
</html>
以下のようなDOMが構築されます。
リソースの読み込み
DOM構築のためのHTML解析中にリソースを検出すると、それらの読み込みを行います。リソースの例としては画像やフォント、CSSやJavaScriptなどがあります。
それらのリソースの中には、ブロックリソースと呼ばれる、クリティカルレンダリングパスをブロックするリソースがあります。ブロックリソースは、レンダリングをブロックするレンダーブロックリソース、HTMLのパースをブロックするパーサーブロックリソースがあります。画像やフォントなどのリソースはブロックリソースではなく、これらの読み込みは待機されません。
レンダーブロックリソースとしては、<link rel="stylesheet>"
のうちdisable
属性やデバイス固有のmedia
属性がないものなどが挙げられます。パーサーがこのリソースを検出すると、CSSを読み込んで後述するCSSOMを構築するまでレンダリングを待機させます。一方で、CSSの読み込みによってHTMLパーサーはブロックされず、並行して残りのHTMLをパースしてDOMを構築することができます。DOMとCSSOMが揃ったタイミングでレンダリングが実行されます。
CSSが基本的にレンダーブロックリソースになるのは、FOUC (Flash of Unstyled Content)と呼ばれる、スタイルが適用されていないページが一瞬表示される現象を防ぐためです。もしもCSSがレンダーブロックリソースではなかったら、CSSが読み込まれてCSSOMが構築される前に、スタイル情報のないDOMだけを使用して画面が描画される可能性があるということです。
パーサーブロックリソースとしては、<script>
タグのうちdefer
属性やasync
属性、type="module"
をもたないものなどが挙げられます。こちらはレンダーブロックリソースと違い、リソースの読み込みによってHTMLパーサーがブロックされます。リソースが読み込まれるとスクリプトが実行され、完了を待ってからパーサーが再びDOMの構築を行います。
JavaScriptが基本的にパーサーブロックリソースになるのは、JavaScriptがDOMやCSSOMを変更できるからです。なぜDOMやCSSOMを変更できるとパーサーブロックリソースにする必要があるのかについて正確な情報は見つけられなかったのですが、ChatGPTに聞くと「HTMLの整合性と予測可能な解析順序を維持するため」という回答が得られました。
パーサーブロックリソースは実質的にレンダーブロックリソースでもあります。クリティカルレンダリングパスは、パースのあとにレンダリングが実行されるため、パースがブロックされるということはレンダリングもブロックされていると言えます。
<script>
タグでは、defer
属性やasync
属性、type=module"
を使用するとスクリプトの読み込みに限ってはパーサーをブロックしないようになります。defer
属性をつけるとDOM構築後のDOMContentLoaded
イベントの直前にスクリプトが実行され、async
属性をつけると非同期でスクリプトが読み込まれ、読み込みが完了するとすぐにスクリプトを実行します。type="module"
はdefer
属性と似た挙動で、type="module"
とasync
属性を使用すると、async
属性と似た挙動になります。
defer
属性はDOM構築後のDOMContentLoaded
イベントの直前にスクリプトが実行されるため、スクリプト実行でHTMLパーサーをブロックすることはないのですが、async
属性はスクリプトを読み込み終わったらすぐにスクリプトを実行するため、DOM構築中にはHTMLパーサーを中断させる可能性があります。
パーサーブロックリソースがパーサーをブロック中に他のリソースの検出が遅れてしまうという問題は、プリロードスキャナと呼ばれるセカンダリHTMLパーサで軽減することができます。プリロードスキャナは、メインのHTMLパーサーと並行して動作して、検出したリソースをその場で読み込みます。メインのHTMLパーサーがJavaScriptの処理などによってブロックされている間に、その先に存在するリソースを検出して読み込むことができます。これによって、次にメインのHTMLパーサーがリソースに到達した時点で読み込みが完了している可能性が高くなります。
ただ、プリロードスキャナはJavaScriptを実行しないため、例えば動的に挿入されたスクリプトタグなどを検出することはできません。
2. CSSOMの構築
CSSOMの構築は、CSSからCSSOM(CSS Object Model)と呼ばれるデータを構築するステップで、CSSのパース処理です。CSSOMとは、すべてのCSSセレクタとセレクタに関連するプロパティを、ツリー形式のデータにしたものです。
CSSOMの構築はDOMと違って段階的には行われません。CSSはスタイルの上書きが発生する可能性があるため、すべてのCSSを解析し終えるまでCSSOMは完成しません。例えば.foo { color: red; }
と.foo.bar { color: blue; }
というCSSがあるとき、foo
とbar
の両方をクラスに持つ要素には、.foo {}
のスタイルが適用された後、.foo.bar {}
のスタイルで上書きされ、結果としてcolor: blue;
が適用されます。
そのため、リソースの読みこみでも説明したように、検出したCSSをすべて読み込んで処理するまでレンダリングはブロックされます。FOUCの他にも、部分的なCSSの適用によって正しくないスタイルが表示される可能性もあるということです。
CSSOMの例としては、以下のようなCSSで、
body {
font-size: 16px;
}
p {
font-weight: bold;
}
span {
color: red;
}
p span {
display: none;
}
img {
float: right;
}
以下のようなCSSOMが構築されます。
CSSOMがツリー構造をしているのは、親子関係によるスタイルの継承を効率的に行うためです。CSSでは親から子へルールを伝播させる必要があり、ツリー構造なら親ノードのスタイルを自動的に子ノードへと伝播できます。上の図では、薄い字で書かれているスタイルが祖先から継承されたスタイルです。p span {}
のCSSは、祖先からfont-size
やfont-weight
のプロパティを継承しています。
また、上の図は完全なCSSOMではなく、スタイルシートでオーバーライドすると決めたスタイルだけが表示されています。実際にはブラウザごとに存在するデフォルトのユーザーエージェントのスタイルシートから取得したスタイルを含んでいます。
3. スタイル
スタイルは、DOMとCSSOMからレンダーツリーを構築するステップです。これまでに構築してきたDOMとCSSOMはどちらも独立して管理されているデータ構造なので、この2つからレンダーツリーを構築して以降の処理をシンプルにします。レンダーツリーはレンダリングに必要なノードのみが含まれています。
レンダーツリーは、DOMツリーのルートから走査していき、一部の表示に関係のないノードやCSSで非表示にされているノードを除外したあと、CSSOMを適用してスタイルを計算します。このような手順を踏むことによって、レンダーツリーには、実際に画面に表示するべきノードと計算されたスタイルが含まれます。
DOMの構築とCSSOMの構築で例として出したDOMとCSSOMからレンダーツリーを作ると、以下のようになります。
DOMに存在していたノードのうち、p > span
はdiaplay: none
が設定されているため、レンダーツリーには含まれていません。
4. レイアウト
レイアウトは、レンダーツリーに含まれるノードの位置やサイズなどを決めるステップです。レンダーツリーには画面に表示されるノードとスタイルの情報は存在しますが、デバイスのビューポート内での位置やサイズの計算は行われていないため、このステップで処理します。また、このステップは初回はレイアウトと呼ばれ、2度目以降はリフローと呼ばれることがあります。
レイアウトはレンダーツリーからビューポート内での各ノードの位置やサイズを計算し、ボックスツリーに変換します。この処理はレンダーツリーのルート (body) から走査していき、各ノードの位置やサイズをビューポートを基準に計算していきます。まだ読み込みが完了していない画像などは、プレースホルダースペースが作成され、読み込まれるとリフローが発生します。
レイアウトの対象となるノードが多いほど計算の量が増えるため、時間が長くなります。細かい更新でも影響範囲が大きいことがあり、レイアウトの処理は比較的負担が高いことが多いです。
5. ペイント + 合成
ペイントと合成は、画面にピクセルを表示するステップです。ここまでの処理で何を描画すればよいかはわかっているため、それを使用して画面にピクセルを表示します。ボックスツリーではどの順序で描画すればよいのかがわからないので、描画の順序を計算したあとに描画します。
ペイントで描画に必要なデータを作成し、合成(composite)で画面の描画を行います。
この分離によって最適化を行うことが可能になっており、例えば一部のアニメーションやスクロールなどによる再描画ではペイントステップがスキップされて合成ステップが実行されます。ペイントまでのレンダリング処理はメインスレッドで実行されるのですが、合成ステップの多くの処理は別のスレッドで実行されるため、一部のアニメーションやスクロールはメインスレッドをブロックしません。
画面の更新
ブラウザが描画する画面は一度表示されて終わりではなく、様々なきっかけで更新されます。例えばJavaScriptやCSSアニメーションでCSSプロパティが変更されると画面は更新されます。
更新のたびにクリティカルレンダリングパスがすべて実行されるのではなく、必要なステップだけが実行されます。どのステップが必要になるのかは変更の種類によって変わるのですが、ここではいくつか紹介していきます。
クリティカルレンダリングパスのすべてのステップは以下のようになります。
ここでパースと書かれているステップは、HTMLのパースによるDOMの構築とCSSのパースによるCSSOMの構築を表しており、DOMやCSSOMの変更ではありません。
パース以降をすべて実行
パースから実行される例としてわかりやすいのはdocument.write()
APIです。ドキュメントに直接文字列を書き込むAPIなのですが、非推奨なので目にすることはないと思います。
API以外でいうと、例えば<link rel="stylesheet">
などを動的に挿入した場合にはCSSのパースが実行されます。CSSの内容によっては、すべてのステップが実行されることがあります。
スタイル以降をすべて実行
これはJavaScriptやCSSによって、レイアウトに影響を与えるleft
やwidth
などのプロパティを変更すると発生します。これらのレイアウトプロパティは、変更された要素に影響を受けるすべての要素を再描画する必要があるため、負荷が高いです。
スタイル → ペイント → 合成
これはJavaScriptやCSSによって、見た目を変更するcolor
やbox-shadow
などのプロパティを変更すると発生します。これらのペイント専用プロパティは、レイアウトを変更せず視覚的な要素だけを更新するため、レイアウトステップが必要なく、比較的負荷が低いです。
(スタイル → ) 合成
これはJavaScriptによって、レイアウトもペイントも必要としないtransform
やopacity
などのプロパティを変更すると発生します。これらのプロパティは、ペイントステップも必要なく、スタイルと合成ステップだけで処理することが可能です。
上記のプロパティをCSSアニメーションで変更している場合やスクロール処理などでは、スタイルすら実行されないことがあります。どちらもDOMやCSSOMを変更する必要がないため、合成ステップのみで処理が行われる可能性があります。上述しましたが、合成ステップの処理の多くは別のスレッドで実行されるため、メインスレッドがブロックされないというメリットがあります。
さいごに
ブラウザがWebページを表示する仕組みをまとめました。
クリティカルレンダリングパスの各ステップの概要と、画面が更新されたときにどのステップが実行されるかを意識できると、パフォーマンス最適化の役に立ちそうだなぁと思いました。
初回読み込みでいうと、HTMLのパースで実行されるリソースの読み込みは最適化の影響が大きそうだと感じています。ネットワークI/Oはブラウザ内の処理に比べるととてつもなく遅いと思うので、まずはそこを意識して最適化をするのが良いかもしれません。
実際のブラウザでは、ここに書かれていない最適化が行われていることも多く、この投稿だけではすべてを理解することはできません。ただ、クリティカルレンダリングパスの基本的な流れはあまり変わらないと思います。この投稿の知識が、ブラウザのDevToolsのPerformanceタブが見せてくれる情報の理解の助けになることを願っています。
参考資料
- ちいさなWebブラウザを作ろう
- Critical rendering path - MDN
- ページの生成: ブラウザーの動作の仕組み - MDN
- クリティカル パスの把握 - web.dev
- リソースの読み込みを最適化する - web.dev
- クリティカル レンダリング パス - web.dev
- オブジェクト モデルの構築 - web.dev
- レンダリング ブロック CSS - web.dev
- JavaScript によるインタラクティビティの追加 - web.dev
- レンダリング ツリーの構築、レイアウト、ペイント - web.dev
- ブラウザのプリロード スキャナに対抗しない - web.dev
- レンダリング パフォーマンス - web.dev
- コンポジタ専用プロパティを使用し、レイヤ数を管理する - web.dev
- 最新のウェブブラウザの詳細(パート 3) - Chrome
- RenderingNG - Chromium
- レンダリング ブロック リソースを排除する - Lighthouse
- スクリプト: async, defer - JAVASCRIPT.INFO
Discussion