仕組みから学ぶ Web
応用読了目安 13#レンダリング#リフロー#CSS#パフォーマンス

レンダリングパイプライン — どのCSSが重いかは工程で決まる

ブラウザが HTML を受け取ってから画面を描くまでの工程。どのプロパティを触ると、どの工程からやり直しになるのか。

この記事の進み方

What — HTML から画面までの5工程

Figureレンダリングパイプライン
  1. 1① Styleどの要素にどの CSS が当たるかを決める
  2. 2② Layout(リフロー)各要素の位置と大きさを計算する重い
  3. 3③ Paint(描画)ピクセルの色を決める
  4. 4④ Composite(合成)レイヤーを重ねて1枚の画面にする軽い
  5. 5⑤ 画面へディスプレイに出す軽い

top / width / margin を変えると ② Layout から。color / background を変えると ③ Paint から。transform / opacity を変えると ④ Composite から。同じ「動かす」でも、やり直す工程の数が 3 対 1 になる。

1/5
① Styleセレクタを照合し、各要素の最終的なスタイル値を確定させる。クラスの付け外しはここから再実行される。

上から順に流れる。あるプロパティを変更すると、その工程以降がすべてやり直しになる。どこから再開されるかが、そのプロパティの「重さ」。

同じ「動かす」でも工程数が違う

Compare要素を 100px 下へ動かす2つの方法

位置の指定が変わるため、レイアウトの再計算から始まる。

element.style.top = '100px';

Style → Layout → Paint → Composite
      ~~~~~~~~~~~~~~~~~~~~~~~~~~
      3工程やり直し
やり直す工程
Layout から3つ
影響範囲
周囲の要素にも波及
処理する場所
CPU(メインスレッド)
毎フレーム実行
厳しい
  • position: absolute なら周囲への波及は減るが、Layout 自体は走る。
  • メインスレッドで動くため、JS の処理が詰まっていると一緒に止まる。

見た目は完全に同じ。しかしブラウザがやり直す工程の数が違う。アニメーションのように毎フレーム実行される処理では、この差がそのまま体感になる。

確認 — ここまで読めたか

要素を動かすとき、top の書き換えと transform: translateY() で何が違いますか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — なぜ Layout が飛び抜けて重いのか

Layout が重いのは、1箇所の変更が全体に波及しうるからです。

ある要素の幅を広げると、その中のテキストの折り返し位置が変わります。行数が変われば高さが変わり、高さが変われば下にある要素が押し下げられ、それが親の高さを変え、さらに上の階層に波及する。変更の影響範囲を事前に限定できないため、ブラウザは広い範囲を計算し直します。

Paint は影響が限定的です。色が変わっても、他の要素の位置は動きません。Composite に至っては、すでに描き終わった絵を GPU で重ねるだけなので、計算らしい計算がありません。

この3段階の差は構造的なものであり、ブラウザの実装が改善しても順序は変わりません。

強制同期レイアウトという落とし穴

さらに厄介なのが、読み取りと書き込みを交互に行うパターンです。

// これは非常に遅い
for (const el of elements) {
  el.style.width = el.offsetWidth + 10 + 'px';
  //               ~~~~~~~~~~~~~~ 読み取り
  // ~~~~~~~~~~~~~ 書き込み
}

ブラウザは、書き込みをすぐには反映せず溜めておきます。しかし offsetWidth のような現在の値を問い合わせる操作が来ると、溜めた変更を全部適用して Layout を走らせないと正しい値が返せません。

結果として、ループの回数だけ Layout が実行されます。100要素なら100回。これが強制同期レイアウトです。

// 読み取りを先に全部済ませてから、書き込みをまとめる
const widths = elements.map((el) => el.offsetWidth);
elements.forEach((el, i) => {
  el.style.width = widths[i] + 10 + 'px';
});
// Layout は 1 回で済む
確認 — ここまで読めたか

ループの中で offsetHeight を読んでから style を書く、を繰り返すと何が起きますか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この実装のうち、どこを直せばスクロールが滑らかになりますか?

与えられた条件
  • 記事ページで、スクロール量に応じてヘッダーを縮め、進捗バーを伸ばす実装
  • scroll イベントのハンドラで、以下を毎回実行している
  • ① const scrolled = window.scrollY / document.body.scrollHeight
  • ② header.style.height = (80 - scrolled * 40) + 'px'
  • ③ bar.style.width = (scrolled * 100) + '%'
  • ④ if (header.offsetHeight < 60) header.classList.add('compact')
  • 記事は長く、DOM 要素は 3000 個ほどある
この軸で考える
  • · 各行がパイプラインのどの工程から再開させるか
  • · 読み取りと書き込みが同じループ内で交互に起きていないか
  • · 見た目を保ったまま、Composite だけで済ませる書き方はあるか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「スマホだけスクロールがカクつく」と相談された相手に、見るべき順番を説明してください

与えられた条件
  • 相手は scroll イベントで要素の位置を書き換えている
  • 相手はデスクトップでは問題を再現できていない
  • 相手は「スマホが遅いから仕方ない」と考えている

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。

この記事を前提にしている記事