ブラウザ動作の理解-リフローとリペイント及びその最適化

ブラウザ動作の理解の2回目です。今回はレンダリング・ツリーの更新に絡む、リフロー (要素の大きさ、位置などの再計算) とリペイント (描画) に関する解説を、「High Performance Web Pages – 20 new best practices」 の著者 Stoyan Stefanov のブログエントリーから 「Rendering: repaint, reflow/relayout, restyle」 を翻訳してお届けします。

同記事は、前半がリフローとリペイントに関する解説と、表示レスポンスを向上させるためのスタイル変更に関する Tips、後半は dynaTrace AJAX EditionSpeedTracer を使った IE および Chrome の描画パフォーマンスの計測の解説で構成されてる、長い記事となっています。

今回はその前半部分をご紹介します。

レンダリングのプロセス

異なるブラウザは異なる動作をします。しかし次の図は、ページを構成するコードをダウンロードした後、多かれ少なかれ各ブラウザで共通に行うことの一般的な概念を表しています。

レンダリングのプロセス

レンダリングのプロセス

  • ブラウザは、HTML ソースコード (タグのスープ) をパースし、DOM ツリーを構築します。それぞれの HTML タグは、ツリー中に関連するノードを持っており、開始~終了タグ間の一連のテキストはテキストノードで表されています。DOM ツリー上のルート・ノードは document.Element(<html> タグ) です。
  • ブラウザは、CSS コードをパースし、-moz-webkit など可能なハックを解釈し、解釈不能なその他の表現は大胆に無視します。スタイリング情報の優先順位に関する基本的なルールは次の通りです。ユーザー・エージェントのスタイルシート (ブラウザのデフォルト)、ユーザー定義のスタイルシート、ページの著者が定義したスタイルシート (link で読み込まれる外部ファイル、import されたファイル、インラインでの定義、そして最も優先順位の高い HTML タグの style 属性に埋め込まれたスタイル) の順です。
  • 次は、最も興味深いパート … レンダリング・ツリーの構築です。レンダリング・ツリーは、DOM ツリーに似た類のものですが、同じものではありません。レンダリング・ツリーは、スタイルに関する情報を持っており、それゆえ divdisplay:none で隠したとすると、レンダリング・ツリー中にその div は現れません。head やその中の他の見えない要素も同じです。反対に、レンダリング・ツリー中に1つ以上のノードがある状態で表される DOM 要素があります。例えば、<p> 中の各行のテキスト・ノードは、レンダリング・ツリー中にノードを必要とします。レンダリング・ツリー中の1つのノードは、フレーム あるいは ボックス (ボックス・モデルで言うところの CSS ボックス) と呼ばれます。これらそれぞれのノードは、幅、高さ、ボーダー、マージン等の CSS ボックス属性を持ちます。
  • 一旦レンダリング・ツリーが構築されると、ブラウザはレンダリング・ツリー・ノードをスクリーン上に ペイント (描画) することが出来ます。

森と木々

例を示します。

HTML ソース

<html>
<head>
	<title>Beautiful page</title>
</head>
<body>
	<p>
		Once upon a time there was
		a looong paragraph...
	</p>
	<div style="display: none">
		Secret message
	</div>
	<div><img src="..." /></div>
	...
</body>
</html>

DOM ツリー

この HTML を表している DOM ツリーは、基本的に各タグに1つのノードと、ノード間に記述されたテキスト用の1つのテキスト・ノードを持っています (簡単化のため、空白もテキスト・ノードだという事実は無視します)。

documentElement (html)
	head
		title
	body
		p
			[text node]
		div
			[text node]
		div
			img
		...

レンダリング・ツリー

レンダリング・ツリーは、DOM ツリーの可視化される部分ですので、head と 隠れた div などの幾つかの要素がなくなります。しかし、各段落テキストの (フレームとかボックスとかの) 追加されたノードを持ちます。

root (RenderView)
	body
		p
			line 1
			line 2
			...
		div
			img
		...

レンダリング・ツリーのルート・ノードは、他の全ての要素を含むフレーム (ボックス) です。それはブラウザ・ウィンドーの内側部分と考えることが出来るため、ページが表示される専用の領域となります。WebKit におけるルート・ノードは RenderView と呼ばれ、基本的にページトップ (0, 0) から (window.innerWidth, window.innerHeight) までの矩形の表示域である CSS の初期包含ブロック [注1] に相当します。

スクリーン上に何をどの様に正確に表示するかを計算することは、レンダリング・ツリーを再帰的に辿ること (フロー) を伴います。

リペイントとリフロー

ページを最初に表示するときには、レイアウトと共に描画が少なくとも1回行われます (空白ページが好きなら別ですが 😉 )。その後、レンダリング・ツリーを構築するために使われた入力情報の変更は、次の1つまたは2つの結果をもたらします。

  1. レンダリング・ツリーの1部 (もしくは全部) は、再評価され、ノードの幅・高さなどが再計算されます。これは リフロー あるいは レイアウト と呼ばれます (タイトルの語呂合わせのために リ・レイアウト と書きましたが、これは悪乗りです、すみません)。最初のページのレイアウトのために、少なくとも1回はリフローが行われることを覚えて置いてください。
  2. ノードの位置的なプロパティが変わったか、背景や色を変えるといったスタイル上の変化があれば、スクリーンの1部が更新されます。

リフローとリペイントはそれなりに負荷が高いので、ユーザーのエクスペリエンスを悪くし、UI の反応が鈍くする原因となります。

リフローとリペイントが発生するトリガー

レンダリング・ツリーを構築するために使われた入力情報のいかなる変更も、リフローとリペイントを発生させます。具体的には次のような場合です。

  • DOM ノードの追加、削除、更新
  • display: none (リフローとリペイント)、あるいは visibility: hidden (位置の変更は起きないので、リペイントのみ) による DOM ノードの見た目の変更
  • ページ中の DOM ノードの位置の移動やアニメーション
  • スタイル属性のちょっとした変更のためのスタイルシート追加
  • ウィンドーサイズの変更やフォントサイズの変更、そしてスクロール (おお神よ、これもですか!) など、ユーザーの操作

例を見てみましょう。

var bstyle = document.body.style; // キャッシュ

bstyle.padding = "20px"; // リフロー、リペイント
bstyle.border = "10px solid red"; // さらにリフロー、リペイント

bstyle.color = "blue"; // 位置は変化しないので、リペイントのみ
bstyle.backgroundColor = "#fad"; // リペイント

bstyle.fontSize = "2em"; // リフロー、リペイント

// 新しい DOM 要素の追加 - リフロー、リペイント
document.body.appendChild(document.createTextNode('dude!'));

ある種類のリフローは他よりも負荷が高くなります。レンダリング・ツリーを考えて見ましょう。もし body の直接の子孫であるツリーの末端であるノードを変えるなら、たぶん他の多くのノードを無効にはしないでしょう。しかし、アニメーションを加え、ページ上部にある div を拡大し、ページの残りを下に押しやることになる場合はどうでしょう。それはとても高負荷になります。

ブラウザは賢い

レンダリング・ツリーの修正に関連するリフローとリペイントはとても高負荷なため、ブラウザは、マイナスの影響を減らす工夫をします。その戦略1つは、少なくとも今すぐには、何もしないことです。 ブラウザは、スクリプトが必要とする変更の待ち行列をセットアップし、バッチでそれらを実行します。この方法により、それぞれが1回のリフローを要求する変更が結合され、たった1回のリフローが計算されるだけとなります。ブラウザは、変更をキューに追加し、ある時間が経過するか、変更の回数がある回数に達した時に、キューをフラッシュします。しかし、スクリプトは時々、こういったブラウザのリフロー最適化を妨げ、キューをフラッシュし、バッチ化された全ての変更を実行してしまうことがあります。これは次のようなスタイルに関する情報を要求したときに起きます。

  1. offsetTopoffsetLeftoffsetWidthoffsetHeight
  2. scrollTopscrollLeftscrollWidthscrollHeight
  3. clientTopclientLeftclientWidthclientHeight
  4. getComputedStyle()、あるいは IE の currentStyle

上記すべての項目は基本的に、ノードに関するスタイル情報を要求します。そしてそういう場合はいつでも、ブラウザは最新情報を返そうとします。そのためには、全ての計画された変更を適用し、キューをフラッシュし、ひるむことなくリフローを実行するのです。

例えば、次の様にループ内で立て続けにスタイル情報の set/get を行うのは悪い考えです。

// 悪い例
el.style.left = el.offsetLeft + 10 + "px";

リフローとリペイントの最小化

ユーザーのエクスペリエンスにとってのリフローとリペイントに関するマイナスの影響を少なくする戦略は、単純にリフローやリペイントを極力少なくし、スタイルに関する情報への要求を少なくすることです。そうすれば、ブラウザはリフローを最適化できます。では具体的には、どのようにすればよいでしょうか?

  • 1つ1つ、個々のスタイルを変更しないようにします。頑健性と保守性の点で最も良い方法は、スタイルではなくクラス名を変更することです。しかしそれは、静的なスタイルを前提としています。スタイルが動的である場合には、要素ごとに少しのスタイルを変更するのではなく、cssText プロパティを編集します。
    // 悪い例
    var left = 10, top = 10;
    el.style.left = left + "px";
    el.style.top  = top  + "px";
    
    // ましな例
    el.className += " theclassname";
    
    // あるいは、top と left が動的に計算されるなら...
    
    // ましな例
    el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
    
  • DOM の変更を1回分にまとめ、それらを オフライン で実行します。オフラインとは次を意味します。
    • documentFragment を使い、テンポラリーな変更を保持します。
    • 更新予定であるノードのクローンを生成し、そのコピー上で作業し、最後にオリジナルと交換します。
    • 要素を display:none (1回のリフローとリペイントを伴います) で隠し、100の変更を加え、display を復元します (ここでも1回のリフローとリペイントを伴います)。この方法は、100のリフローとリペイントを2回にします。
  • ブラウザによるスタイルの計算を過度に要求するのを止めます。もし計算された値が必要なら、1度だけ取得し、ローカル変数にキャッシュし、ローカルにコピーされた値を使います。悪い例をもう1度示します。
    // 悪い例
    for (big; loop; here) {
    	el.style.left = el.offsetLeft + 10 + "px";
    	el.style.top  = el.offsetTop  + 10 + "px";
    }
    
    // ましな例
    var left = el.offsetLeft,
    	top  = el.offsetTop
    	esty = el.style;
    for (big; loop; here) {
    	left += 10;
    	top  += 10;
    	esty.left = left + "px";
    	esty.top  = top  + "px";
    }
    
  • レンダリング・ツリーに関して、その変更後の再評価がどれだけ必要になるかを一般化して考えてみましょう。例えば、絶対位置を使用すれば、レンダリング・ツリー中では body の子の要素になるため、例えそれをアニメーション化しても、他の多数のノードには影響しません。他のノードの幾つかは、それらの上部に要素が配置されていれば、リペイントが必要な領域にあるでしょう。しかしそれでも、リフローまでは必要とはならないのです。

用語の定義

ブラウザ (レンダリング・エンジン) によっては異なる用語が用いられているので、最後に記しておきます。

  • レンダリング・ツリー – DOM ツリーのビジュアルな側面を表します。
  • レンダリング・ツリー中のノードは、frame あるいは box と呼ばれます。
  • Mozilla では、レンダリング・ツリーの再計算を リフロー と呼びます。他のブラウザでは レイアウト と呼ばれるようです。
  • 再計算されたレンダリング・ツリーの結果を元にスクリーンを更新することを リペイント と呼びます (IE や DynaTrace では redraw です)。
  • SpeedTracer では、位置的な変更を伴わないスタイルの変更を style recalculation と呼び、(位置的変更を伴う) layout と区別します。

参考文献

もしこの話が面白いと思ったら、さらに幾つかの参考文献を読むと良いでしょう。特に最初の3つは、ここに示した Web 開発者向けの話題よりさらに深く、ブラウザ内部に関する話となります。

コメントから拾った参考文献

ブラウザ動作の理解-リフローとリペイント及びその最適化」への2件のフィードバック

  1. tokkonoPapa

    hatena 等でブックマークして下さった方々へのお詫び

    canonical の URL が誤っていたので、一部リンク切れが発生してしまいました。確認不足ですみませんです。Head Cleaner 1.4.1 で、この不具合が出ていましたが、1.4.1.1 で修正されたようです。

    返信
  2. ピンバック: たてよこWebアワードに応募をした | DogooBaco

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

海外からの投稿は受け付けていません