ROCHAS

Layout、Paintingとは何か?レンダリングから学ぶWebサイトのパフォーマンス

どうしたらWebサイトのパフォーマンスを向上させることができるのか? レンダリングエンジンについて学ぶことで、その根拠を理解していきたいと思います。 前半ではレンダリングプロセスについて、後半ではJavaScript、CSSそれぞれの高速化、最適化について書きます。

1. レンダリングエンジンとは何か

レンダリングエンジンはHTMLドキュメントなどを解析し、画面に表示する、ブラウザの大切な役割を担っています。
Chrome、Safari、OperaはWebKit、FirefoxはGecko、IEはTridentをコアのレンダリングとして採用しており、ポートとなるとまた複雑でChromiumなどがあります。
もちろん各ブラウザに違いはありますが、アドレスバーにURLが入力され、ページのロードが開始されると、レンダリングエンジンは基本的には次のようなプロセスを経ます。


追記:4月3日、ChromeはWebKitをフォークした新しいオープンソースのレンダリングエンジン、Blinkの開発を公式発表しました。OperaもBlink の移行がBruce Lawson氏のブログに綴られています。
また同日、MozillaもSamsungとの共同開発によるAndroidとARM向けレンダリングエンジン Servo を発表、ソースはGithubで公開されています。

2. レンダリングプロセス

レンダリングプロセス

  1. レンダリングエンジンはHTMLドキュメントのタグを解析し、DOMツリーを構築します。 またスタイル情報も、外部CSSファイル、style要素の両方を解析します。
  2. このDOMツリーとスタイル情報から、レンダーツリーが構築されます。
  3. レンダーツリーが構築されると、Layout(Reflow)が発生し、レンダーツリーの位置的な情報を元に、スクリーンにレイアウトされます。
  4. 次にPainting(Repaint)が発生し、レンダーツリーの視覚的な情報を元にペイントされます。

これはあくまでも基本的なプロセスであって、ページを最初に表示する時には、Layoutは最低1回行われます。
ここで重要なのはLayout、Paintingは繰り返し発生するという点です。
レンダリングエンジンはよりユーザーが操作しやすいよう、できるだけ早くコンテンツを画面に表示しようとします。全てのHTMLドキュメントが解析し終わる前から、Layoutが開始されるため、新しい情報が読み込まれる度に、レンダーツリーが再構築され、LayoutやPaintingもしくはその両方が発生します。
つまりローディング中、完了後に関わらず、スタイルの変更、JavaScriptによる動的な処理、ユーザー操作によって変更が生じた場合にLayoutやPaintingは発生するのです。ここがパフォーマンスに大きな影響を与えます。

Webページがどんなふうに表示されているのか、実際に見ていただくのが一番!こちらはLayout(Reflow)をヴィジュアライズ化した、mozilla.orgが作成した動画です(27秒)。
途中何度も要素が描き変えられているのが見てとれます。これがLayoutです!レンダリングエンジンってすごいですねー!

3. LayoutとPaintingの違い

Painting(Repaint)

  • color, background-color, visibility, outlineなど、視覚的なスタイルの変更がなされるとPaintingが発生します。
  • Paintingはその要素の視覚的な情報が再計算されるため、パフォーマンスを低下させます。
  • PaintingとはWebKitでの名称であり、GeckoではRepaintと呼ばれています。

Layout(Reflow)

  • DOMの操作、ウィンドウのリサイズ、スクロール、また、position, display, dimention(width, height)など位置的なスタイルの変更がなされるとLayoutが発生します。
  • Layoutは先祖要素から子孫要素までその要素が属してるノード、あるいはドキュメントノード全体の位置的な情報が再計算されてしまうため、最もパフォーマンスを低下させます。そうなるともはやページ全体をレイアウトし直すと同じことです。
  • LayoutとはWebKitでの名称であり、GeckoではReflowと呼ばれています。

ブラウザは賢いので変更が生じた際、なるべく変更を最小限に抑えようとします。 例えば要素を非表示にするためにvisiblity:hiddenを指定するとpaintingが発生します。ところがdisplay:noneを指定するとLayoutとPaintingの両方が発生してしまいます。つまり位置的な変更がなされるdisplay:noneの方が負荷が高いわけです。

4. LayoutやPaintingを引き起こす原因

  • DOMノードの追加、変更、削除
  • アニメーション
  • スタイルの変更
  • クラス属性の変更
  • :hover擬似クラスなどイベントの発生
  • input要素などユーザー入力によるテキストノードの変更
  • offsetWidth / offsetHeight や getComputedStyle などで値を取得する
  • フォントの変更
  • ウィンドウのリサイズ
  • オリエンテーションの切り替え
  • スクロール

これらはほんの一例であり、ローディングに時間が掛かったり、UIの操作鈍らせたり、アニメーションがスムーズにいかなかったり、LayoutやPaintingはパフォーマンス低下の原因となります。また何がLayoutでPaintingなのかというのはレンダリングエンジンによって異なります。

ウィンドウのリサイズやスクロールでさえ発生してしまうのですから、全くなくすことはできませんが、いかにLayoutやPaintingの回数を減らすか、範囲を小さくするかがパフォーマンスの向上へと繋がるのです。スマートフォンであればなおさらユーザー操作が頻繁に行われることを想定した設計が重要になります。
ではどうしたらよいか?具体的な改善策を見ていきます。

5. JavaScriptのパフォーマンス

DocumentFragmentでDOM操作のLayoutの回数を減らす

DOMに新しい要素を追加する、テキストノードの値を変更する、属性の値を変更するなど、ドキュメントツリーの変更はLayoutを発生させます。
複数のDOMノードを操作する場合に、その都度操作をしてしまうと、その回数分レンダーツリーが再構築され、無駄なLayoutが繰り返されてしまいます。
このような場合には、DocumentFragmentを使って、複数の変更をまとめてから、その変更をドキュメントツリーに追加することでLayoutを1回で済ませます。
DocumentFragmentは軽量で小さなノード構造を持ったDocumentオブジェクトのようなものであり、ドキュメントツリーから独立して操作が行えるため、負荷を軽減できるのです。

cloneでDOM操作のLayout回数を減らす

cloneを使ってドキュメントツリーの変更を要素のコピーに対して行い、変更が終わったら本物の要素と交換することで、Layoutを1回で済ませます。
ただしこの方法だと処理は軽くなりますが、参照渡しなので、コピー元に変更が反映されません。またイベントハンドラもコピーできません。かといってディープコピーでは重くなるので、formからのユーザーによる変更といった用途には不向きです。

また一旦DocumentFragmentに追加したものをcloneするという感じで、2つを組み合わせるとさらに効果が期待できます。

アニメーションのフレームレートは60FPS以内を目指す

フレームレートとは動画やアニメーションで1秒間に何回フレームが描画されるかを単位FPS(Frame Per second)で表したものです。
ブラウザのリフレッシュレートは一般的には60Hzとされており、一部ブラウザやスマートフォンではもっと低いものもあります。 FPSが高いほどアニメーションは滑らかになりますが、その分メモリに負荷が掛かり、ブラウザのリフレッシュレートを超えてしまうとアニメーションがガタガタしたり、クラッシュするなどの原因となります。
それらを防ぎ、滑らかなアニメーションを実現するためには、フレームレート60FPSを越えないよう、16ms以内に処理を完結させる必要があります。

レンダリングプロセス

ただLayoutが大きかったり、ウィンドウのリサイズ、スクロールとページのあちこちで発生することを想定してみてください。setTimeout()やsetInterval()でアニメーションを行なった場合、60FPS以内に保てているとは限りません。 そんな時はChrome Developer Toolのタイムラインパネルでフレームレートをリアルタイムで確かめることができます。スマートフォンはRemote Debuggingでシュミレートして、アニメーションの実行間隔を調節してみましょう。

requestAnimationFrame()でアニメーションによるメモリリークを抑える

requestAnimationFrame()によるアニメーションは、60FPSを振り切らないようブラウザのリフレッシュレートが考慮されており、またタブが非表示(後側)の場合は、実行回数が自動的に低下します。
そのためsetTimeout()やsetInterval()よりメモリの消費を抑えることができ、DOMベースのアニメーションをはじめ、canvas、WebGL、SVGで効果が期待できます。
ただし60FPSが常に一定ではないという点ではsetTimeout()同様、調整が必要です。 またIE9以下、Androidは未実装なのでクロスブラウザに対応させる場合にはPolyfillを使います。

複数回のスタイル変更はclassで1回にまとめる

JavaScriptによる要素のスタイル属性の変更はLayoutやPaintingの原因となり、複数回のスタイル変更をstyleプロパティで行なうと、その回数分LayoutやPaintingが発生します。このような場合にパフォーマンスを改善する方法は2つあります。

1つ目はclassNameを使って変更を1回にまとめる方法。変更する値が事前に判っている場合は、classの変更によって新しいスタイルに差し替えます。
ただしclassを変更すると1回はLayoutが発生しますので、できるだけDOMツリーの深いノードにclassを指定して、Layoutの範囲を最小限に抑えます。

/* 良い例 classによるstaticな変更 */
.active {
  color: red;
  height: 100px;
  background-color: white;
}
element.className = 'active';

2つ目はcssTextプロパティでstyle属性の変更を1回にまとめる方法。この方法はアニメーションなど変更する値が事前にわからない動的な変更の場合に使います。

Layout発生のトリガーとなるJavaScriptに注意する

ブラウザは本来、Layoutはなるべく1回で済ませるため、複数の変更をキャッシュし、変更が終ってからバッチ処理をする機能を持っています。 しかし、要素の位置やスタイルが計算されるようなプロパティやメソッドを使うと、Layoutが強制執行されてしまいます。

offsetTop, offsetLeft, offsetWidth, offsetHeight scrollTop, scrollLeft, scrollWidth, scrollHeight clientTop, clientLeft, clientWidth, clientHeight scrollBy(), scrollTo(), scrollX, scrollY getComputedStyle() height, width

例えば要素の位置を取得、設定してループさせるなんていうのはパフォーマンス的にはよくありません。 せめて取得した値はローカル変数にキャッシュして、1回にかかる処理時間を短縮しましょう。

ローカル変数とグローバル変数のパフォーマンスを理解する

スコープとは、どこから変数を参照できるか、という概念で、ローカル変数は関数内から、グローバル変数はプログラムのどこからでも参照できます。
グローバル変数は、グローバルスコープ内でvar宣言をした時、グローバルオブジェクトに追加した時、そしてvar宣言をしなくても作成されてしまうため、扱いに注意しないとグローバル汚染をするだけでなくパフォーマンスを低下させる原因にもなります。
なぜかというと、ローカルスコープ内からグローバル変数を参照しようとすると、グローバル変数が見つかるまでスコープを遡り、最終的にはWindowオブジェクトにまで到達し、他のスコープにまたがってしまいます。
またグローバル変数は常に名前で参照されるのに対し、ローカル変更にはインデックスが使われます。
こうしたことからも、グローバル変数を使う意図がない限りはローカル変数を使いましょう。 以下の例ではplayersの合計得点から平均を計算しています。

6. CSSのパフォーマンス

position:fixedによる固定レイアウトは負荷がかかる

ヘッダー固定や背景を固定したレイアウトにはposition:fixed、background-attachment:fixedが使われますが、これらのCSSプロパティはLayoutが繰り返されており、負荷がかかりやすいです。
たまにスマートフォンでスクロールするとチラつきが見られるなど固定した箇所に不具合が起きているのは、スクロールしても同じ位置を維持しようと、画面を書き換えているためです。

アニメーション要素にはposition:absoluteでLayoutの範囲を小さくする

CSS3やJavaScriptによるアニメーションはフレーム(FPS)ごとに位置が再計算されるため、継続中はLayoutが繰り返されます。
そんな時はposition:absoluteを指定し、変更が及ぶ範囲をアニメーションしている要素だけに限定することで、他の要素のレイアウトへの影響を最小限に抑えることができます。
positionの仕様ではabsoluteとfixedは親要素から計算されるのに対し、relativeはドキュメントルートまで遡ってpotisionが計算されます。そのためrelativeはページ全体にLayoutが発生してしまいます。Layoutは発生する範囲が大きい程負荷がかかるのです。

overflow: scrollによるスクロールは負荷がかかる

コンテンツをスクロールさせるUIもユーザー操作によるLayoutを発生させる原因となります。
パフォーマンス的にはoverflow: visibleで対応する方が望ましいが、さもなければせめて、position:absoluteでLayoutが及ぶ範囲を限定しましょう。

LayoutやPaintingコストがかかるCSSを知っておく

以下のプロパティはパフォーマンスコストが高い。
@font-face
animation
transition
box-shadow
border-radius
gradient
opacity
background-size
text-align

Resetか、normalizeか?全てをリセットするのはLayoutが多発する

Eric Meyer’s Reset CSSは、margin, padding, border, font-sizeなどを全てリセットしてしまうため、結局スタイルの再指定が必要となってしまいます。
一方、normalize.cssはブラウザやOS間の差異を埋め、有用なデフォルトスタイルを残しノーマライズするため、再指定を最小限に抑えることができます。
どちらが正しいということではなく、その他色々なボイラープレート等を参考にしつつ、ターゲットブラウザやプロジェクトに合ったガイドラインをつくることが、Layoutを最小限にすることにもつながります。

不必要なCSSセレクタは指定しない

/* 悪い例 */
ul.menu li a
form#btn-submit

/* 良い例 */
.menu a
#btn-submit

CSSセレクタは右から左へと解釈されるため、たくさん羅列してしまうと、レンダリングに時間がかかってしまいます。
悪い例ではまず、ドキュメント全体からaを走査し、次に全てのaからliに絞り込みます。それらから.menuに、そして最後にulに絞り込むといったように、何回もドキュメント内を走査してしまいます。
またページ内に単一であるIDの前に要素を追加するのもよくない。セレクタの優先度を上げてしまったり、コードの再利用の妨げにもなってしまいます。 セレクタなるべくツリーの深いノードにセレクタを指定して、Layoutの範囲を最小限に抑えましょう。

以上レンダリングに焦点を絞ってパフォーマンスを向上させる方法を見てきました。 デザイン段階からパフォーマンスへ配慮すること、コードは設計を立てて書くこと、ユーザー操作を想定することがいかに大切かがわかります。 Webサイトを表示しているのはブラウザですものね、パフォーマンスに悩んだ時は、LayoutやPaintingのことを思い出してみるときっとベストプラクティスが見つかると思います!

参考