【実務・中級編】 強制同期レイアウト(Layout Thrashing) – Webブラウザの仕組み実践ガイド

「ブラウザを泣かせるな」——強制同期レイアウト(Layout Thrashing)を根絶する技術

フロントエンドエンジニアとして数年経験を積むと、ふと「なぜか画面のスクロールがカクつく」「特定の操作で一瞬だけフリーズする」という壁にぶつかるはずだ。

その犯人の多くは、強制同期レイアウト(Layout Thrashing)だ。ブラウザの内部処理を知らないと、何気なく書いた一行のJavaScriptが、ブラウザに「今すぐ全部計算し直せ!」と無理難題を押し付ける引き金になる。

今日は、ブラウザの「心臓部」が裏側で何を叫んでいるのか、そして僕たちがどうコードを洗練させるべきか、現場の視点で語ろう。

—

ブラウザは「怠け者」であるべきだ

まず、ブラウザのレンダリングパイプラインを思い出してほしい。
1. DOM構築
2. CSSOM構築
3. Render Tree構築
4. Layout(計算)
5. Paint(描画)

ブラウザは非常に賢い。一度Layoutが発生すると、次のフレームの描画まで「Layoutは古いものだ」と認識し、再計算を先延ばし(バッチ処理)する。この「怠け者」のような最適化のおかげで、スムーズな60fpsの描画が保たれているんだ。

しかし、JavaScriptでDOMを更新した直後に、その要素のサイズや位置(`offsetWidth`や`getBoundingClientRect`など)を参照しようとすると話が変わる。

ブラウザはこう考える。「うわ、ユーザーが今すぐ正確な座標を知りたがっている。溜まっているLayoutタスクを全部終わらせて、最新の数値を返さないと!」

これが強制同期レイアウトだ。JavaScriptの実行中に強制的にLayoutフェーズが割り込まれ、ブラウザの最適化サイクルが破壊される。これが繰り返されると、ユーザーには「重いサイト」として映る。

—

「悪のコード」と「賢いコード」

まずは、やってはいけない「悪のパターン」を見てみよう。

// 悪のパターン:Layout Thrashingの温床
const list = document.getElementById(‘list’);
const items = list.querySelectorAll(‘.item’);

items.forEach(item => {
// ① スタイルを書き換える
item.style.width = ‘200px’;

// ② 直後に幅を取得する(ここで強制同期レイアウトが走る!)
// ループのたびに計算が走り、CPUは悲鳴を上げる
console.log(item.offsetWidth);
});

このコードは、要素の数だけ計算を強制する。もし100個の要素があれば、100回Layoutを走らせているんだ。ブラウザにとっては拷問に近い。

解決策:読み込みと書き込みを分離する(Batching)

対策はシンプルだ。「書き込み(Write)」と「読み込み(Read)」を完全に分離すればいい。一気に書き込み、一気に読み込む。これだけでブラウザの負担は激減する。

// 賢いパターン:読み込みと書き込みの分離
const list = document.getElementById(‘list’);
const items = list.querySelectorAll(‘.item’);

// 1. 書き込み処理(DOMの更新)を先にまとめて行う
items.forEach(item => {
item.style.width = ‘200px’;
});

// 2. 読み込み処理(Layoutが必要なプロパティ取得)を後でまとめて行う
items.forEach(item => {
// ここではすでに一度のLayoutで済んでいるはずだ
console.log(item.offsetWidth);
});

—

実務で使える「FastDOM」的アプローチ

実務の現場では、コンポーネントが複雑に絡み合い、どこでDOM操作が発生しているか追いにくいこともあるだろう。そんな時は、「読み込み」と「書き込み」のキューを作るという戦略が極めて有効だ。

以下は、実務で使える非常に軽量なパターンの実装例だ。

/

  • 簡易的なLayout Thrashing回避ユーティリティ

/
const scheduler = {
reads: [],
writes: [],

// 読み込みタスクを予約
read(fn) { this.reads.push(fn); },

// 書き込みタスクを予約
write(fn) { this.writes.push(fn); },

// フレームの最後に一括実行
flush() {
this.reads.forEach(fn => fn());
this.writes.forEach(fn => fn());
this.reads = [];
this.writes = [];
}
};

// 使用例:
scheduler.write(() => { element.style.width = ‘100px’; });
scheduler.read(() => { console.log(element.offsetWidth); });

// requestAnimationFrameの中でflushを呼べば、ブラウザの描画タイミングに完璧に同期できる
requestAnimationFrame(() => scheduler.flush());

—

最後に:シニアからのアドバイス

ブラウザのレンダリングを完全にコントロールしようと考えるのは傲慢だ。しかし、「ブラウザに無駄な計算をさせない」ことは、一流のフロントエンドエンジニアとしての最低限の礼儀だ。

  • `offsetHeight`, `getBoundingClientRect`, `getComputedStyle` を使うときは、必ず心の中でアラートを鳴らせ。
  • Chrome DevToolsの「Performance」タブを見て、赤い警告(Recalculate Style)が頻発していないか確認する癖をつけよう。

コードはただ動けばいいわけじゃない。ブラウザという名の「エンジン」をいかに効率よく回すか。そこにこそ、エンジニアの美学が宿る。

もし明日、君のチームのアプリが少し重いと感じたら、まずはこの「強制同期」を疑ってみてくれ。そこにはきっと、劇的な改善の余地が眠っているはずだ。

コメント

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