【実務・中級編】 リフローを引き起こすプロパティと操作 – Webブラウザの仕組み実践ガイド

おい、お前ら!今日もコードと格闘してるか?

フロントエンド開発の深淵に挑む者たちよ、今日はWebブラウザのパフォーマンスチューニングの「肝」とも言える領域に踏み込んでいくぞ。そう、リフロー(Reflow)だ。巷では「レイアウト(Layout)」とも呼ばれるが、呼び方はどうあれ、こいつを理解せずして真のパフォーマンス最適化は語れない。

俺たちフロントエンドエンジニアは、DOMを操作し、スタイルをいじり、ユーザー体験を磨き上げるのが仕事だ。だが、その裏でブラウザがどんな地獄のような再計算を強いられているか、どれだけのコストを支払っているか、意識している奴はどれだけいる?

今日は、俺が長年Webの荒波を乗り越えてきた中で培った、リフローに関する極限の知見を惜しみなく共有する。耳かっぽじってよく聞け。

—

リフロー、その名が示す「再配置」の重み

まずはおさらいだ。ブラウザのレンダリングパイプラインを覚えているか?

1. Parsing: HTMLをDOMツリーに、CSSをCSSOMツリーに変換
2. Style: DOMとCSSOMを結合し、各ノードに適用されるスタイルを決定
3. Layout (Reflow): 各要素の正確な位置とサイズを計算(ここが今日の主役だ!)
4. Paint (Repaint): Layoutで決定した情報に基づいて、要素のピクセルをレンダリングレイヤーに描画
5. Composite: 描画されたレイヤーを重ね合わせ、最終的な画像をスクリーンに表示

この中の「Layout」、これがまさにリフローだ。DOMツリーやCSSOMツリーに変更があった場合、ブラウザは「おいおい、要素の配置が変わっちゃうじゃないか!」と気づき、全要素、あるいは影響範囲の要素のサイズや位置を最初から計算し直すんだ。

想像してみろ。舞台監督がいて、役者たちがステージに立っている。役者の一人が「俺、もっと左に寄りたいな」と言い出したら、その役者だけでなく、周りの役者、背景、照明、全ての位置関係を再確認して調整しなきゃいけない。リフローはまさにそれ。たった一つの要素の幅が変わっただけでも、その影響がドミノ倒しのように他の要素に波及し、最悪の場合、ドキュメント全体のリレイアウトが必要になる。これがどれだけCPUに負荷をかけるか、わかるだろう?

特に複雑なレイアウトや大量の要素があるページでリフローが頻繁に発生すると、目に見えてカクつきや遅延が発生する。ユーザーは一瞬で「このサイト、重いな」と感じ、離脱に繋がる。俺たちの努力が水の泡だ。

リフローが発生すると何が起きる?

リフローが発生すると、必ずその後には「Paint(リペイント)」と「Composite(コンポジット合成)」が続く。つまり、リフローはレンダリングパイプラインの最初期に発生する最もコストの高い操作なんだ。これを避ける、あるいは最小限に抑えることが、フロントエンドパフォーマンス最適化の至上命題の一つとなる。

リフローを引き起こすプロパティと操作:現場の泥臭い一覧

さあ、ここからが本番だ。具体的にどんな操作やCSSプロパティの変更がリフローを引き起こすのか、俺が実際に現場で見てきた事例を交えながらリストアップしていくぞ。どれもこれも、ブラウザが「おい、レイアウトを再計算しろ!」と悲鳴を上げるものばかりだ。

1. DOM構造の変更

これが最も直接的でわかりやすいリフロー要因だ。要素を追加したり、削除したり、中身を変更したりすれば、当然ながらレイアウトが変わる可能性がある。

  • 要素の追加/削除: `appendChild()`, `insertBefore()`, `removeChild()`, `replaceChild()`
  • 要素の非表示/再表示: `display: none` から他の値への変更、あるいはその逆
  • `display: none` は要素をレイアウトツリーから完全に削除するため、再表示時にリフローが発生する。
  • `visibility: hidden` は要素をレイアウトツリーに残すため、リフローは発生しないが、リペイントは発生する。
  • HTMLの直接変更: `element.innerHTML = ‘…’`
  • これは特に危険だ。既存のDOMノードを全て破棄し、HTML文字列から再構築するため、大規模なリフローが発生しやすい。

// サンプルコード: DOM操作によるリフロー
const container = document.getElementById(‘myContainer’);

// 1. 新しい要素を追加する操作(リフロー発生)
const newElement = document.createElement(‘div’);
newElement.textContent = ‘追加された要素’;
newElement.style.border = ‘1px solid red’; // スタイル変更もリフロー要因になる
container.appendChild(newElement); // DOMツリーに新しい要素が追加され、レイアウトが再計算される

// 2. 既存の要素を削除する操作(リフロー発生)
if (container.children.length > 0) {
container.removeChild(container.children[0]); // 要素が削除され、周りの要素の配置が変わりレイアウトが再計算される
}

// 3. innerHTMLによる大規模な変更(大規模なリフロー発生)
// 既存のコンテンツを全て破棄し、新しいHTMLで置き換えるため、ほぼ確実に大規模なリフローが発生する
container.innerHTML = `

新しいコンテンツ1

新しいコンテンツ2

新しいコンテンツ3

`;

2. 要素のサイズや位置の変更

これは言わずもがな。要素の箱の大きさが変わったり、位置が変わったりすれば、ブラウザは「全体の配置を再計算しなきゃ!」となる。

  • サイズ関連: `width`, `height`, `min-width`, `max-width`, `min-height`, `max-height`
  • 余白関連: `margin`, `padding`, `border`
  • 位置関連: `top`, `bottom`, `left`, `right` (`position: static` や `position: relative` の場合。`absolute` や `fixed` は影響範囲が限定されやすいが、それでもリフローが発生しないわけではない)
  • フレックスボックス/グリッド関連: `flex`, `grid`, `align-items`, `justify-content` など、コンテナのレイアウトモデルそのものを変更したり、子要素のプロパティを変更したりすると、全体のレイアウトが再計算される。

// サンプルコード: サイズ・位置変更によるリフロー
const box = document.getElementById(‘myBox’);

// 1. 幅を変更する操作(リフロー発生)
box.style.width = ‘200px’; // 要素の幅が変わり、周囲の要素のレイアウトにも影響を及ぼす可能性がある

// 2. マージンを変更する操作(リフロー発生)
box.style.marginTop = ’20px’; // 要素の外部余白が変わり、周囲の要素の配置が再計算される

// 3. パディングを変更する操作(リフロー発生)
box.style.padding = ’15px’; // 要素の内部余白が変わり、要素自体のコンテンツ領域のサイズも変わりうるため、レイアウトが再計算される

// 4. borderを変更する操作(リフロー発生)
box.style.borderWidth = ‘5px’; // ボーダーの幅が変わり、要素の視覚的なサイズと、周囲のレイアウトに影響を与える

3. フォント関連の変更

フォントサイズや種類が変わると、テキストが占める領域が変化する。当然、行の高さや要素の全体の高さも変わるため、リフローの原因となる。

  • `font-size`, `font-family`, `font-weight`, `line-height`, `letter-spacing`, `word-spacing`
  • `text-align`, `vertical-align`

// サンプルコード: フォント変更によるリフロー
const textElement = document.getElementById(‘myText’);

// 1. フォントサイズを変更する操作(リフロー発生)
textElement.style.fontSize = ’24px’; // テキストが占める領域が変化し、要素の高さや周囲のレイアウトに影響を及ぼす

// 2. フォントファミリーを変更する操作(リフロー発生)
// 同じフォントサイズでも、フォントの種類によって文字の幅や高さが異なるため、リフローが発生する可能性がある
textElement.style.fontFamily = ‘serif’;

// 3. line-heightを変更する操作(リフロー発生)
textElement.style.lineHeight = ‘1.8’; // 行の高さが変わり、要素の高さに影響を及ぼす

4. テキスト内容の変更

テキストノードの中身が変わると、そのテキストが占める幅や高さが変わる可能性がある。これにより、親要素や兄弟要素のレイアウトに影響が出ることがある。

  • `element.textContent = ‘…’`, `element.innerText = ‘…’`
  • フォーム要素への入力(`input`, `textarea`)

// サンプルコード: テキスト内容変更によるリフロー
const dynamicText = document.getElementById(‘dynamicText’);

// 1. テキストコンテンツを変更する操作(リフロー発生)
dynamicText.textContent = ‘新しい、少し長いテキストコンテンツです。’; // テキストの長さが変わると、要素の幅や改行位置が変わり、リフローが発生する

5. ウィンドウのリサイズやスクロールバーの出現/非出現

これはユーザー操作によるものだが、リフローを引き起こす典型的な例だ。

  • ウィンドウのリサイズ: ブラウザウィンドウのサイズが変われば、ビューポートのサイズに依存する要素(`width: 100%` など)は全て再計算が必要になる。
  • スクロールバーの出現/非出現: コンテンツ量によってスクロールバーが表示されたり消えたりすると、その分だけビューポートの利用可能幅が変化するため、ページ全体のリフローが起こる場合がある。

これらは直接コードで制御するものではないが、レスポンシブデザインでウィンドウサイズが変わったときにどれだけパフォーマンスが劣化するかを意識しておくべきだ。

6. 一部のCSSプロパティへのアクセス(強制同期レイアウト!)

ここが「マジかよ!?」と膝を打つポイントだ。お前らが「ブラウザ、今すぐレイアウト計算の結果を教えてくれ!」と命令するようなものだ。

JavaScriptでDOMのプロパティにアクセスすると、ブラウザはしばしば最新のレイアウト情報を返すために、その場でリフローを実行してしまうことがある。これを「強制同期レイアウト (Forced Synchronous Layout)」と呼ぶ。

特に危険なのは、スタイルを変更した直後にこれらのプロパティにアクセスすることだ。これを読み書きの間に挟むと、「レイアウトスラッシング (Layout Thrashing)」という最悪のパフォーマンスボトルネックが発生する。

強制同期レイアウトを引き起こす代表的なプロパティ

  • `element.offsetWidth`, `element.offsetHeight`
  • `element.clientWidth`, `element.clientHeight`
  • `element.scrollWidth`, `element.scrollHeight`
  • `element.scrollTop`, `element.scrollLeft`
  • `element.getBoundingClientRect()`
  • `window.getComputedStyle()`
  • その他、要素の幾何学的な情報(位置やサイズ)を要求するほとんどのプロパティ。

// サンプルコード: 強制同期レイアウトとLayout Thrashingの例

const badBox = document.getElementById(‘badBox’);

// 😈 悪い例: Layout Thrashing
// スタイル変更 (書き込み) -> 強制同期レイアウト (読み込み) -> スタイル変更 (書き込み) -> 強制同期レイアウト (読み込み)…
// 各ループでブラウザは「おい、今の状態を教えてくれ!」と呼び出され、その都度レイアウト計算を強制される。
function animateBadly() {
for (let i = 0; i < 100; i++) { badBox.style.width = `${100 + i}px`; // スタイル変更 (書き込み) console.log(badBox.offsetWidth); // 強制同期レイアウト (読み込み) // ブラウザはoffsetWidthの最新値を返すために、今変更したwidthを元にレイアウトを再計算する // 次のループでまたwidthを変更し、またoffsetWidthを読み込む...これは地獄だ! } } // animateBadly(); // 実行するとカクつきがひどいだろう const goodBox = document.getElementById('goodBox'); // ✨ 良い例: レイアウトスラッシングを避ける // 全ての書き込み処理を先にまとめて行い、その後で全ての読み込み処理を行う。 // または、requestAnimationFrameを使って読み書きの分離を徹底する。 function animateNicely() { const changes = []; for (let i = 0; i < 100; i++) { changes.push(100 + i); } // 書き込み処理をまとめて実行 changes.forEach(width => {
goodBox.style.width = `${width}px`;
// ここではoffsetWidthなどの読み込みはしない
});

// その後で読み込み処理を実行 (もし必要なら)
// この場合はアニメーションなので、読み込みは不要だが、例として挙げる
// console.log(goodBox.offsetWidth); // 読み込みは一度だけ、最後にまとめて
}
// animateNicely(); // こちらはスムーズに実行されるはず

この「強制同期レイアウト」は、まさにブラウザがパフォーマンス最適化のために行っている「レイアウトのバッチ処理」を邪魔する行為だ。ブラウザはCSSの変更を検知しても、すぐにリフローはせず、次のフレーム描画のタイミングでまとめて計算しようと賢く振る舞う。だが、お前が「今すぐ!」と命令したら、その賢い判断を台無しにするんだ。

リフローを避けるための実践的なTipsとベストプラクティス

さて、ここまでリフローの恐ろしさを語ってきたが、ただ怯えていても仕方ない。俺たちが実践できる具体的な対策を伝授しよう。

1. `transform` と `opacity` を活用せよ!

これが最も重要なリフロー回避術だ。`transform`(`translate`, `scale`, `rotate`など)と `opacity` の変更は、リフローやリペイントを引き起こさず、コンポジット合成の段階で処理される。つまり、最もパフォーマンスコストの低いアニメーションを実現できるんだ。

要素の位置を変えたいなら `left/top` ではなく `transform: translate()` を使え。フェードイン/アウトなら `display` や `visibility` ではなく `opacity` を使え。

/ ❌ 悪い例: リフローを引き起こす可能性 /
.bad-animation {
transition: width 0.3s ease-out, height 0.3s ease-out, margin-left 0.3s ease-out;
}

/ ✅ 良い例: リフローを起こさず、コンポジット段階で処理される /
.good-animation {
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
}

2. `will-change` プロパティでブラウザに「心構え」をさせろ

CSSの `will-change` プロパティは、要素に対して将来的にどのような変更が加えられるかをブラウザに事前に伝えることができる。これにより、ブラウザはその要素に対して、あらかじめ最適化(例えば、GPUレイヤーへの昇格など)を施すことができるんだ。

ただし、濫用は厳禁だ。本当にアニメーションする要素、パフォーマンスがクリティカルな要素にのみ使え。

/ will-change を使ってブラウザに最適化を促す /
.animating-element {
will-change: transform, opacity; / transform と opacity が変更されることをブラウザに伝える /
/ 初期のスタイル /
transform: translateX(0);
opacity: 1;
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
}

.animating-element.is-moving {
transform: translateX(100px);
opacity: 0.5;
}

3. DOM操作はバッチ処理、そしてオフスクリーンで

複数のDOM操作が必要な場合、一つずつ実行するのではなく、まとめて(バッチで)処理するべきだ。

  • DocumentFragment の活用:

DOMツリーに直接追加する前に、`DocumentFragment` に要素を組み立て、最後に一度だけDOMツリーに追加する。これにより、リフローは一度しか発生しない。

const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) { const item = document.createElement('div'); item.textContent = `アイテム ${i}`; fragment.appendChild(item); } container.appendChild(fragment); // ここで一度だけリフロー

  • 要素を一時的に非表示にする:

大規模なDOM操作を行う前に、対象の要素やその親要素を `display: none` で非表示にし、操作が完了してから再度表示する。これにより、非表示中のリフローは発生しない。ただし、`display: none` 自体がリフローを引き起こすので、全体の操作で2回のリフローは発生するが、多数のリフローを避ける効果はある。

container.style.display = ‘none’; // リフロー1回目
// 大量のDOM操作やスタイル変更
container.innerHTML = ‘

新しいコンテンツ

‘.repeat(100);
container.style.width = ‘500px’;
// …
container.style.display = ”; // リフロー2回目

4. クラスの切り替えを徹底せよ

JavaScriptで直接 `element.style.property = ‘value’` のようにスタイルを変更するのではなく、CSSクラスを定義し、JavaScriptではそのクラスの追加・削除を行うべきだ。これにより、CSSは一箇所に集約され、管理しやすくなるだけでなく、ブラウザはクラス変更によるスタイル適用を効率的に処理できる可能性がある。

/ CSS /
.box {
width: 100px;
height: 100px;
background-color: blue;
transition: all 0.3s ease; / 良いアニメーションのためにもトランジションはCSSで /
}

.box.expanded {
width: 200px;
height: 200px;
background-color: red;
transform: translateX(50px); / transformならリフローしない /
}

// JavaScript
const myBox = document.getElementById(‘myBox’);

// ❌ 悪い例: JavaScriptで直接スタイルを操作
myBox.style.width = ‘200px’;
myBox.style.height = ‘200px’;
myBox.style.backgroundColor = ‘red’;

// ✅ 良い例: クラスの切り替え
myBox.classList.add(‘expanded’); // クラス追加でスタイルが適用される
// もしtransformも変更するなら、それはリフロー/リペイントを起こさない

5. レイアウトスラッシングを撲滅せよ!

先ほども触れたが、読み込みと書き込みを交互に行うレイアウトスラッシングは絶対に避けるんだ。

読み込み (Read): `offsetWidth`, `offsetHeight`, `getComputedStyle()` など
書き込み (Write): `width`, `height`, `left`, `top`, `margin`, `padding` などのスタイル変更

// 読み込みと書き込みを分離するパターン
function optimizeLayoutThrashing() {
const elements = Array.from(document.querySelectorAll(‘.item’));
const newWidths = [];

// 最初に全ての読み込み処理を行う
elements.forEach(el => {
// 例: 現在の幅に基づいて新しい幅を計算
newWidths.push(el.offsetWidth 1.2);
});

// その後、全ての書き込み処理を行う
elements.forEach((el, index) => {
el.style.width = `${newWidths[index]}px`;
});
}

// requestAnimationFrame を活用したより高度な分離
function animateWithRaf() {
const element = document.getElementById(‘animatedElement’);
let currentPosition = 0;

function update() {
// 1. 読み込み (Read) フェーズ
// 例えば、現在のスクロール位置などを読み込む

// 2. 書き込み (Write) フェーズ
// DOMを変更する
element.style.transform = `translateX(${currentPosition}px)`;
currentPosition += 5; // 5pxずつ移動

if (currentPosition < 200) { requestAnimationFrame(update); // 次のフレームで再度実行 } } requestAnimationFrame(update); // アニメーション開始 } `requestAnimationFrame` を使うと、ブラウザの描画サイクルに合わせて処理を実行できるため、ブラウザはレイアウト計算を効率的にバッチ処理できる。アニメーションや頻繁なDOM操作には必須のテクニックだ。

最後に:ブラウザの仕組みを知ることは、開発者の「第六感」を磨くこと

今日話したリフローの話は、Webブラウザがどうやって画面を描画しているか、その「裏側」を理解することの重要性を物語っている。単にコードを書くだけでなく、そのコードがブラウザにどんな影響を与えるかまで想像できるようになれば、お前らのフロントエンド開発力は格段に上がる。

パフォーマンス最適化は、単なる技術的な課題ではない。それはユーザーへの敬意であり、より良いWeb体験を創造するための俺たちの責任だ。

今日学んだことを胸に刻み、明日からのコードに活かせ。そして、もし後輩が「なんかこのアニメーション、カクつくんですよね…」と悩んでいたら、今日俺が話したことを、お前自身の言葉で伝えてやってくれ。それが、次の世代のフロントエンドエンジニアを育てる、お前らの役目でもあるんだからな。

じゃあな、また現場で会おう。健闘を祈る。

コメント

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