【実務・中級編】 強制同期レイアウトの検知と回避 – Webブラウザの仕組み実践ガイド

やあ、現場の最前線でコードと格闘している同志諸君。今日もブラウザという名のブラックボックスと向き合っていることだろう。

最近のフロントエンド開発は、ReactやVue、Next.jsといった強力なフレームワークのおかげで、ある程度のパフォーマンスは「勝手に」担保されるようになった。だが、そんな甘い環境に慣れきった頃にやってくるのが、「なぜか特定のアニメーションだけガクつく」「スクロールが重い」という、フレームワークの層を突き抜けたブラウザ・レンダリング層の悲鳴だ。

今日は、その元凶の筆頭候補であり、我々シニアエンジニアがレビューで最も厳しくチェックするポイントの一つ、「強制同期レイアウト(Forced Synchronous Layout, FSL)」について語ろう。ここを理解すれば、君の書くコードの質は一段上のステージへ上がるはずだ。

—

1. ブラウザは「怠け者」であり、それが正義である

まず、ブラウザのレンダリングパイプラインをおさらいしよう。通常、ブラウザは以下のステップで画面を描画する。

1. JavaScript: データの取得やDOM操作。
2. Style: CSSの適用(どの要素にどのスタイルが当たるか)。
3. Layout (Reflow): どの要素がどこに、どのサイズで配置されるかの計算。
4. Paint: ピクセルを描く(背景色や文字など)。
5. Composite: レイヤーを重ねて最終的な画面を作る。

ここで重要なのは、ブラウザは極めて賢く、そして「怠け者」だということだ。JavaScriptでDOMのスタイルを変更しても、ブラウザはその瞬間にレイアウト計算を始めたりはしない。「あとでまとめてやるから、今はフラグだけ立てておこう」と、変更をキューに溜め込み、次のフレーム(16.7ms以内)の描画タイミングで一気に処理する。これをバッチ処理と呼ぶ。

ところが、この怠け者の平穏を乱すコードがある。それが「強制同期レイアウト(FSL)」だ。

—

2. 強制同期レイアウト(FSL)の正体

FSLとは、「まだ計算が終わっていない(レイアウトが確定していない)状態で、JavaScriptからレイアウト情報を要求する」ことによって発生する。

想像してみてほしい。君がレストランのシェフで、注文をまとめて調理しようとしている。そこへウェイターがやってきて、「テーブル5の料理の正確な重さは今何グラムだ?」と聞いてくる。君は調理の手を止め、一旦計量し、答えを出さなければならない。そしてまた調理に戻る。これを繰り返されたら、料理はいつまで経っても完成しないだろう。

ブラウザの内部では、以下のようなことが起きている。

// 1. スタイルを変更する(ブラウザは「後で計算しよう」とフラグを立てる)
element.style.width = ‘100px’;

// 2. 直後に幅を取得しようとする(ブラウザはパニックになる)
const width = element.offsetWidth; // ← ここで強制的にレイアウト計算が走る!

`offsetWidth` や `getComputedStyle()` といったプロパティは、「最新の正確な値」を返さなければならないという仕様がある。そのため、ブラウザは「まだ後でやるつもりだったレイアウト計算」を、その瞬間に、メインスレッドを止めて実行せざるを得なくなる。これがFSLだ。

—

3. 最悪のシナリオ:レイアウト・スラッシング

FSLが単発ならまだマシだ。最悪なのは、これをループの中で行う「レイアウト・スラッシング(Layout Thrashing)」だ。

// 現場でよく見る「死のループ」
for (let i = 0; i < paragraphs.length; i++) { // 書き込み(Write): スタイルを変更 paragraphs[i].style.width = box.offsetWidth + 'px'; // 読み取り(Read)も同時に発生 } このコードでは、ループのたびに「前の要素の変更を反映させるためのレイアウト再計算」が走り、ブラウザは一歩進んで止まり、計算し、また一歩進んで止まる……という地獄のような挙動を繰り返す。要素が数百個あれば、数ミリ秒で終わるはずの処理が数百ミリ秒、あるいは秒単位でメインスレッドを占有する。 ---

4. 現場で使える回避策とサンプルコード

では、どうすればいいのか。答えはシンプルだ。「読み取り(Read)」を先にまとめて行い、「書き込み(Write)」を後でまとめて行う。 これだけだ。

悪い例(FSLを引き起こす)

/

  • 各カードの幅を、親要素の幅に合わせて調整するダメな例

/
function badResize(cards) {
const container = document.querySelector(‘.container’);

for (let i = 0; i < cards.length; i++) { // ループの中で offsetWidth を呼ぶため、 // 前のループのスタイル変更を反映させるために毎度レイアウト計算が走る。 const targetWidth = container.offsetWidth; cards[i].style.width = targetWidth + 'px'; } }

良い例(最適化済み)

/

  • 読み取りと書き込みを完全に分離したプロのコード

/
function goodResize(cards) {
const container = document.querySelector(‘.container’);

// 1. 読み取り(Read)フェーズ:
// まとめて値を取得する。この時点ではレイアウト計算は1回(あるいは0回)で済む。
const targetWidth = container.offsetWidth;

// 2. 書き込み(Write)フェーズ:
// 取得した値を元に、DOMを変更する。
// ブラウザはこの変更をキューに入れ、次の描画タイミングで「一度だけ」計算する。
for (let i = 0; i < cards.length; i++) { cards[i].style.width = targetWidth + 'px'; } } さらに高度なテクニックとして、`requestAnimationFrame` (rAF) を使う方法がある。これはブラウザの描画直前のタイミングに処理を予約するものだ。 /

  • requestAnimationFrame を活用した超スムーズな更新

/
function optimizedUpdate(cards) {
const container = document.querySelector(‘.container’);

// 読み取りは今すぐやる
const targetWidth = container.offsetWidth;

// 書き込みは次の描画フレームの直前に予約する
requestAnimationFrame(() => {
cards.forEach(card => {
card.style.width = targetWidth + ‘px’;
});
console.log(‘描画直前に一括更新しました’);
});
}

—

5. 犯人を見つける方法(検知の極意)

理屈はわかった。では、巨大なプロジェクトの中でどこがボトルネックになっているかをどう見極めるか。

1. Chrome DevTools の Performance タブを開く。
2. 記録(Record)を開始し、重い操作を再現する。
3. 記録を止めた後、「Main」セクションの赤い三角マークを探す。
4. 「Forced Reflow」 または 「Recalculate Style」 という警告が出ていれば、そこが犯行現場だ。
5. Call Stack(コールスタック)を辿れば、どのJSファイルの何行目が原因か一発でわかる。

また、開発中に役立つのが[FastDOM](https://github.com/wilsonpage/fastdom)のようなライブラリだ。これは内部で `read` と `write` のタスクをバッファリングし、FSLを物理的に起こさせないように制御してくれる。大規模開発ではこういったツールの導入を検討するのもアーキテクトの仕事だ。

—

最後に:アーキテクトの視点

「たかが数ミリ秒のレイアウト計算じゃないか」と思うかもしれない。だが、その積み重ねが、ユーザーが感じる「手触り感」の差になる。1フレーム(16.7ms)を死守できるかどうか。それが一流のフロントエンドエンジニアと、ただコードを書く人の境界線だ。

次に `offsetTop` や `getComputedStyle` を書こうとしたとき、この話を思い出してほしい。「今、俺はシェフの手を止めていないか?」と。

現場からは以上だ。最高のパフォーマンスを叩き出してくれ!

コメント

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