⚰️

あなたの書いたCSSがブラウザを苦しめる

に公開

レンダリングの裏側、ちゃんと理解してる?

ブラウザのアドレスバーにURLを入力してからページが表示されるまでに、ブラウザは以下のような処理を順に行います。

  1. Loading(リソースの読み込み)
    a. Download:HTMLやCSS、JavaScriptなどのリソースをネットワークから取得
    b. Parse:取得したリソースを解析し、DOMやCSSOMを構築
  2. Scripting(JavaScriptの実行)
    DOMの操作やイベント処理などが行われる
  3. Rendering(レイアウトツリーの構築)
    a. Calculate Style:CSSOMとDOMを元に各要素のスタイルを計算
    b. Layout:スタイル情報をもとに各要素の位置やサイズを決定
  4. Painting(描画)
    a. Paint:要素ごとの見た目を描画指示に変換
    b. Rasterize:描画指示をピクセル情報に変換
    c. Composite Layers:複数のレイヤーを合成して最終的な画面を生成

なお、これらの処理は初回表示時だけでなく、ユーザーの操作やJavaScriptの実行、ドキュメントイベントなどによって再び実行されることがあります。ただし、再レンダリングでは常にすべての処理が再実行されるわけではなく、変更の内容に応じて、どの処理からやり直すかが変わります。

この記事では、特に Rendering(レイアウトツリーの構築) に焦点を当て、そのチューニング方法について解説します。

ブラウザがページを描画するまでの流れ

Renderingフェーズでは、各DOM要素に対して次の2つの処理が行われます。

  • スタイルの計算(Calculate Style)
  • レイアウトの計算(Layout)

それぞれの処理は以下のような内容です。

スタイルの計算(Calculate Style)

CSSOMツリーに格納されているCSSルールセットがどのようにDOM要素に対して適用されるのか計算するフェーズです。
具体的には、各DOM要素に対してCSSセレクタのマッチングを行い、適用されるスタイルを計算します。

/* CSSルールセット */
body div.button /* CSSセレクタ */
{ 
  width: 100%; /* CSSプロパティ */
  padding: 16px;
  background-color: black;
  font-size: 16px;
}

このようなセレクタに対して、ブラウザは次のように右から左へ順にマッチング処理を行います。

  1. class属性にbuttonが含まれている
  2. その要素名がdivである
  3. その先祖にbodyがある

これらの施行がすべて通った場合に、初めてこのスタイルがそのDOM要素に適用されます。途中で1つでも条件が満たされなければ、マッチングは失敗します。

レイアウトの計算(Layout)

Layoutでは、Calculate Styleで計算した各要素のスタイルを元にして、実際の位置や大きさを計算していきます。この実際の位置というのは、CSSのtopやleftといったプロパティの話ではなく、レンダリングする対象内での絶対位置のことです。

再レンダリングはいつ起きる?どこまでやり直される?

DOM操作などによって再レンダリングが発生する場合、再びCalculate StyleLayoutの処理が実行されます。ただし、前回のレンダリング結果(スタイルやレイアウト情報)は可能な限り再利用されるため、初回とまったく同じ処理がすべて繰り返されるわけではありません。

たとえば、Calculate Styleの再計算では、すべてのDOM要素に対してスタイルを再計算するのではなく、変更の影響を受けた要素のみを対象に再計算が行われます。したがって、初回レンダリング時よりも再レンダリング時の処理は効率的になる傾向があります。

再レンダリングの代償:何がどこまで再実行されるのか?

  • **class属性を変更した場合
    • セレクタのマッチングが再実行されるため、Calculate Styleが再実行されます。該当要素や、その子孫要素に影響するスタイルがある場合は、それらも再計算の対象になります。
  • JavaScriptでstyleプロパティを直接変更した場合
    • CSSセレクタのマッチング処理は不要となり、Calculate Styleは最小限になります。
  • background-colorなどの見た目のみの変更を行った場合
    • 視覚的な再描画(Paint)は必要になりますが、レイアウトの再計算(Layout)は発生しません。

開発者は、「どの操作がどのレンダリング工程に影響するのか」を理解しておくことで、パフォーマンスに考慮したスタイルやスクリプトの設計が可能になります。

CSSの書き方ひとつで描画は速くも遅くもなる

CSSのマッチング処理のパフォーマンスは、セレクタの書き方だけでなく、ドキュメントのDOMツリーの構造にも大きく影響されます。
マッチング処理では、ドキュメント内のすべてのDOM要素に対して、すべてのCSSルールセットを照らし合わせて、適用すべきスタイルを判定しています。

そのため、たとえ"重い"セレクタを使っていたとしても、DOMツリーの構造次第では、パフォーマンスへの影響がごく僅かに収まる場合もあります。
逆に、セレクタがシンプルでも、DOMが深く複雑であればマッチングコストは高くなります。

つまり、セレクタの最適化を行うには、CSSの記述だけに注目するのではなく、DOM構造も含めて全体的に設計・検証する必要があるということです。
実際に、パフォーマンスを計測しながら調整していく姿勢が欠かせません。

とはいえ、毎回DOM構造まで最適化していられない、というのも本音でしょう。
だからこそ、日常的に書くCSSセレクタのクセが、レンダリングパフォーマンスを左右します。
ここからは「まず押さえておきたいセレクタ設計のポイント」を紹介します。

  • CSSセレクタをシンプルにする
  • 子孫セレクタ・間接セレクタを避ける
  • 全称セレクタ(*)との組み合わせを避ける

そのセレクタ、本当に必要?

シンプルなセレクタを使う

CSSセレクタのマッチング処理を高速化するための基本は、セレクタをできるだけシンプルに保つことです。
前述の通り、ブラウザにはセレクタを右から左に評価します。そのため、セレクタが長く複雑であればあるほど、マッチング時にたどるDOMの階層が多くなり、処理コストが上がります。

BAD: table > tbody > tr > td
GOOD: td

たとえば上記のBADの例では、以下のような条件をすべて順番に確認する必要があります。

  1. 要素名がtdである
  2. その親要素がtrである
  3. その親要素がtbodyである
  4. その親要素がtableである

一方、GOODのように単一要素セレクタにすれば 「tdであるかどうか」 の1ステップだけで済みます。

また、次のようなケースもよくあります。

BAD: div.button
GOOD: .button

div.buttonのように要素名とクラス名を併記すると、要素名とクラス名の両方でマッチング処理が必要になります。
それに対して.buttonのようにクラスだけで指定すれば、クラス名のマッチングだけで済むため、より高速です。

セレクタを複雑に組み合わせず、できる限りシンプルに記述することは、パフォーマンスを向上だけでなく、CSSルールセットの詳細度が一定になり保守性の向上にもつながります。コードが読みやすくなり、ルールの競合も減らせます。

子孫セレクタや間接セレクタは避ける

CSS書く際は、子孫セレクタ間接セレクタの使用をできるだけ避けるべきです。これらのセレクタはマッチング処理に余計な負荷をかける可能性が高いからです。

/* 子孫セレクタの例 */
#header a {}

/* 間接セレクタの例 */
.logo ~ .nav {}

子孫セレクタ(#header a)は、#headerの中にあるすべてのa要素にマッチします。
間接セレクタ(.logo ~ .nav)は、.logoと同じ階層で後続する.navクラスの要素すべてにマッチします。

これらが重くなる主な理由は以下のとおりです。

  • マッチ対象の要素が多くなる可能性がある
  • バックトラック(処理のやり直し)が発生することがある

たとえば#header aのような子孫セレクタでは、マッチングに失敗する場合も、a要素のすべての親要素をたどって#headerが存在しないことを確認する必要があります。
DOMのネストが深いほど、この処理は重たくなります。

また、間接セレクタや複雑な構造を持つセレクタでは、マッチングが一度失敗しても途中からやり直す「バックトラック」が行われることがあります。
たとえばheader > div aというセレクタは以下のような処理になります。

  1. 要素がaである
  2. div要素が見つかるまで親要素をたどる
  3. その親要素がheaderであるかを確認
  4. 見つからなければ再度2からやり直す

このように、処理のやり直しが発生するセレクタは、マッチングコストが高くなりやすいのです。

セレクタは明確かつ局所的に効かせるのが基本です。できるだけ構造に依存しすぎず、直接的な指定を心がけましょう。

全称セレクタ(*)との併用は避ける

全称セレクタ(*)は、あらゆる要素にマッチするという性質を持っています。これを使えばすべての要素にCSSを適用できますが、その柔軟性の代償として、非常に高いマッチングコストを招く場合があります。

特に、他のセレクタと組み合わせて使用した場合は要注意です。
たとえば以下のような記述です。

header * {
  /* ... */
}

このようなセレクタは、ドキュメント内のすべてのDOM要素に対して以下の処理を試みることになります。

  1. 対象があらゆる要素である(*により制限がない)
  2. その親のいずれかがheader要素であるかを確認する。

見るからに大変です。
このように、セレクタの評価対象が際限なく広がってしまい、マッチング処理に無駄な負荷がかかります。

実際、WebKitなど一部のブラウザエンジンでは、CSSのマッチングを後続化するための最適化処理が入っていますが、*を含むセレクタはこの最適化を無効化する可能性があります。

全称セレクタは便利なようでいて、思わぬパフォーマンスの落とし穴になりがちです。基本的には使用を避け、より限定的な要素名やクラスで明示的に指定するのが安全です。

命名と構造を整えて、ブラウザにやさしいCSSを:BEM

ここまで、CSSセレクタのマッチング処理を遅くしないために、どのようにセレクタを記述すれば良いかを見てきました。ポイントは、セレクタをシンプルに保つこと、そして基本的に1つのクラスセレクタでスタイルを当てることです。

しかし、実際のウェブサイトやアプリケーションでは、「シンプルなセレクタを保つ」だけでは済まない場面も多くあります。子孫セレクタや複雑な構造を避けようとすると、HTML側でclass属性を細かく付与する必要があり、CSSのクラス名もやや冗長になりがちです。

そうした課題を解決するために有効なのが、BEM(Block, Element, Modifier)といった命名規則を取り入れたCSS設計手法です。BEMを用いることで、クラス名だけで構造や役割が明確になり、セレクタの記述も一貫してシンプルに保てます。

結果として、保守しやすく、パフォーマンスにも優れたCSSを実現することができます。

BEMに関する詳しい解説は以下の記事でどうぞ

ただのセレクタがマッチング地獄を生む

CSSのマッチング処理(Recalculate Style)は、DOMツリーやCSSの構造によって大きくパフォーマンスが左右されます。ここでは、マッチング処理を極力発生させないための実践的な方法をいくつか紹介します。

インラインスタイルでマッチング処理を回避する

CSSのマッチング処理は、JavaScriptによってDOM構造や属性が変化した後にも発生します。このとき、レンダリングエンジンはCSSOMに登録されたすべてのCSSルールとDOM要素を突き合わせてマッチングを行います。

このマッチング処理そのものを回避する方法として、インラインスタイルの活用が挙げられます。JavaScriptによるスタイル変更には主に以下の2つの手法があります。

  • DOM要素のclass属性などを変更する
  • DOM要素のstyle属性を直接変更する(インラインスタイル)
<!-- class属性を切り替える方法 -->
<style>
.active {
  color: red;
}
</style>

<div id="target">hoge</div>

<script>
document.getElementById('target').classList.add('active');
</script>

2つ目のDOM要素のstyle属性を変更する方法は、JavaScriptで変更することで、CSSルールセットの宣言なしに、その要素に当たるCSSプロパティを設定できます。
DOM要素のstyle属性を直接変更することで、CSSセレクタのマッチング処理を避けることができます。

<!-- style属性を直接操作する方法 -->
<div id="target">hoge</div>

<script>
document.getElementById('target').style.color = 'red';
</script>

classを変更する方法では、その変更が他のDOM要素にも影響を与える可能性があるため、再マッチングの範囲が広がりがちです。一方、インラインスタイルを使えば、特定の要素に対する直接的なスタイル指定となり、マッチング処理そのものを回避できます。

ただし、保守性や可読性を考えると、インラインスタイルは限定的に使用し、基本的にはクラスによるスタイル設計を推奨します。高パフォーマンスを優先する一部のケースでのみの活用が望ましいです。

未使用のCSSルールは削除する

CSSセレクタのマッチング処理は、CSSルールセットの数が多ければ多いほど重くなります。たとえ1つのルールセットでも、すべてのDOM要素に対してマッチングチェックが行われる可能性があるため、未使用のルールが積もるほど処理負荷が増大します。

不要なセレクタやルールセットは定期的に洗い出して削除することで、Recalculate Styleの負荷を削減できます。

メディアクエリで適用範囲を限定する

メディアクエリを活用することで、条件に一致したときのみCSSルールが有効になるため、マッチング対象のCSSを限定できます。これにより、不要なルールセットの適用やマッチング処理を回避できます。

<link rel="stylesheet" href="hoge.css">
<link rel="stylesheet" media="(max-width: 768px)" href="fuga.css">
@media (max-width: 768px) {
  section {
    padding: 16px;
  }
}

このように、特定のデバイスや画面サイズでのみスタイルを適用することで、常時マッチング対象になるCSSルールセットを減らせます。

レイアウト処理を賢く抑えるテクニック

CSSの適用(Calculate Style)が完了すると、ブラウザは視覚的要素の位置や大きさを計算する「Layout(レイアウト)」処理に入ります。この工程では、ボックスモデルに基づいて各要素の具体的な表示位置が決定されます。しかし、無駄なLayoutを発生させるようなコードは、パフォーマンスの大きな低下を招きます。

ここでは、Layout処理を抑えるための具体的なテクニックを紹介します。

無駄なレイアウトを引き起こさないために

Layoutのコストは、以下の要因によって左右されます。

  • DOM要素の数や構造の複雑さ
  • CSSで指定されたプロパティの内容
  • DOM操作のタイミングや頻度

Layoutが発生する主なきっかけは以下の3点です。

  • 要素のサイズや位置の変更
  • DOMツリー構造の変更(ノードの追加・削除など)
  • 要素内のコンテンツの変更

ボックスモデルの影響
レイアウト時、各要素はボックスモデルに従って処理されます。従って、以下のようなプロパティの変更はLayoutを強制的に引き起こします。

  • サイズ指定:width, height
  • 位置指定:top, left, right, bottom
  • 余白指定:margin, padding
  • 枠線指定:border, border-width


ボックスモデル

ドキュメンに含まれるDOM要素のCSSプロパティの変更でボックスモデルに影響を与えると、Layoutを引き起こします。
要素の座標やボックスの大きさに影響を与えるCSSプロパティには、次のようなものがあります。

たとえば、margin-topをJavaScriptで変更すれば、その要素の上下関係が変わるため、周囲のDOMツリー全体に再計算が波及する可能性があります。

DOMツリーの一部変更でも全体に影響する?

Layoutの計算は、要素の変更が他の要素にどれほど影響を与えるかによって範囲が変わります。多くの場合、一部の要素に変更を加えるだけでドキュメント全体の再レイアウトが必要になります。

ただし、次のような条件を満たす要素は、その要素自身と子要素のみに影響を限定できます

  • svg要素
  • input要素(type="text"またはtype="search"
  • 次の条件をすべて満たす要素である
    • displayinlineでもinline-blockでもない
    • width, heightautoではなく、%指定でもない
    • overflowscroll, auto, hiddenのいずれか
    • table要素の子孫でない

このような条件を意識してDOMを設計することで、無駄な際レイアウトを最小限に抑えられます。

非表示でもコストがかかる - visibility: hiddenの落とし穴

非表示にする場合、display: noneを使えばLayoutの対象から完全に除外できます。一方で、visibility: hiddenを使うと、要素は見えなくなりますが領域は保持されるため、レイアウト計算の対象になります。

/* Layoutの対象外になる */
.hidden {
  display: none;
}

/* Layoutに含まれる */
.invisible {
  visibility: hidden;
}

不要なLayoutを避けたい場面では、display: noneの使用を優先すべきです。

画像サイズを明示することでLayoutを減らす

画像の表示にもLayoutが発生します。img要素にサイズ(widthheight)を指定しないと、画像の読み込み前にサイズが不明なため、仮サイズで一度Layoutが走ります。そして、読み込み完了後に再度Layoutが発生します。

<!-- GOOD -->
<img src="hoge.jpg" width="300" height="200">

<!-- BAD -->
<img src="hoge.jpg">

画像サイズを指定しないと、ページ内の他の要素の配置まで巻き込んで、複数回の再Layoutが発生することになります。画像が多いページほど影響が大きくなります。

Discussion