この記事ではブラウザの仕組みを図解を用いてわかりやすくご説明します。
最近のブラウザは優秀なので、ブラウザの仕組みを理解していなくても、パフォーマンスの問題が発生することは少ないかもしれません。
しかし、アニメーションを多用するサイトやユーザーインタラクティブなサイトの場合、パフォーマンスの問題はとてもシビアです。
ブラウザの仕組みを知ることで、ブラウザのパフォーマンスを最大限に引き出す実装を行うことができます。
画面がなめらかに表示されないのはなぜ?
スクロールをしていてカクつく。またはアニメーションがカクカクしている時というのはブラウザがどういう状態なのでしょうか?
まずは、この状態を定量的に説明するためFPS(フレームレート)から説明します。
FPSとはFrame Per Secondの略で1秒ごとの画面(フレーム)の切り替わる回数を表しています。
ブラウザでサイトを見た際には最高で60fpsとなるため、1秒間に60回画面が更新されていることになります。スクロールによる表示の更新やアニメーションはテレビと同じ様に静止画を高速で切り替えることで動いているように見せているわけです。
60fpsということは、逆算すると~16.6msに1回の画面更新( = 1/60)を行っていることがわかります。
しかし、JavaScriptの処理時間や画面描写処理に重い処理があると、画面更新のタイミングまでに処理が間に合わず、フレームレートを下げてしまいます。逆に、16ms以内に処理が完了すれば、画面がちらつくことも、カクカクすることもないわけです。(厳密には例外もありますが。)
つまり、ブラウザで画面を見ていてパフォーマンスが悪いなと感じた場合、16ms以内に処理が完了していないということなので、重い処理がないか、または頻繁に呼ばれている処理がないかなどを見ていくことになります。
では、ブラウザは各フレーム毎にどのような処理を行っているのでしょうか?
もう少し具体的に見ていきましょう。
ブラウザの処理フロー
下の図はブラウザが各フレームごとに行っている処理の流れです。
ブラウザではParse(パース) > Style(スタイル) > Layout(レイアウト) > Paint(ペイント) > Composite(コンポジット)の流れで画面にどの様に表示すればよいのかを計算しています。
例えば、画面の初期表示の際にはブラウザはこの処理フローを最初から行います。逆に画面の表示内容に更新があった場合は必要な工程のみを処理し、再計算が不要な工程は省略します。ということは、どの変更がどのフローに関連しているのかを知っているだけで、ブラウザの負担を大きく減らすことができます。これがフロントエンジニアがブラウザの仕組みを知る最大のメリットです。
それではそれぞれの工程を詳しく見ていきましょう。
Parse(パース)
この工程ではHTMLとCSSの解析が行われます。ブラウザはHTMLとCSSを平行して解析し、それぞれDOM Tree(DOMツリー)とStyle Rules(スタイルルールズ)という処理しやすい構造体に変換します。すなわち、これらの構造体を生成するのがパースの役割です。
HMTLをパースしてDOMツリーを作成します。DOMツリーの一番上はdocumentになります。
CSSをパースしてStyle Rulesを作成します。
Style(スタイル)
この工程では先程作成したDOM TreeとStyle Rulesの紐づけが行われます。具体的にはどのスタイルがどの要素に適用されるのかをマッチングし、複数個のスタイルが一致する要素に関しては、スタイル適用の優先順位に従って最終的に適用されるスタイルを割り出します。
Layout(レイアウト)
先程作成したRender Treeにより、どの要素にどのスタイルが適用されるのかをブラウザは確認できるようになりました。
この工程ではそれぞれの要素の位置と大きさの計算を行います。この処理はLayout(レイアウト)、またはReflow(リフロー)と呼ばれます。レイアウトの計算はRender Treeの上流から下流にかけて再帰的に行われます。画面の初期表示の際は一番上位のHTMLタグに相当するルートのレンダラーから計算が始まり、各階層を順番に処理します。
逆に画面更新をした場合(例えば、JavaScriptで要素を挿入した場合や位置情報を書き換えた場合)はブラウザでは再計算の範囲を最小限で済ませたいため、その要素と小要素、同じ階層の兄弟要素の再計算が行われます。
display: none
を適用した要素はLayoutツリー内には登録されません。(Layoutツリー内に登録したい場合はvisibility: hidden
を使用します。)逆に::before{content: 'Text'}
などの疑似要素はこの工程でLayoutの対象として含まれます。 Global LayoutとIncremental Layout
Layoutの再計算にはGlobal Layout(グローバルレイアウト)とIncremental Layout(インクリメンタルレイアウト)の2種類があります。Global Layoutはページ全体に関わるレイアウト変更が行われた際に発生します。例えば、ブラウザの幅が変更された場合やページのフォントサイズが変更された場合です。一方、Incremental Layoutは変更が限定的な場合(要素が追加された場合など)に実行されるため、比較的軽い処理となります。
Paint(ペイント)
Paint Records(ペイントレコーズ)の作成
Parse > Style > Layoutの工程を経て、要素を出力する位置や大きさ、色がわかりました。さあ、いよいよ画面上に表示を行っていきましょう!?? いや、少し待ってください。画面上では要素が重なっている部分があります。どちらを先に描くかによって見え方が全然違います。Paintの工程では要素の重なりを考慮して、順番に命令を作成することで正しく描写できるようにします。この命令はPaint Records(ペイントレコーズ)と呼ばれます。
Paint Recordsの命令の順番は『Stacking Contexts(スタッキングコンテキスト)』を元に決定されます。スタッキングコンテキストは要素の出力順を保持しています。
モダンブラウザでは同時にLayer Tree(レイヤーツリー)を作成します。Layerの分離は変更の際の計算量を少なくするために大変有効です。例えば、Layerを分離せず描写を行った場合には、変更があった際に他の要素との影響を考慮して多くの計算を行う必要があります。しかし、レイヤーを分離して他の要素を考慮しないでよい状態にしておくと、そのレイヤーのみ再計算を行えばよいことになり、計算量を大幅に削減することができます。
z-index
とは関係がありません。3Dトランスフォーム(transform: translateZ(0)
)やtransformを使用したアニメーションを検知した際にブラウザはLayerを分離します。また、will-changeを使用したアニメーションプロパティーの指定が合った場合にもLayerは分けられます。レイヤーの状態はChromeの開発ツールのLayerパネルで確認する事ができます。ここまでの処理がブラウザのMain Thread(メインスレッド)上でおこなわれます。
最終的に作成されたLayer TreeとPaint RecordsはCompositor Thread(コンポジタースレッド)に渡され、Main Threadは開放されます。
Composite(コンポジット)
レイヤーの合成とラスタライズ
この工程ではMain Threadに代わってCompositor Threadが働きます。まず、先程の作成したPaint Recordsを元に各レイヤーのピクセル単位で色を当て込んでいきます。レイヤーの大きさはそれぞれですが、場合によってはページ全体に及ぶため、非常に計算量が多くなり、一つのスレッドで行うと時間がかかってしまいます。そのため、この処理はRaster Thread(ラスタースレッド)に代行されます。
Raster Threadは合計4つ用意されており、それぞれCompositor Threadから依頼された分を並行して処理します。Raster Threadでラスタライズ(色の当て込み)が完了した各レイヤーをCompositor Threadは集約し、合成レイヤーを作成します。そして、この合成レイヤーをGPUに送り画面に表示します。合成レイヤーの作成はViewport(画面内の領域)から優先して行い、スクロールなどの画面処理を受け取った際に順次作成され、GPUに送られます。
transform
、opacity
の適用もこの工程で行われます。この工程はMain Threadとは別のCompositor Threadで動くため、Main ThreadでJavaScriptが実行されている場合でも、処理の完了を待つ必要がなくパフォーマンス低下を防ぐことができます。逆にpos: left
などを使用したアニメーションではLayoutの工程から計算し直す必要があるため、パフォーマンスの低下が発生します。これでレンダリングの工程はすべて完了です。次の段落では各工程とスレッドの関係について説明します。
レンダリングとスレッド
下図のようにレンダリングの工程によってブラウザは異なるスレッドを使用します。
Main Thread
Main Threadはその名の通りメインで使用されるスレッドです。Main ThreadはParse〜Paintまでを基本的に担当しています。また、Main Threadではレンダリングの処理以外にもJavaScriptの実行を担当しているため、常に大忙しです。そのため、Main Threadの負担をなるべく軽くしてあげる事が重要です。
Compositor Thread
Compositor Threadはレイヤーの合成が担当です。Compositor ThreadはRaster Threadにレイヤーのラスタライズ(ビットマップ化)を依頼します。Raster Threadによってラスタライズされた各レイヤーはCompositor Threadによって一枚のレイヤーに合成されます。因みに前工程のPaint、Layoutが変更された場合(leftプロパティーによるアニメーションなどが行われた場合)はMain ThreadがComposite処理を一時的に代行します。
Raster Thread
レイヤーのラスタライズ(ビットマップ化)を行います。Raster Threadは4つあるので空いているスレッドにComposite Threadから依頼が来ます。
GPU
画面への描写を行います。因みに、transform、opacityを用いたアニメーションの場合はレイヤーはすでに分離されており、かつレイヤーのラスタライズも完了しているため、Compositor ThreadとGPU間のみでのやり取りとなります。そのため、Main Threadを待つ必要がなく、高速に処理することができます。
ブラウザはこのようにスレッドを分けることで画面表示を最適化しています。
ケーススタディー
このようなステップを経てブラウザはHTML, CSSファイルから画面を生成します。これらの工程を見てみると変更するプロパティーによって関わってくる工程が異なることがわかります。そして、上流の工程に関わる変更は下流の工程にも影響してくるため再計算しなければならない量が多く、パフォーマンスが悪くなる事が見て取れます。ここでは仮に要素の高さや色をjavaScriptで変更した場合、どのような処理を行う必要があるのかをおさらいします。
JavaScriptである要素の高さを変更した場合
大きさや位置の計算はLayoutの処理でしたね。Layoutに関わる変更が発生した場合、上図のようにStyleからすべての工程を再計算する必要があります。このためLayoutに関わる変更は多くの計算を必要とし、パフォーマンスを下げる原因となります。また、高さを変更した要素が多くの子要素を持っている場合には、子要素の再計算も行う必要があるため注意が必要です。また、Layoutの処理はMain Thread上で動いているので、他のJavaScriptの処理に影響されます。
- すべての工程の影響部分を再計算する必要があるため、処理が重い。
- Main Threadで計算が必要なので、他の処理を待たせる(または他の処理を待つ)場合がある。
- Layoutに関わるアニメーションはなるべく行わない。代わりにTransformを使用する。
- 仮に行う必要がある場合は、なるべく子要素を減らし、will-changeの使用を検討する。
ある要素の色を変更した場合
ある要素の色を変更した場合、Layout(位置や高さ)は関係ないのでスキップされて、Paint、Compositeが呼ばれます。Layoutの処理がない分、先程よりは処理時間は短くなりますが、Main Threadは変わらず使用されるので、処理が重くなることがあります。opacityを代用できる場合にはそちらを使用しましょう。
- Layoutの変更よりは処理が軽い。しかしMain Threadを使用するのでなるべく使いたくない。
- opacityで代用できる場合はそちらを優先して使用する。
- 仮に行う必要がある場合は、will-changeの使用を検討する。
transformプロパティーでアニメーションした場合
transformは一見、Layoutの操作に関係しそうですが、実際にはCompositeの処理の際に呼び出されています。Compositeの工程はCompositeスレッドで処理されるため、Main Threadの状態に影響を受けることなく快適に処理することができます。
- Main Threadに影響されないため、待ちが発生しづらく快適に動作。
- 計算量が少ないため、快適に動作。
- アニメーションにはtransform, opacityを優先的に使用する。
注意)実際はレンダリングエンジンによってフローはそれぞれ異なり、Safariが使用しているWebkitではTransformプロパティーの変更でもLayoutから再計算が行われます。
CSS Trigersというサイトではプロパティーごとにどの処理が行われているのかを確認することができます。
まとめ
いかがでしたでしょうか? 実際には各レンダリングエンジンで処理が異なるため、ここで紹介したフローが絶対ということではありませんが、基本的な仕組みを知っていると、レンダリングに優しい実装が行え、不要なトラブルを避けることができます。 そして、画面描写で何かトラブルが合った際にも、見るべきところが何となくわかるので、解決の糸口になるかと思います。
参考
How Browsers Work: Behind the scenes of modern web browsers
Inside look at modern web browser (part 3)
Rendering Performance