レンダリングパイプライン — どのCSSが重いかは工程で決まる
ブラウザが HTML を受け取ってから画面を描くまでの工程。どのプロパティを触ると、どの工程からやり直しになるのか。
この記事の進み方
What — HTML から画面までの5工程
- 1① Styleどの要素にどの CSS が当たるかを決める中
- 2② Layout(リフロー)各要素の位置と大きさを計算する重い
- 3③ Paint(描画)ピクセルの色を決める中
- 4④ Composite(合成)レイヤーを重ねて1枚の画面にする軽い
- 5⑤ 画面へディスプレイに出す軽い
top / width / margin を変えると ② Layout から。color / background を変えると ③ Paint から。transform / opacity を変えると ④ Composite から。同じ「動かす」でも、やり直す工程の数が 3 対 1 になる。
上から順に流れる。あるプロパティを変更すると、その工程以降がすべてやり直しになる。どこから再開されるかが、そのプロパティの「重さ」。
同じ「動かす」でも工程数が違う
位置の指定が変わるため、レイアウトの再計算から始まる。
element.style.top = '100px';
Style → Layout → Paint → Composite
~~~~~~~~~~~~~~~~~~~~~~~~~~
3工程やり直し- やり直す工程
- Layout から3つ
- 影響範囲
- 周囲の要素にも波及
- 処理する場所
- CPU(メインスレッド)
- 毎フレーム実行
- 厳しい
- –position: absolute なら周囲への波及は減るが、Layout 自体は走る。
- –メインスレッドで動くため、JS の処理が詰まっていると一緒に止まる。
見た目は完全に同じ。しかしブラウザがやり直す工程の数が違う。アニメーションのように毎フレーム実行される処理では、この差がそのまま体感になる。
要素を動かすとき、top の書き換えと transform: translateY() で何が違いますか?
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 を書く、を繰り返すと何が起きますか?
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
この実装のうち、どこを直せばスクロールが滑らかになりますか?
- 記事ページで、スクロール量に応じてヘッダーを縮め、進捗バーを伸ばす実装
- 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 だけで済ませる書き方はあるか
「スマホだけスクロールがカクつく」と相談された相手に、見るべき順番を説明してください
- 相手は scroll イベントで要素の位置を書き換えている
- 相手はデスクトップでは問題を再現できていない
- 相手は「スマホが遅いから仕方ない」と考えている
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。