注意
この記事はAIの補助を受けて編集しています。
はじめに
この記事では以下をだけを解説します。
- ブラウザがHTML/CSSから画面を描画するまでのRendering Pipeline
- DOMツリー・CSSOMツリー・レンダーツリーの役割と違い
- レイアウト(リフロー)・ペイント・コンポジットの各段階
- なぜ
transformアニメーションは速く、leftは遅いのか - レイアウトスラッシングとは何か、どう避けるか
- CSSOMがレンダリングをブロックする仕組み
対象読者
- CSSの内部動作を「なぜそうなるのか」まで理解したい人
- ブラウザレンダリングの基本を体系的に学びたい人
- React/Vueを使う前に、ブラウザの描画原理を押さえたい人
前提知識
| 項目 | 内容 |
|---|---|
| Version | CSSの基本(セレクタ、プロパティ) |
| Environment | モダンブラウザ(Chrome 120+ / Firefox 120+) |
| 条件 | HTML基礎、DevToolsが開けること |
問題:「CSSが思い通りに動かない」の正体
よくある例:
<div class="parent">
<div class="child">長いテキスト...</div>
</div>
.parent { width: 300px; background: #f0f0f0; }
.child {
width: 100%;
background: red;
padding: 20px; /* ❌ はみ出る! */
}
なぜwidth:100%ではみ出すのか?
さらに不可解な違い:
/* ガタガタするアニメーション */
.box {
transition: left 0.3s;
position: relative;
left: 0;
}
.box:hover { left: 100px; }
/* なめらかなアニメーション */
.box2 {
transition: transform 0.3s;
}
.box2:hover { transform: translateX(100px); }
同じ100px移動なのに、なぜ結果が違うのか?
原因は、ブラウザ内部のレンダリングパイプラインにあります。
原因:ブラウザは「装飾」ではなく「パイプライン」で動く
多くの開発者がCSSを「デザインを適用する簡単な言語」と誤解しています。
しかし実際には、ブラウザは以下のような複雑なパイプラインを持っています。
各段階には大きなコスト差があります。
- レイアウト(リフロー) : 非常に高コスト。各要素の位置・サイズを計算。
- ペイント : 中コスト。ピクセルを描画。
- コンポジット : 低コスト。主にレイヤー合成処理を行う。多くの場合GPUアクセラレーションが利用される。
leftアニメーションは レイアウト → ペイント → コンポジット を毎フレーム発生させる。
transformアニメーションは コンポジットのみ で済む(多くの場合GPUが担当する)。これが速度差の理由です。
解決方法:レンダリングパイプラインをステップごとに理解する
Step 1: DOMツリーとは?
- HTMLをパースして作られる木構造。
- コンテンツの階層関係のみを持ち、スタイル情報は持たない。
- JavaScriptから操作できるのはこのDOM。
Step 2: CSSOMツリーとは?
- CSSをパースし、カスケード・詳細度を計算した結果の木。
- 各要素に最終的に適用されるスタイルを持つ。
-
display:noneの要素もCSSOMには存在する(後述のRender Treeとは異なる)。
【重要】CSSOMはレンダリングをブロックする
ブラウザはCSSOMが完成するまでRender Treeを構築できません。なぜなら、スタイルが後から変わるとレイアウトやペイントを再計算する必要があるからです。そのため、<link rel="stylesheet"> はRender Blocking Resourceとして扱われます。
<!-- このCSSのダウンロードとパースが終わるまで、ブラウザは描画を開始できない -->
<link rel="stylesheet" href="styles.css">
Step 3: レンダーツリー(Render Tree)とは?
- DOM + CSSOM を結合して作られる。
- 実際に画面に表示される要素のみを含む。
- 以下のものはレンダーツリーに含まれない:
-
display:noneの要素 -
<head>内の要素 -
scriptタグ
-
- ただし
::before/::afterなどの擬似要素は追加される(仮想ノードとして)。
Step 4: レイアウト(リフロー)
- レンダーツリーの各ノードの正確な位置(x, y)とサイズ(width, height) を計算。
- ビューポートのサイズに依存する。
- レイアウトを引き起こすプロパティ例:
-
width,height,margin,padding -
top,left,right,bottom -
font-size,font-family -
display,flex,grid,position
-
/* この変更はレイアウトを引き起こす */
.element {
width: 200px; /* サイズ変更 → 周りも影響 */
}
Step 5: ペイント
- レイアウトで決まった領域にピクセルを描画。
- テキスト、画像、背景、ボーダー、影などを塗りつぶす。
- ペイントを引き起こすプロパティ例:
-
color,background-color -
border-color,outline -
box-shadow,text-shadow
-
/* この変更はペイントのみ(レイアウトは発生しない) */
.element {
color: blue; /* 色変更 → 再描画 */
}
Step 6: コンポジット
- 複数のレイヤーを合成して最終的な画面を生成。
- 主にレイヤー合成処理を行い、多くの場合GPUアクセラレーションが利用される。
- 最も軽い処理。
- コンポジットのみを引き起こすプロパティ例:
transformopacity
/* コンポジットのみ!超高速 */
.element {
transform: translateX(100px);
opacity: 0.5;
}
Step 7: なぜtransformは速いのか?
-
transformとopacityは、多くの場合コンポジットレイヤーとして処理される。 - ブラウザは状況に応じてGPUアクセラレーションを利用する。
- レイアウトもペイントも再計算しない。
実践例(React + TypeScript)
例1: レイアウトスラッシング(Layout Thrashing)とは?
レイアウトスラッシング = レイアウトの読み取りと書き込みを交互に行い、ブラウザに強制的に同期レイアウトを繰り返させる悪いパターン。
// Bad.tsx – レイアウトスラッシング発生
import { useEffect, useRef } from 'react';
export const BadList = () => {
const itemsRef = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
for (let i = 0; i < itemsRef.current.length; i++) {
const el = itemsRef.current[i];
if (!el) continue;
// ❌ 読み → 書き → 読み → 書き (強制レイアウト)
const h = el.offsetHeight; // 読み(レイアウト強制)
el.style.height = `${h + 10}px`; // 書き(レイアウト無効化)
const w = el.offsetWidth; // また読み(再度レイアウト強制)
el.style.width = `${w + 5}px`;
}
}, []);
return (
<div>
{Array(100).fill(0).map((_, i) => (
<div key={i} ref={el => itemsRef.current[i] = el}>
Item {i}
</div>
))}
</div>
);
};
例2: レイアウトスラッシングを避ける
// Good.tsx – バッチ読み → バッチ書き
import { useEffect, useRef } from 'react';
export const GoodList = () => {
const itemsRef = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
// フェーズ1: 全ての測定値を読み取る(バッチリード)
const measurements = itemsRef.current.map(el => ({
height: el?.offsetHeight || 0,
width: el?.offsetWidth || 0
}));
// フェーズ2: 全ての書き込みを次のフレームで実行(バッチライト)
requestAnimationFrame(() => {
itemsRef.current.forEach((el, i) => {
if (!el) return;
el.style.height = `${measurements[i].height + 10}px`;
el.style.width = `${measurements[i].width + 5}px`;
});
});
}, []);
return (
<div>
{Array(100).fill(0).map((_, i) => (
<div key={i} ref={el => itemsRef.current[i] = el}>
Item {i}
</div>
))}
</div>
);
};
ポイント:
- 読み取りと書き込みを時間的に分離する。
-
requestAnimationFrameを使って書き込みを次のフレームに遅らせる。
注意点(Pitfall)
1. どのプロパティがレイアウトを引き起こすか?
実務ルール: アニメーションが必要なら transform + opacity だけを使う。
2. 強制同期レイアウト(Forced Synchronous Layout)を起こすコード
// ❌ やってはいけない
element.style.width = '100px';
const height = element.offsetHeight; // ここで強制レイアウト発生!
以下のプロパティを読むとブラウザは最新のレイアウトを計算するために強制リフローを起こす:
-
offsetTop,offsetLeft,offsetWidth,offsetHeight -
scrollTop,scrollLeft,scrollWidth,scrollHeight -
clientTop,clientLeft,clientWidth,clientHeight getComputedStyle()
3. レイヤー爆発(Layer Explosion)は避ける
/* ❌ 全ての要素に will-change を指定しない */
* { will-change: transform; }
will-change は本当にアニメーションさせる要素だけに、必要な時だけ使う。
まとめ
Key Takeaways
- CSSは「装飾」ではなく「レンダリングパイプラインへの指示書」。
- パイプラインは DOM → CSSOM → Render Tree → Layout → Paint → Composite。
-
レイアウト(リフロー)は最も重い → アニメーションには
transform/opacityを使う。 - CSSOMはレンダリングをブロックする。これがCSSを遅延ロードできない理由の一つ。
- レイアウトスラッシングを避ける = 読み取りと書き込みを分離する。
- Chrome DevToolsのPerformanceタブ でLayout/Paint/Compositeの発生を確認できる。
実務ですぐに使えるチェックリスト
-
アニメーションに
left/top/widthを使っていないか →transformに置き換える -
offsetHeight/getComputedStyleの直後にスタイル変更をしていないか -
will-changeをむやみに使っていないか(必要な要素だけ) - DevTools → Rendering → 「Paint flashing」で再描画範囲を可視化しているか
- CSSファイルの読み込みがレンダリングをブロックしすぎていないか(Critical CSSの検討)
参考文献
👉 次回予告: 「【Frontend CSS – パート2】カスケードと詳細度の真実 ─ CSS優先順位はどう計算されるのか?」
