やあ。最近、プロダクトのパフォーマンス改善に頭を悩ませているみたいだな。
「DOM要素を少し動かしただけなのに、なぜかアプリ全体がカクつく」「スマホの実機でスクロールすると一瞬フリーズする」。この手のパフォーマンスの闇に直面したとき、君はどこから手をつける?
とりあえず `console.log` を仕込んだり、手当たり次第に `will-change: transform` を貼り付けたりしていないだろうか? ちょっと待ってくれ。それ、医者が聴診器も当てずに手術を始めるようなものだ。
今回は、ブラウザの裏側で何が起きているのかを丸裸にし、DevToolsを相棒にして「ペイントフラッシング(再描画領域の可視化)」を使いこなしながら、真のボトルネックを特定する極意を伝授しよう。
—
ブラウザの裏側:なぜ画面は「再描画」で重くなるのか?
まず、ブラウザがHTMLをパースしてから画面にピクセルを描き出すまでの旅路を思い出してほしい。
HTML ParserがDOMを作り、CSSOMと合体してRender Treeを構築する。ここまではいい。問題はその先だ。
1. Layout (Reflow): 各ノードが画面上のどこに、どのサイズで配置されるかを計算する。
2. Paint: テキストの色、影、ボーダーなど、ピクセル単位に落とし込むための「描画コマンド(ディスプレイリスト)」を生成する。
3. Composite: レンダリングされたレイヤーを合成し、GPUへ送り出して画面に映し出す。
この中で、最もCPUをゴリゴリ削り、メインスレッドをブロックする元凶が 「Paint(再描画)」 だ。
JavaScriptでDOMのスタイル(例えば `background-color` や `box-shadow` など)を書き換えると、ブラウザは「おっと、そこだけ見栄えが変わったから塗り直さなきゃいけないな」と判断する。これがペイント(Repaint)だ。
そして最悪なのが、このペイントが「画面の広範囲に波及する現象」。これが今回の主役である「ペイントフラッシング(Paint Flashing)」の温床になる。ブラウザは無駄に画面全体、あるいは大きな矩形領域を塗り直そうとしやがる。これが原因で、メインスレッドが悲鳴を上げ、フレームレート(60fps / 120fps)が盛大にドロップするんだ。
—
DevToolsでペイントフラッシングを可視化する
百聞は一見にしかず。ブラウザに「どこを塗り直しているか」を緑色のフラッシュで告発させよう。
手順:
1. Chromeで対象のWebページを開き、`F12`(または右クリックから「検証」)でDevToolsを開く。
2. コマンドメニュー(Mac: `Cmd + Shift + P` / Windows: `Ctrl + Shift + P`)を開く。
3. `Rendering` と入力し、「Rendering」パネルを表示するを選択。
4. その中にある 「Paint flashing」 にチェックを入れる。
さあ、ページをスクロールしたり、ボタンをクリックしたりしてみてくれ。
画面のあちこちで、緑色の矩形がピカピカと点滅するはずだ。この緑色に光った領域こそ、ブラウザが「あ、ここ塗り直さなきゃ…」と汗水垂らして再描画コストを支払っている現場だ。
もし、画面のほんの一部(例えば小さなトグルスイッチ)をいじっただけなのに、画面の大部分が緑色に発光していたら?
……おめでとう、そこが君のアプリケーションのパフォーマンス・ボトルネックだ。レイヤーの切り分けができておらず、無駄な広範囲ペイントが発生している証拠だな。
—
実践:重い処理と、それを救うクリーンなコード
百聞は一見にしかず、実際に「重い再描画を引き起こすアンチパターン」と、それを現代のブラウザの仕組みに合わせてスマートに解決するコードを見てみよう。
以下のサンプルは、動的に要素を生成・アニメーションさせるコンポーネントのイメージだ。
❌ やってはいけないアンチパターン(無駄なレイアウト&ペイントの嵐)
// 【アンチパターン】
// スクロールやタイマーのたびにレイアウトプロパティ(offsetTopやwidthなど)を読み取り、
// さらにスタイルを直接書き換えることで、強制同期レイアウト(Layout Thrashing)と
// 広範囲のペイントフラッシングを引き起こす最悪の例。
const box = document.getElementById(‘target-box’);
window.addEventListener(‘scroll’, () => {
// 1. DOMからレイアウト情報を強制的に読み取る(これだけでメインスレッドが同期的に計算を強いられる)
const currentTop = box.getBoundingClientRect().top;
// 2. スタイルを書き換えることで、Layout -> Paint のフルコンボが毎スクロールイベントで発火
box.style.top = (currentTop + 1) + ‘px’;
box.style.boxShadow = `0 4px ${Math.abs(currentTop) % 20}px rgba(0,0,0,0.5)`;
});
なにが駄目か分かるかい? `getBoundingClientRect` でレイアウトを強制的に同期取得した直後にスタイルをいじっているため、ブラウザは「計算して、描いて、また計算して……」という地獄のループ(Layout Thrashing)に陥る。Paint flashingをオンにすれば、画面中が緑色のストロボのように激しく点滅するはずだ。
—
⭕ シニアが推すベストプラクティス(GPUレイヤーの分離と合成)
ペイントを発生させず、GPUの合成(Composite)だけでアニメーションを完結させるのが現代の鉄則だ。`transform` と `opacity` だけを動かせば、LayoutもPaintもバイパスしてCompositeフェーズだけで処理できる。
このコードを動かしてみるといい。さっきまで緑色に発光していたボックスが、嘘のように静まり返るはずだ。
ブラウザは「お、こいつはレイヤーが分離されていて `transform` しか変わらないから、ペイントし直す必要ないな。GPUにそのままパッと動かしてもらおう」と判断してくれる。これが、我々フロントエンドエンジニアが目指すべき「ブラウザに愛されるコード」だ。
—
チーフアーキテクトからの実務アドバイス
現場でパフォーマンスチューニングを行うときの鉄則を最後に伝えておこう。
1. 推測するな、計測しろ(Measure, Don’t Guess):
感覚で「ここが重いはずだ」と決めつけて手を動かすな。まずDevToolsの `Performance` パネルでプロファイリングを録り、どこでLong Task(50ms以上のメインスレッド占有)が起きているか、Paintに何ミリ秒溶かしているかを数値で確認しろ。
2. レイヤーの乱用には気をつけろ:
`will-change` や `translate3d(0,0,0)` は万能薬ではない。すべての要素を別レイヤー(Composite Layer)に昇格させると、今度はVRAM(GPUのメモリ)を食い潰しすぎて逆にアプリがクラッシュするという別の悪夢を見る。本当に滑らかに動かしたいアニメーション要素や、スクロールに追従するヘッダーなどに絞って適用するのがプロの技だ。
さあ、今日の業務に戻ったら、まずはプロダクトの重いページで `Paint flashing` をオンにしてみてくれ。きっと新しい発見があるはずだ。
もしまた詰まったら、いつでも私を頼るといい。健闘を祈る!

コメント