【実務・中級編】 レイアウト(リフロー)の発生条件と最適化 – Webブラウザの仕組み実践ガイド

お疲れ様です。今日のコードレビューで、また「やっちゃいけない場所でのレイアウト強制(強制同期レイアウト)」の臭うコードを見かけたので、少し時間をもらってこの話をしようと思います。

フロントエンドをやっていると、「アニメーションがカクつく」「スクロールが引っかかる」という壁に必ずぶつかりますよね。その原因の多くは、CSSの書き方やJavaScriptでのDOM操作によって、ブラウザが裏側で「レイアウト(リフロー)」を暴発させているからです。

今日は、ブラウザの頭の中を覗きながら、なぜリフローが重いのか、どうすればそれを回避できるのか、実務で使える具体的なパターンまで徹底的に解説していきます。

—

1. ブラウザの裏側で何が起きているか?

まず、ブラウザがHTMLを受け取って画面にピクセルを描画するまでのフローを軽くおさらいしましょう。

1. HTMLパーシング & DOMツリー構築
2. CSSパーシング & CSSOMツリー構築
3. レンダーツリー(Render Tree)の構築
4. レイアウト(Layout / リフロー):各ノードが画面上のどこに、どのサイズで配置されるべきかを計算する幾何学的計算。
5. ペイント(Paint / リペイント):色や影、境界線などのピクセルを描画する。
6. コンポジット(Composite):レイヤーを合成して画面に出力する。

この中で、CPUを最も激しく叩くのが「4. レイアウト(リフロー)」です。
「要素の幅を変えた」「親のフォントサイズが変わった」といった変更が入ると、ブラウザは「子供のサイズも再計算しなきゃいけない」「兄弟要素の位置もズレるぞ」と、DOMツリーの広範囲(最悪の場合はルート要素から)にわたって幾何学的計算をやり直します。これが「重い」と言われる所以です。

さらに最悪なのが、JavaScriptで「DOMのスタイルを書き換えた直後に、そのプロパティ(例えば `element.offsetWidth` など)を読み取る」というコードです。
ブラウザは、「おいおい、さっき変更したけど、最新のレイアウト結果を教えるために、今すぐ強制的にレイアウト計算を終わらせないと嘘になるぞ!」と、JavaScriptの実行を止めてレイアウトを即座に計算します。これが実務のパフォーマンスキラーである「強制同期レイアウト(Forced Synchronous Layout / レイアウトスラッシング)」です。

—

2. リフローを誘発するプロパティの正体

「じゃあ、どのプロパティがレイアウトを引き起こすんだ?」という話ですが、基本的には「要素のサイズや位置を変えるもの」すべてです。逆に言えば、`transform` や `opacity` はレイアウトをスキップしてコンポジットだけで処理できるため、アニメーションにはこれらを使うべき、という鉄則に繋がります。

レイアウト(リフロー)を誘発する主なプロパティ

  • ボックスモデル系: `width`, `height`, `padding`, `margin`, `border-width`
  • レイアウト・配置系: `top`, `bottom`, `left`, `right`, `position` (absolute/relativeの切り替えなど), `float`, `display` (none への切り替えなど)
  • テキスト・フォント系: `font-size`, `font-family`, `line-height`, `text-align` (※祖先要素での変更は全体に波及)

読み取るだけでリフローを強制する主なプロパティ・メソッド

JavaScriptでこれらにアクセスした瞬間、ブラウザのキャッシュが無効化され、同期的なレイアウト計算が走ります。

  • `element.offsetWidth`, `element.offsetHeight`, `element.clientWidth`, `element.clientHeight`
  • `element.getBoundingClientRect()`
  • `window.getComputedStyle()` (※一部の幾何学的プロパティ)
  • `window.scrollX`, `window.scrollY`, `element.scrollTop`, `element.scrollLeft`

—

3. 実務での最適化デザインパターン:レイアウトを最小化する

では、我々フロントエンドエンジニアは日々の実装でどう立ち回るべきでしょうか。
現場ですぐに使える具体的なテクニックをいくつか紹介します。

パターンA:DOMの読み書きを分離する(レイアウトスラッシングの防止)

ループの中でDOMの書き込みと読み取りを交互に行うのは、リフローの連続発生(スラッシング)を招く最悪のアンチパターンです。「最初にまとめて読み、次にまとめて書き込む(Read/Writeの分離)」を徹底します。

❌ やってはいけないアンチパターン:

// 各要素の幅を取得して、それに合わせたパディングを設定する(最悪の例)
const boxes = document.querySelectorAll(‘.box’);

boxes.forEach(box => {
// 読み取り(ここで強制レイアウト発生)
const currentWidth = box.offsetWidth;

// 書き込み(レイアウトが無効化される)
box.style.paddingLeft = `${currentWidth 0.1}px`;
});

⭕ 正しいベストプラクティス:

// 1. まず「読み取り」をすべて終わらせてメモリにキャッシュする
const boxes = document.querySelectorAll(‘.box’);
const widths = Array.from(boxes).map(box => box.offsetWidth);

// 2. その後で「書き込み」をまとめて実行する
boxes.forEach((box, index) => {
box.style.paddingLeft = `${widths[index] 0.1}px`;
});

これだけで、ブラウザのレイアウトエンジンが無駄な再計算を行う回数劇的に減らすことができます。

—

パターンB:アニメーションは `transform` と `opacity` に逃がす

「要素を動かす」「拡大・縮小する」という要件があったとき、うっかり `left` や `width` をアニメーションさせていませんか?これらは毎フレームリフローを引き起こし、モバイル端末では確実にフレームレートが低下(カクつき)します。

❌ 避けるべき実装:

.modal {
/ topやleftを変更すると毎フレームリフローが発生する /
position: absolute;
top: 0;
transition: top 0.3s ease;
}
.modal.is-open {
top: 100px;
}

⭕ 推奨する実装:

.modal {
/ transformならGPUレイヤーで処理され、リフローを完全にバイパスできる /
transform: translateY(0);
transition: transform 0.3s ease;
will-change: transform; / ブラウザに事前にヒントを与える /
}
.modal.is-open {
transform: translateY(100px);
}

`transform` や `opacity` の変更は、ブラウザの「コンポジター线程(Compositor Thread)」で処理されるため、メインスレッドがJavaScriptの実行などで忙しいときでも、比較的滑らかにアニメーションしてくれます。

—

パターンC:`will-change` プロパティの正しい使い方

先ほどのコードにも出てきましたが、`will-change: transform;` はブラウザに対して「この要素はもうすぐ変形するから、あらかじめ独立したレイヤー(GPUレイヤー)に昇格させておいてくれ」と伝える強力なヒントです。

ただし、すべての要素にこれを貼るのは絶対にNGです。GPUのメモリを無駄に消費し、かえってパフォーマンスの劣化を招きます。
「モーダルの開閉」や「複雑なカルーセルのスワイプ」など、ユーザーインタラクションの直前に付与するか、頻繁に動くことが確実なインタラクティブ要素にのみ限定して使うようにしてください。

—

最後に:シニアからのメッセージ

ブラウザは私たちが書いたコードの意図を汲み取り、賢く最適化しようと必死に裏で頑張っています。しかし、その裏側の仕組み(特にレイアウトとペイントのコスト)を無視した雑なDOM操作を繰り返すと、ブラウザは白旗を上げ、ユーザーの端末は発熱し、画面はカクつきます。

「動けばいいや」ではなく、「このコードはブラウザにどれだけの計算コストを払わせているか?」を想像できるようになると、ワンランク上のフロントエンドエンジニアになれます。

明日からのコードレビュー、そして自分の実装で、ぜひこの「リフローの最小化」を意識してみてください。それでは、また!

コメント

タイトルとURLをコピーしました