【実務・中級編】 レイアウト(リフロー)の仕組み – Webブラウザの仕組み実践ガイド

「画面がなんかガクつく」「スクロールやアニメーションが滑らかじゃない」――。
フロントエンドの現場で誰もが一度は直面し、そして多くの若手が「なんとなく重いから、なんとなく軽そうなコードに書き換える」という暗中模索に陥る問題。その諸悪の根源の多くは、ブラウザが裏側で実行している「レイアウト(リフロー)」の制御不全にあります。

こんにちは。チームのフロントエンドのパフォーマンス設計を担当しているシニアアーキテクトです。

今日は、フレームワークの機能比較のような表面的な話はいったん脇に置いておきましょう。私たちが毎日向き合っている「Webブラウザ」という、地球上で最も洗練されたランタイムが、HTMLとCSSからどのようにしてピクセルを計算し、画面に描き出しているのか。その中でも特にCPUの計算資源を喰い潰す「レイアウト(Layout / FirefoxではReflowと呼ばれるプロセス)」の深淵に迫ります。

仕様書の裏側にあるブラウザの「泥臭い物理演算」を理解すれば、あなたの書く1行のコードが、劇的にブラウザに優しく、そしてユーザーにとって極上のサクサク感をもたらすようになります。じっくり腰を据えて学んでいきましょう。

—

1. ブラウザの裏側で何が起きているか:DOM/CSSOMからレイアウトへの旅

まずは、ブラウザがWebページを描画するパイプライン全体における「レイアウト」の位置づけを整理しましょう。

[HTML] -> DOM Tree ──┐
├─> Render Tree (Layout Tree) ──> [レイアウト(リフロー)] ──> [ペイント] ──> [コンポジット合成]
[CSS] -> CSSOM Tree ─┘ ★今回の主役

ブラウザはHTMLを解析して「DOMツリー」を、CSSを解析して「CSSOMツリー」を作ります。これらをガッチャンコして、画面に実際に表示される要素だけを集めた「レンダーツリー(BlinkレンダリングエンジンではLayout Treeと呼びます)」を構築します(`display: none` の要素はここに含まれません)。

ここからがレイアウト(リフロー)の出番です。

レイアウトの本質は「ジオメトリ(幾何学)の決定」

レンダーツリーができた時点では、ブラウザはまだ「どの要素が、画面のどこに、どのサイズで表示されるべきか」を知りません。知っているのは「この要素は `width: 50%` で、フォントサイズは `16px` だ」というような、相対的、あるいは抽象的な宣言だけです。

レイアウトプロセスの目的は、「ビューポート(画面)の左上を原点 `(0, 0)` としたときの、各ノードの正確な絶対位置(X座標、Y座標)と絶対サイズ(幅、高さ)をピクセル単位で計算し尽くすこと」です。

1. 上から下へのトラバース(走査):
ブラウザは、レンダーツリーのルート(``, ``)から始めて、子要素へと再帰的にレイアウト処理を伝播させます。
2. 境界ボックス(Bounding Box)の計算:
親要素の幅を基準に、子要素の `margin`、`border`、`padding`、そしてコンテンツ自体の幅を考慮して、その要素の「箱」のサイズを決定します。
3. 高さの決定:
Webの基本特性として、幅は「親要素のサイズ」で決まり、高さは「中のコンテンツ(テキストの折り返しなど)の量」によって決まります。そのため、文字が1行折り返すだけで、その要素の高さが変わり、後続するすべての要素の位置がドミノ倒しのように下にズレることになります。

この「ドミノ倒し」こそが、レイフロー(再フロー=リフロー)が重い処理である最大の理由です。

—

2. リフローを引き起こす「トリガー」と、その恐るべき影響範囲

では、実務においてどのような操作がリフローを発生させるのでしょうか。
基本的には「要素の幾何学的形状(ジオメトリ)に変化を与える操作」すべてが対象です。

リフローを誘発する主なCSSプロパティ

  • サイズ関連: `width`, `height`, `padding`, `margin`, `border`, `min-height`, `box-sizing`
  • 配置・レイアウト関連: `position`, `top`, `left`, `float`, `clear`, `display`, `flex-direction`, `grid-template-columns`
  • テキスト関連(高さに影響するため): `font-size`, `line-height`, `font-family`, `text-align`, `white-space`

影響範囲(Scope)のリアル

ブラウザは非常に賢いので、何か一部が変わったからといって、常に画面全体(Global Layout)を再計算するわけではありません。

変更されたノードに「Dirty(汚れた)」フラグを立て、そのノードと、その変更によって影響を受ける局所的な範囲だけを再計算しようと試みます(これを Incremental Layout(インクリメンタル・レイアウト) と呼びます)。

しかし、現代の複雑なWebアプリケーションでは、たった1つの要素の幅が変わっただけで、親要素の高さが変わり、その親の兄弟要素が押し下げられ、結果的にページ全体のレイアウト再計算(Global Layout)に発展することが珍しくありません。

—

3. 現場を凍りつかせる「Layout Thrashing(強制同期レイアウト)」の罠

ここからが、中級エンジニアが絶対にマスターすべき、そして現場で最も頻出するパフォーマンスバグの解説です。その名も「Layout Thrashing(レイアウト・スラッシング / 強制同期レイアウト)」。

通常、JavaScriptでDOMのスタイルを変更(書き込み)しても、ブラウザは即座にレイアウトを計算しません。そんなことを毎回やっていたら動作がガタガタになるからです。ブラウザは「書き込み要求」をキューに溜め、現在のフレームの最後にバッチ処理(まとめて一括処理)としてレイアウトを実行します。

しかし、「書き込み」の直後に、JavaScriptで要素のサイズや位置を「読み取る」コードを書くと、この美しい最適化が完全に破壊されます。

なぜ起きるのか?

ブラウザは、JSから「今の正確な `offsetHeight` はいくつか?」と聞かれたとき、手元に最新のレイアウト結果がなければ正しい値を返せません。
そのため、キューに溜まっていた「書き込み(変更)」をその場で大急ぎで適用し、JSの実行を一時停止させて、強制的にその瞬間にレイアウト(リフロー)を実行します。

これが、ループ処理の中で交互に行われたらどうなるでしょうか?

// 【極悪非道なアンチパターン】ループ内での「読み取り」と「書き込み」の交互実行
for (let i = 0; i < paragraphs.length; i++) { // 1. 読み取り(ブラウザはここで強制的にリフローを実行せざるを得ない!) const width = container.offsetWidth; // 2. 書き込み(DOMを汚す) paragraphs[i].style.width = width + 'px'; } ループが100回回れば、1フレーム(16.7ms)の中で100回のリフローが発生します。画面は完全にフリーズします。これが「Layout Thrashing(レイアウトの鞭打ち)」と呼ばれる現象です。

—

4. 【実践】リフローを最小限に抑えるプロのコーディング手法

この問題をいかにして美しく解決するか。現場ですぐに使える3つの処方箋を提示します。

処方箋①: 読み取り(Read)と書き込み(Write)の分離(バッチ化)

基本原則は「Readは先にまとめて行い、Writeは後からまとめて行う」です。

// 【ベストプラクティス】ReadとWriteを完全に分離する
// 1. まず必要な情報をすべて「読み取る」(リフローは発生しない、または1回で済む)
const targetWidth = container.offsetWidth;

// 2. 次に、一括で「書き込む」
for (let i = 0; i < paragraphs.length; i++) { paragraphs[i].style.width = targetWidth + 'px'; }

処方箋②: `requestAnimationFrame` (rAF) の活用

非同期でDOMの更新スケジュールをブラウザの描画タイミング(通常は60Hz、120Hzなど)に同期させる強力なAPIです。特に、スクロールイベントやリサイズイベントと連動してDOMを動かす場合は必須のテクニックです。

// スクロール時の処理を最適化する例
let ticking = false;

window.addEventListener(‘scroll’, () => {
if (!ticking) {
window.requestAnimationFrame(() => {
// 描画直前のタイミングで、安全にDOMの書き込みを行う
updateElements();
ticking = false;
});
ticking = true;
}
});

処方箋③: CSS `contain` プロパティによるレイアウトの「隔離」

これはモダンブラウザが提供する非常に強力な武器です。
CSSの `contain: layout;`(あるいは `contain: paint;` / `contain: content;`)を指定すると、「この要素の内部で発生したレイアウトの変化は、要素の外側には一切影響を与えない」とブラウザに宣言できます。

/
このカード要素の中でどれだけDOMが動いたりサイズが変わったりしても、
ブラウザはカードの外側のレイアウトを再計算(リフロー)しない。
/
.card-component {
contain: layout;
width: 300px;
height: 400px; / サイズを固定しておくとさらに効果的 /
}

これを適切に使うことで、大規模なシングルページアプリケーション(SPA)でも、リフローの影響範囲をコンポーネントの内部だけに「完全隔離」することができます。

—

5. 現場ですぐに試せる!検証用サンドボックスコード

理論を学んだら、次は自分の目で確かめる番です。
以下は、「Layout Thrashingが発生する悪いコード」と「最適化された良いコード」の差を、Chrome DevToolsのPerformanceパネルで視覚的に比較できるHTMLコードです。

そのまま1つの `.html` ファイルとして保存し、ブラウザで開いてみてください。





Layout Thrashing Sandbox


レイアウト・スラッシング 検証

ボタンを押して、処理にかかる時間とDevToolsの「Performance」タブの警告を確認してください。


実行時間: — ms



DevToolsでの検証手順

1. このHTMLファイルをChromeで開きます。
2. `F12`(Macは `Cmd + Option + I`)でデベロッパーツールを開き、「Performance」タブを選択します。
3. 記録(Record)ボタン(左上の丸ボタン)を押します。
4. 「悪い例を実行」ボタンを押し、数秒待ってから記録を停止します。
5. タイムライン上に並ぶ赤い三角マーク(Warning)と、大量の「Forced Reflow(強制リフロー)」の警告を確認してください。
6. 同様に「良い例を実行」で記録をとると、一瞬で処理が終わり、警告が完全に消え去っていることが確認できるはずです。

---

6. まとめ:仕様を知る者が、真に美しいコードを書く

今回の話をまとめましょう。

  • レイアウト(リフロー)は、要素の正確な位置とサイズ(幾何学)をピクセル単位で計算する重い処理。
  • Layout Thrashingは、JSによる「DOM書き込み」と「幾何学情報の読み取り」が交互に発生することで引き起こされる。
  • 解決策は、「ReadとWriteの徹底的な分離」、`requestAnimationFrame`による描画同期、そしてCSS `contain`プロパティによる計算範囲の隔離。

「動けばいい」というフェーズを越え、「いかにブラウザのレンダリングエンジンと協調して動作させるか」を意識し始めたとき、あなたのエンジニアとしての戦闘力は一段上のステージへと引き上がります。

次にコードを書くときは、その1行がブラウザのパイプラインにどんな「波紋」を広げるか、ぜひ想像してみてください。

チームのみんなが書くコードが、もっと洗練されたものになることを期待しています。何かわからないことがあれば、いつでもSlackでメンションを飛ばしてくださいね。それでは、良いハッキングを!

コメント

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