🤖

ブラウザのレンダリングパイプラインとパフォーマンス最適化について

に公開

現代のウェブアプリケーションでは、気づけばレイアウトスラッシングやパフォーマンス低下を引き起こすコードを書いていて動作が重くなることがあります。
DOMのちょっとした変更がUIのカクつきやレスポンス低下を引き起こすこともあります。

その根本原因を理解するためには、レンダリングパイプラインを正しく把握することが重要です。
本記事ではレンダリングの仕組みと、それを踏まえた実践的なパフォーマンス最適化手法をまとめていきます。

レンダリングパイプラインとは

ブラウザはHTMLとCSSを元に、以下の流れで画面をレンダリングします。
Reflow / Layout>Repaint / Paint>Compositeで上流の処理に戻れば戻るほどコストが高く、パフォーマンス低下を引き起こします。そのため、不要な巻き戻りを避けることがパフォーマンス改善につながっていきます。

  1. DOM の生成
  2. CSSOM の生成
  3. レンダーツリー構築
  4. レイアウト(Reflow / Layout):各要素の位置・サイズを計算します。
  5. ペイント(Repaint / Paint):背景やテキストをピクセルへ描画します。
  6. コンポジット(Composite):ペイントされたレイヤーを GPU 等で合成します。

処理の上流に戻るほどコストが高いため、不要な巻き戻りを避けることがパフォーマンス改善につながります。

Reflow / Repaint / Composite の違い

できる限り Composite だけで済む変更 に抑えると良いです。

処理 内容 コスト
Reflow(レイアウト) 幾何計算を再実行
Repaint(再描画) 見た目のみ再描画
Composite(合成) レイヤーを再合成

どの変更がどこに影響するか

変更内容 Reflow Repaint Composite
幅・高さの変更
背景色の変更
transform / opacity
DOM の追加・削除

パフォーマンスを落とさないための最適化

1. レイアウトスラッシングの回避

読み取りと書き込みを分離してレイアウトスラッシングの回避します。

悪い例:

const items = document.querySelectorAll('.item');
for (const el of items) {
  const w = el.offsetWidth; // 読み取り
  el.style.width = (w + 10) + 'px'; // 書き込み
}

改善例:

const items = document.querySelectorAll('.item');
const widths = items.map(el => el.offsetWidth); // 読み取りのみ
widths.forEach((w, i) => {
  items[i].style.width = (w + 10) + 'px'; // 書き込みのみ
});

2. アニメーションは transform / opacity を使う

top / left を変更するとレイアウト処理が走るので、transform, opacityを使用します。

.move {
  transition: transform 300ms;
}
el.classList.add('move');
el.style.transform = 'translateY(150px)';

3. スタイルはクラス切り替えでまとめる

それぞれで変更を加えるより、クラスで一括してスタイルを変更したほうがパフォーマンス的に良い

.dark {
  background: #222;
  color: white;
  border-color: #444;
}
el.classList.add('dark');

4. DOM 操作はまとめる

DocumentFragment や innerHTML を活用します。

const frag = document.createDocumentFragment();
for (let i = 0; i < 500; i++) {
  const li = document.createElement('li');
  li.textContent = `Row ${i}`;
  frag.appendChild(li);
}
list.appendChild(frag);

5. requestAnimationFrame() で更新タイミングを制御

ブラウザのフレームと同期します。

requestAnimationFrame(() => {
  box.style.transform = 'translateX(100px)';
});

6. 必要な時だけwill-changeを使用する

will-changeを永続的に使用するとメモリ浪費を招きます。

el.style.willChange = 'transform';
requestAnimationFrame(() => {
  el.style.transform = 'scale(1.2)';
});
el.addEventListener('transitionend', () => {
  el.style.willChange = '';
});

7. content-visibility / contain で影響範囲を限定

表示領域外のレンダリングを省略できます。

.card {
  content-visibility: auto;
  contain-intrinsic-size: 600px;
}

8. display:none と visibility:hidden を使い分ける

displayを使用すると構造が破壊されるので、レイアウト破壊を避けたい時はvisibilityを使用します。

el.style.visibility = 'hidden'; // Repaint のみ
// layout にも影響させたい時だけ
el.style.display = 'none'; // Reflow 発生

9. resize / scroll はデバウンス・スロットル

頻発するイベントでReflowが多発するのを防ぎます。

let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const rect = el.getBoundingClientRect();
    indicator.style.width = rect.width + 'px';
    ticking = false;
  });
});

10. 長いリストは仮想化する

// translateY で合成に寄せる
pool.style.transform = `translateY(${offsetY}px)`;
pool.innerHTML = html; // 一括更新

11. 強制レイアウト処理を避ける

下記APIを使用すると強制レイアウト処理が走るので注意が必要です。

  • offsetWidth
  • offsetHeight
  • getBoundingClientRect()
  • scrollTop / scrollHeight etc.

DevTools による検証方法

Chrome DevTools → Performanceで確認します。
また、Rendering タブ → Paint Flashing を有効にすると再描画領域が可視化できます。

  • Recalculate Style
  • Layout
  • Paint
  • Composite Layers

まとめ

ブラウザの仕組みを理解した実装は、ユーザー体験を大きく改善します。
継続的に最適化することを心がけていきましょう。

参考

https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/How_browsers_work#render
https://web.dev/articles/critical-rendering-path
https://developer.mozilla.org/en-US/docs/Web/CSS/transform
https://developer.mozilla.org/en-US/docs/Glossary/Reflow
https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
https://developer.mozilla.org/en-US/docs/Web/CSS/will-change
https://developer.mozilla.org/en-US/docs/Web/CSS/visibility
https://csstriggers.com/

Discussion