ブラウザレンダリングパイプライン
🎯 核心問題
なぜあるウェブページは滑らかに動き、あるページはパワポのようにカクつくのか? ブラウザはどうやってHTML、CSS、JavaScriptのコードを、あなたが見ているウェブページに変換しているのか?本章では、ブラウザの「工場」内部に深く入り込み、そのワークフローを理解することで、よりパフォーマンスの高いウェブページを書けるようになる。
この記事で学べること:
| 章节 | 内容 | 学完能干嘛 |
|---|---|---|
| 第1章 | レンダリングパイプラインを理解する理由 | パフォーマンス最適化の必要性を理解する |
| 第2章 | レンダリングパイプラインの5つの段階 | ブラウザレンダリングの基本フローを把握する |
| 第3章 | DOMツリーとCSSOMツリーの構築 | HTMLとCSSの解析方法を理解する |
| 第4章 | レンダーツリーの構築 | どの要素がレンダリングされるかを知る |
| 第5章 | レイアウトとリフロー | 高コストなレイアウト計算を回避する |
| 第6章 | ペイントとリペイント | 不要なペイント操作を減らす |
| 第7章 | 合成とGPUアクセラレーション | GPUを活用してアニメーションパフォーマンスを向上させる |
| 第8章 | イベントループ | JavaScriptの実行メカニズムを理解する |
| 第9章 | パフォーマンス最適化の実践 | よく使われるパフォーマンス最適化テクニックを習得する |
各章は「原理の理解」から始まり、最適化コードを手書きできる必要はない。パフォーマンス問題に遭遇したときに、いつでも参照しに来ればよい。
1. なぜ「レンダリングパイプライン」を理解する必要があるのか?
1.1 「動く」から「速く動く」へ:フロントエンド開発の進化の道
フロントエンドを学び始めた頃は、コードが「動くかどうか」だけを気にする——ページが表示され、ボタンがクリックできれば成功だ。しかしプロジェクトが大きくなり、ユーザーが増えると、すぐに残酷な現実に気づく:同じ機能でも、ある人が書いたページは絹のように滑らかで、別の人が書いたページはユーザーがマウスを投げつけたくなるほどカクつく。
これは車の運転を学ぶようなものだ。初心者は「車が動くかどうか」だけを気にするが、ベテランドライバーは「いつギアを変えるか、いつブレーキを踏むか、どう運転すれば燃費が良いか」を気にする。ブラウザはあなたが運転する「車」であり、その「動作特性」を理解してこそ、速く安定して走らせることができる。
🐢 初心者の考え方(機能だけを重視)
- ページが表示されればそれでいい
- カクつきはブラウザの問題
- パフォーマンス最適化は後回しでいい
🚀 上級者の考え方(体験を重視)
- 滑らかさはユーザー体験の核心
- ブラウザのワークフローを理解する
- コードを書く時点でパフォーマンスを考慮する
レンダリングパイプラインを理解することは、「動く」から「速く動く」への重要な一歩だ。
1.2 実際の失敗談:「最適化」したのになぜか逆に遅くなった?
張さんのパフォーマンス失敗談
張さんはあるEC企業のフロントエンドエンジニアで、商品詳細ページの最適化を担当していた。このページは商品情報を表示する際にひどくカクつき、ユーザーからのクレームが絶えなかった。
張さんは考えた:「ページがカクつくのはDOMが多すぎるからだ。まずdisplay:noneで非表示にして、修正が終わってから表示すれば、ブラウザは何度もレンダリングしなくて済むはずだ」
そこで彼はこんなコードを書いた:
// あなたが考える「最適化」
const container = document.getElementById('list')
container.style.display = 'none' // 先に非表示にすれば、レンダリングは発生しないはず?
for (let i = 0; i < 1000; i++) {
const item = document.createElement('div')
item.style.width = Math.random() * 100 + 'px' // ランダムな幅
container.appendChild(item)
}
container.style.display = 'block' // 最後に表示、一括レンダリングテストの結果、ページはさらにカクついた!張さんは困惑した:明らかに「最適化」したのに、なぜ逆に遅くなったのか?
後でフロントエンドリーダーがコードを見て、問題点を指摘した:要素は非表示でも、style.widthを変更するたびにブラウザのスタイル計算とレイアウトマークが発生し、ブラウザはバックグラウンドで大量の無駄な処理をしていたのだ。
正しい方法はDocumentFragmentを使ってメモリ上でバッチ操作し、最後に一度だけDOMに挿入して、レンダリングを1回だけトリガーすることだ。
💡 核心的教訓
ブラウザのワークフローを理解していなければ、「賢いつもり」で「最適化コード」を書いても、逆にパフォーマンスを悪化させてしまう。レンダリングパイプラインを理解してこそ、どの操作が高コストで、どの操作が低コストなのかがわかり、間違った場所で力を入れることを避けられる。
2. 核心概念:「レンダリングパイプライン」とは?
🤔 「レンダリング」とは?
レンダリング(Rendering)とは、簡単に言えばブラウザがコードを「描いて」あなたが見るウェブページに変換するプロセスだ。
これは印刷所で本を印刷する様子に例えられる:
- HTML = 原稿の内容(文字、画像、章立て)
- CSS = 組版指示(フォントサイズ、色、余白)
- JavaScript = 動的修正(著者がその場で原稿を修正し、組版を調整する)
ブラウザはこれらの「材料」を受け取ると、いくつもの「工程」を経て、最終的にあなたが見るウェブページを「印刷」する。この一連の工程が、レンダリングパイプライン(Rendering Pipeline)だ。
より理解しやすくするために、パン屋を例にブラウザのレンダリングフローを説明しよう。
2.1 パン屋の例えでレンダリングパイプラインを理解する
あなたがパン屋を経営していて、毎日様々なパンを顧客のために作ると想像してほしい。このプロセスに含まれる各段階は、ブラウザのレンダリングフローと驚くほど似ている:
| 段階 | 🥖 パン屋の例え | ブラウザの実際の動作 | 具体例 |
|---|---|---|---|
| 1. 材料の準備 | 材料リストを整理する(小麦粉、卵、クリーム…) | DOMツリーの構築:HTMLをツリー構造に解析する | <div><p>Hello</p></div>と書くと、ブラウザはdiv→p→"Hello"のツリーに解析する |
| 2. レシピの準備 | レシピカードを整理する(各パンの材料比率) | CSSOMツリーの構築:CSSをルールツリーに解析する | .title { color: red }と書くと、ブラウザは「.titleの文字は赤色」と記録する |
| 3. 計画を立てる | 材料とレシピに基づいて、今日作るパンを決める | レンダーツリーの構築:DOMとCSSOMを統合し、可視要素だけを残す | <script>タグは表示されないので、レンダーツリーには含まれない |
| 4. 配置を決める | パンをショーケースに並べ、各パンの位置を決める | レイアウト(Layout):各要素のサイズと位置を計算する | 「このdivは幅200px、高さ100px、画面の(50, 50)の位置にある」と計算する |
| 5. 色付けと装飾 | パンに卵液を塗り、ゴマを振り、クリームを絞る | ペイント(Paint):要素の色、枠線、影などを「描く」 | 「赤い文字」を実際に画面に描く |
| 6. 組み立て完了 | すべてのパンを重ねて、美しく盛り付ける | 合成(Composite):複数のレイヤーを統合して最終画面にする | GPUが背景レイヤー、文字レイヤー、画像レイヤーを統合して1枚の完全な画面にする |
📊 この表から何が読み取れるか?
表を一行ずつ読み解き、レンダリングパイプラインの各段階を理解しよう:
段階1-2(準備段階):ブラウザはまずコードを「理解」する。HTMLとCSSは別々に解析される。なぜなら役割が異なるからだ——HTMLは「何があるか」を決め、CSSは「どのように見えるか」を決める。
段階3(統合段階):なぜ「統合」が必要か?すべてのHTML要素が表示されるわけではないからだ(例:<head>、<script>)。ブラウザは「可視要素」と「そのスタイル」を組み合わせて、「施工図」を作成する。
段階4-5(描画段階):レイアウトは「位置を計算」し、ペイントは「色を塗る」。レイアウトの変更(例:幅の変更)はペイントを引き起こすが、ペイントの変更(例:色の変更)はレイアウトを引き起こさない。
段階6(合成段階):現代ブラウザの「魔法」。従来の方式は「一度に全部描く」(CPUで逐次処理、遅い)。現代の方式は「レイヤー別描画+GPU合成」(速い)。これがtransformアニメーションがwidthアニメーションより滑らかな理由だ。
2.2 レンダリングパイプラインの5つの段階
3. 第一段階:DOMツリーとCSSOMツリーの構築
3.1 なぜ「ツリー化」するのか?
🤔 DOMとは?
DOM(Document Object Model、ドキュメントオブジェクトモデル)は、ブラウザがHTML文書を変換して作るツリー構造で、JavaScriptがページ要素を操作しやすくするためのものだ。
これは家系図に例えられる:
- 最上位は「祖先」(
<html>) - その下は「子孫」(
<body>、<head>) - さらに下は「孫」(
<div>、<p>、<span>)
なぜツリーに変換するのか? ツリー構造は「検索」と「変更」が非常に効率的だからだ。例えば「classがtitleの要素をすべて見つけたい」場合、ブラウザはツリー上を素早く検索でき、バラバラのテキストの中から少しずつ探す必要がない。
ブラウザはHTMLを受け取ると、すぐに表示するのではなく、まずそれを「理解」する。このプロセスは3つのステップに分かれる:
ステップ1:字句解析——コードを「単語」に分解する
<div class="container">
<p>Hello World</p>
</div>ブラウザはこのコードを見ると、まず「単語分割」を行う:
<div>→ 「開始タグ div」class="container"→ 「属性 class、値 container」<p>→ 「開始タグ p」Hello World→ 「テキスト内容」</p>→ 「終了タグ p」</div>→ 「終了タグ div」
ステップ2:構文解析——「単語」を「ノード」に組み立てる
ブラウザはHTMLのルールに従って、これらの「単語」を「ノード」に組み立てる:
- 要素ノード:
<div>、<p> - 属性ノード:
class="container" - テキストノード:
"Hello World"
ステップ3:ツリー構築——「親子関係」を確立する
最後に、ブラウザはタグのネスト関係に基づいてツリー構造を構築する:
Document(ドキュメントルートノード)
└── html
└── body
└── div.class = "container"
└── p
└── "Hello World"3.2 CSSOMツリー:スタイルの「ルールブック」
🤔 CSSOMとは?
CSSOM(CSS Object Model、CSSオブジェクトモデル)は、ブラウザがCSSルールを変換して作るツリー構造で、各要素の最終的なスタイルを計算するために使われる。
これは服装コーディネートガイドに例えられる:
- 上位のルール(bodyのフォント)は下位(すべての子要素)に影響する
- 衝突がある場合(同じ要素に複数のルールが異なる色を指定)、「優先度」に従ってどれを使うか決める
- 最終的に各要素がどんな「服」を着るべきかを計算する
CSSOMの構築プロセスはDOMと似ているが、1つ重要な違いがある:CSSは「継承」と「カスケード」があることだ。
CSSOMの構築プロセスを見る
元のCSS:
body {
font-size: 16px;
color: #333;
}
.container {
width: 100%;
color: red; /* bodyのcolorを上書きする */
}
.container p {
font-weight: bold;
}構築後のCSSOMツリー:
StyleSheet
├── body
│ ├── font-size: 16px
│ └── color: #333
└── .container
├── width: 100%
├── color: red (優先度が高いため、bodyのcolorを上書き)
└── p
└── font-weight: bold3.3 失敗談:なぜCSSが「効かない」のか?
落とし穴1:CSSセレクタの詳細度の衝突
よくある間違いを見る
/* あなたが書いたCSS */
#header { color: red; } /* idセレクタ、詳細度100 */
.title { color: blue; } /* classセレクタ、詳細度10 */
/* HTML */
<div id="header" class="title">この文字は何色?</div>あなたは青だと思うが、結果は赤だ。なぜならidセレクタの詳細度(100)がclassセレクタの詳細度(10)より高いからだ。
落とし穴2:HTMLタグが閉じられていない、ブラウザが「自動修正」する
ブラウザがエラーのあるHTMLをどう修正するかを見る
<!-- あなたが書いたHTML -->
<div>
<p>これは文章です
</div>
<!-- ブラウザが修正した後 -->
<div>
<p>これは文章です</p> <!-- ブラウザが自動的にタグを閉じてくれる -->
</div>ブラウザはとても「寛容」で、あなたのミスを自動修正してくれる。しかしこの寛容さには代償がある——ブラウザはあなたの意図を推測するために追加の計算が必要で、パフォーマンスに影響する。
4. 第二段階:レンダーツリーの構築
4.1 なぜ「レンダーツリー」が必要なのか?
あなたはこう疑問に思うかもしれない:「すでにDOMツリーとCSSOMツリーがあるのに、なぜさらにレンダーツリーを構築する必要があるのか?DOMをそのまま使えばいいのでは?」
答えは:DOMツリーには「無駄な」情報が多すぎるからだ。
例えば次のHTMLを見てほしい:
<html>
<head>
<title>ページタイトル</title>
<style>/* CSSコード */</style>
<script>/* JavaScriptコード */</script>
</head>
<body>
<div class="container">
<p>可視コンテンツ</p>
</div>
<div style="display: none">
<p>非表示コンテンツ(display:none)</p>
</div>
</body>
</html>DOMツリーはすべての要素を含む:
<head>、<title>、<style>、<script>(これらは表示されない)display: noneのdiv(これも表示されない)
しかしレンダーツリーは「画面に描くべき」要素だけを含む:
<head>とその子要素を除去display: noneのdivを除去
4.2 レンダーツリーの構築ルール
ブラウザはレンダーツリーを構築する際、一連のルールに従う:
| シーン | 処理方法 | 例 | パフォーマンスへの影響 |
|---|---|---|---|
display: none | レンダーツリーから完全に除外 | 要素とその子要素がすべて不可視 | ✅ レンダリング作業を削減 |
visibility: hidden | レンダーツリーに含まれるが、描画されない | スペースを占有するが、完全に透明 | ⚠️ レイアウト計算は必要 |
opacity: 0 | レンダーツリーに含まれるが、透明 | インタラクション可能(クリックできる)が、見えない | ⚠️ レイアウト計算は必要 |
| ビューポート外 | レンダーツリーに含まれるが、一時的に描画されない | ビューポートにスクロールされたときに描画 | ⚠️ ただしレンダーツリー内には存在 |
📊 この表から何が読み取れるか?
重要な発見:display: noneは唯一「本当にパフォーマンスを節約できる」非表示方法だ。要素が完全にレンダーツリー内に存在しないため、ブラウザはレイアウトやペイントの処理を一切行わない。
一方、visibility: hiddenとopacity: 0は「見えない」が、レンダーツリー内に存在し続けるため、ブラウザはレイアウト計算(スペース占有)が必要だ。「非表示だがレイアウトに影響を与えたくない」場合(フェードイン/アウトアニメーションなど)はopacityを使い、「完全に非表示にしてスペースも占有しない」場合はdisplay: noneを使う。
4.3 失敗談:display:noneを設定したのに、なぜページがまだカクつくのか?
❌ よくある誤解:display:noneの要素は「存在しない」と思い込む
多くの人はdisplay: noneを設定すると要素が「消滅」し、どう操作してもパフォーマンスに影響しないと思っている。これは間違いだ!
display: noneの要素はレンダーツリー内に存在しないが、JavaScriptでその属性を変更すると、ブラウザは以下を行う必要がある:
- スタイルの再計算(CSSルールのマッチング)
- 変更の追跡(将来の表示に備える)
次の「最適化」例を見てほしい:
「無効な最適化」のコードを見る
// ❌ あなたが考える「最適化」:先に非表示にして、修正が終わってから表示
const container = document.getElementById('list')
container.style.display = 'none'
// DOMを激しく操作
for (let i = 0; i < 1000; i++) {
const item = document.createElement('div')
item.style.width = Math.random() * 100 + 'px' // 幅を変更!
item.textContent = `Item ${i}`
container.appendChild(item)
}
container.style.display = 'block'
// 問題:style.widthを変更するたびに、ブラウザはスタイルを再計算する必要がある
// 要素がdisplay:noneでも!✅ 正しい最適化の方法:
// DocumentFragmentを使ってバッチ操作
const container = document.getElementById('list')
const fragment = document.createDocumentFragment() // 仮想コンテナ
// すべての操作をメモリ上のfragmentで行う
for (let i = 0; i < 1000; i++) {
const item = document.createElement('div')
item.style.width = Math.random() * 100 + 'px'
item.textContent = `Item ${i}`
fragment.appendChild(item) // 実際のDOMには影響しない
}
// 一度だけ実際のDOMに挿入、レンダリングは1回だけトリガー
container.appendChild(fragment)5. 第三段階:レイアウトとリフロー
5.1 「レイアウト」とは?
🤔 レイアウト(Layout)とは?
レイアウトは、リフロー(Reflow)とも呼ばれ、ブラウザがレンダーツリー内の各要素について「どの位置に、どれだけのスペースを占めるか」を計算するプロセスだ。
これはインテリアデザイナーが部屋を採寸する様子に例えられる:
- まず各部屋の縦横を測る
- 家具をどこに置くか決める
- 各家具の座標を計算する
なぜレイアウトは「高コスト」なのか? 1つの要素の変更が他の要素に影響を与える可能性があるからだ。例えばdivを広げると、隣のdivが押し下げられ、ページ全体の再計算が必要になるかもしれない。
5.2 リフローを引き起こす「地雷原」
以下はリフローを引き起こすよくある操作だ。ブックマークして暗記することを推奨する:
| カテゴリ | プロパティ/操作 | パフォーマンス影響 | 代替案 |
|---|---|---|---|
| サイズ | width, height, min/max-width/height | 💀💀💀 | transform: scale()を使う |
| 位置 | top, right, bottom, left | 💀💀💀 | transform: translate()を使う |
| 余白 | margin, padding | 💀💀 | transformまたはgapを使う |
| 枠線 | border-width | 💀💀 | 頻繁な変更を避ける |
| 内容 | テキスト内容の変更、画像の読み込み | 💀💀 | スペースを事前確保し、レイアウトシフトを避ける |
| フォント | font-size, line-height | 💀💀💀 | 頻繁な変更を避ける |
| 表示 | display値の変更 | 💀💀💀 | visibilityまたはopacityを使う(完全非表示が必要ない場合) |
| クエリ | offsetWidth, offsetHeightなど | 💀💀💀💀💀 | バッチ読み取りで、レイアウトスラッシングを避ける |
📊 この表から何が読み取れるか?
重要な発見:
- ジオメトリプロパティ(幅、高さ、位置)が最も高コスト:完全なレイアウト計算を引き起こす
- クエリプロパティは変更よりも危険:
offsetWidthの読み取りは強制同期レイアウトを引き起こす(5.4節参照) - transformとopacityが最もパフォーマンスが良い:リフローを引き起こさず、合成のみをトリガーする
5.3 失敗談:なぜアニメーションがパワポのようにカクつくのか?
落とし穴:widthでアニメーションする
パフォーマンスの悪いアニメーションコードを見る
/* ❌ 悪いアニメーション:リフローを引き起こす */
.box {
width: 100px;
transition: width 0.3s;
}
.box:hover {
width: 200px; /* 幅の変更はリフローを引き起こす! */
}アニメーションの各フレームでリフローが発生し、ブラウザは以下を行う必要がある:
- 幅の再計算
- 位置の再計算(他の要素に影響する可能性あり)
- 再ペイント
✅ 良いアニメーション:transformを使う
/* ✅ 良いアニメーション:合成のみをトリガー */
.box {
width: 100px;
transform: scaleX(1);
transition: transform 0.3s;
}
.box:hover {
transform: scaleX(2); /* 拡大縮小はリフローを引き起こさない! */
}transformはGPUが直接処理し、リフローとリペイントを引き起こさず、アニメーションは絹のように滑らかだ。
5.4 パフォーマンスキラー:強制同期レイアウト
💀 最も危険なパフォーマンス問題:レイアウトスラッシング
強制同期レイアウト(Forced Synchronous Layout)は、レイアウトスラッシング(Layout Thrashing)とも呼ばれ、最も一般的で深刻なパフォーマンス問題だ。
その原因は:JavaScriptがレイアウトプロパティ(offsetWidthなど)を読み取る際、ブラウザは正確な値を返すために即座にレイアウト計算を実行しなければならない。
「読み取りと書き込みを交互に」行うと、ブラウザは「レイアウト→読み取り→レイアウト→読み取り」を繰り返し、悪循環に陥る。
レイアウトスラッシングのコードを見る
// ❌ 極めて悪い:読み書き交互でレイアウトスラッシングを引き起こす
const elements = document.querySelectorAll('.item')
for (let i = 0; i < elements.length; i++) {
const height = elements[i].offsetHeight // 読み取り → 強制レイアウト
elements[i].style.width = (height * 2) + 'px' // 書き込み → リフローが必要とマーク
// 次のループの読み取りでまた強制レイアウト…悪循環!
}
// 100個の要素があれば、100回のレイアウト計算が発生する!✅ 正しい最適化方法:読み取りと書き込みを分離
const elements = document.querySelectorAll('.item')
// ステップ1:バッチ読み取り(まず全部読み取る)
const heights = []
for (let i = 0; i < elements.length; i++) {
heights.push(elements[i].offsetHeight) // レイアウトは1回だけ
}
// ステップ2:バッチ書き込み(その後全部書き込む)
requestAnimationFrame(() => {
for (let i = 0; i < elements.length; i++) {
elements[i].style.width = (heights[i] * 2) + 'px' // リフローは1回だけ
}
})6. 第四段階:ペイントとリペイント
6.1 「ペイント」とは?
🤔 ペイント(Paint)とは?
ペイントは、ブラウザが「レイアウト計算済み」の要素を実際に画面に「描く」プロセスだ。
これは部屋の壁を塗る様子に例えられる:
- レイアウト段階 = 寸法を測り、線を引く
- ペイント段階 = 実際に塗料を塗り、壁紙を貼る
ペイントはレイアウトほど高コストではないが、安くもない。 頻繁なペイントは依然としてパフォーマンスに影響する。特に複雑な要素(影、グラデーションなど)は注意が必要だ。
6.2 リペイントを引き起こすシグナル
リフローと異なり、リペイントは「外観」の変更のみを含み、「ジオメトリ」の変更は含まない:
| カテゴリ | プロパティ | パフォーマンス影響 | 備考 |
|---|---|---|---|
| 色 | color, background-color | 💀 | 最も一般的なリペイントトリガー |
| 背景 | background-image, background-position | 💀💀 | 画像は単色より遅い |
| 枠線 | border-color, border-style | 💀 | 枠線の色/スタイルの変更 |
| 文字 | text-decoration, text-shadow | 💀💀 | 影は単なる文字より遅い |
| ボックスシャドウ | box-shadow | 💀💀💀 | 複雑な影は非常に遅い |
| 角丸 | border-radius | 💀 | 角丸のサイズ変更 |
| 透明度 | opacity | ✅ | 特殊:リペイントを引き起こさず、合成のみをトリガー |
📊 この表から何が読み取れるか?
重要な発見:opacityは特殊だ!transformと同様に、リペイントを引き起こさず、直接合成段階をトリガーする。これがopacityを使ったフェードイン/アウトアニメーションのパフォーマンスが最も良い理由だ。
また、影とグラデーションはリペイントの中でも特に高コストだ。複雑なピクセル計算が必要だからだ。ページにbox-shadowが多い場合は、疑似要素や画像での代替を検討しよう。
6.3 失敗談:なぜhover効果がカクつくのか?
落とし穴:box-shadowでhoverアニメーションをする
パフォーマンスの悪いhover効果を見る
/* ❌ 悪いhover効果:box-shadowアニメーションは非常に遅い */
.card {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s;
}
.card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); /* 影は非常に遅い! */
}box-shadowはピクセル単位の計算が必要で、アニメーション時にカクつく。
✅ 良い方法:transformまたは疑似要素を使う
/* ✅ 良いhover効果:transformを使う */
.card {
transform: translateY(0);
transition: transform 0.3s, box-shadow 0.3s;
}
.card:hover {
transform: translateY(-4px); /* hover時のみ影を変更、アニメーションはしない */
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}transform: translate3d(0,0,0)任何3D变换都会创建新层opacity配合transition使用时position: fixed固定定位元素需要独立层will-change: transform显式提示浏览器创建层7. 第五段階:合成とGPUアクセラレーション
7.1 「合成」とは?
🤔 合成(Composite)とは?
合成は、現代ブラウザの「魔法」だ。ページの異なる部分を複数のレイヤー(Layer)に分割し、GPU(グラフィックスプロセッサ)を使って並列に最終画面を合成する。
これはPhotoshopのレイヤーに例えられる:
- 従来の方式 = すべてを1つのレイヤーに描く(CPU逐次処理、遅い)
- 合成方式 = レイヤー別に描き、最後に統合(GPU並列処理、速い)
なぜ合成は速いのか? GPUは「画像合成」のような並列タスクが得意で、CPUより数十倍速いからだ。
7.2 どの要素が「合成レイヤー」に昇格されるのか?
ブラウザは特定の要素を自動的に独立した合成レイヤーに昇格させる。以下はよくあるトリガー条件だ:
| トリガー条件 | CSSプロパティ/値 | パフォーマンス影響 | 注意事項 |
|---|---|---|---|
| 3D変形 | transform: translate3d(), rotate3d() | ✅✅✅ | アニメーションパフォーマンスが最高 |
| ハードウェアアクセラレーションhack | transform: translateZ(0) | ✅✅ | 通称「強制GPUアクセラレーション」 |
| 透明度アニメーション | opacityの変化(アニメーションと併用) | ✅✅✅ | リペイントを引き起こさない |
| 固定配置 | position: fixed | ✅ | スクロール時の繰り返しレイアウトを回避 |
| Will-Change | will-change: transform, opacity | ✅✅ | 事前にレイヤーを作成、メモリに注意 |
| Canvas/WebGL | <canvas>, WebGLコンテンツ | ✅✅ | デフォルトで独立レイヤー |
| Video | <video> | ✅✅ | 独立レイヤー、相互影響を防止 |
📊 この表から何が読み取れるか?
重要な発見:transformとopacityは最もパフォーマンスの良いアニメーションプロパティだ。リフローとリペイントを引き起こさず、直接合成をトリガーするからだ。これがパフォーマンス最適化ガイドで常に「transformとopacityでアニメーションせよ」と言われる理由だ。
ただし注意:各合成レイヤーはGPUメモリを消費する。translateZ(0)の乱用はメモリ爆発を引き起こす(7.4節参照)。
7.3 失敗談:合成レイヤーが多すぎて逆にカクつく?
💀 過剰最適化の罠
「GPUアクセラレーションは速い」と聞いて、すべての要素にtransform: translateZ(0)を追加した結果、ページが逆にさらにカクついたという人がいる。
問題の原因: 各合成レイヤーはGPUに「テクスチャ」(ビットマップ)を保存する必要があり、メモリを消費する。ページに100個の合成レイヤーがあると、GPUメモリがパンクし、低スペックデバイスではクラッシュしたりCPUレンダリングにフォールバックしたりする。
「過剰最適化」のコードを見る
/* ❌ 間違ったやり方:すべての要素にGPUアクセラレーションを有効にする */
.card { transform: translateZ(0); }
.button { transform: translateZ(0); }
.icon { transform: translateZ(0); }
/* ... 100個の要素すべてに追加 ... */
/* 結果:GPUメモリが爆発し、ページがフリーズ */✅ 正しいやり方:必要なときだけ使う
/* 戦略1:本当にアニメーションが必要な要素だけに有効にする */
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-5px); /* 自動的に合成レイヤーが作成される */
}
/* 戦略2:will-changeでブラウザにヒントを与える */
.card {
will-change: transform; /* 事前にレイヤーを作成 */
}
/* 戦略3:アニメーション終了後に解除 */
.card:not(:hover) {
will-change: auto; /* GPUメモリを解放 */
}8. イベントループ:JavaScriptの「分身の術」
🤔 イベントループとは?
イベントループ(Event Loop)は、JavaScriptが「非同期」を実現するメカニズムだ。JavaScriptはシングルスレッド(一度に1つのことしかできない)だが、ユーザーのクリック、ネットワークリクエスト、タイマーなど複数のタスクを処理する必要がある。そのため、これらのタスクを管理する「スケジューリングシステム」が必要になる。
これは宅配便の仕分けセンターに例えられる:
- Call Stack(コールスタック) = 現在処理中の荷物
- Web APIs = 外部提携倉庫(タイマー、ネットワークリクエストなど)
- Callback Queue(コールバックキュー) = 処理待ちの荷物棚
- Event Loop(イベントループ) = 仕分けロボット(「次のタスクを処理できるか」を常にチェックしている)
8.1 マクロタスクとマイクロタスク
初期のJavaScriptは1つのタスクキューしか持っていなかった。しかし非同期プログラミングが複雑になるにつれ、ブラウザは2種類のタスクを導入した:
| タイプ | よくある発生源 | 優先度 | 実行タイミング |
|---|---|---|---|
| マクロタスク | setTimeout/setInterval、I/O操作、UIレンダリング | 低 | 各イベントループサイクルで1つ実行 |
| マイクロタスク | Promise.then、MutationObserver | 高 | 現在のマクロタスク終了後、すべてのマイクロタスクを即座にクリア |
実行順序の「口诀」:
1. 現在のマクロタスクを実行(例:<script>全体)
2. 実行中に生成されたすべてのマイクロタスクを実行(Promise.thenなど)
↳ マイクロタスクは新しいマイクロタスクを生成でき、すべてクリアされるまで続く
3. 必要に応じてUIレンダリングを実行(リフロー/リペイント)
4. 次のイベントループサイクルを開始し、次のマクロタスクを実行8.2 失敗談:PromiseはsetTimeoutより速い?
❌ よくある誤解:setTimeout(fn, 0)は「即座に」実行される
多くの人はsetTimeout(fn, 0)が「0ミリ秒後に即座に実行される」と思っているが、これは間違った理解だ。
実際には、setTimeout(fn, 0)の意味は:「少なくとも0ミリ秒待ってから、コールバックをマクロタスクキューに追加する」だ。しかし現在のコールスタックがクリアされ、マイクロタスクキューがクリアされ、可能なUIレンダリングが完了するまで待つ必要がある。
実行順序を見る
console.log('1. Start')
setTimeout(() => {
console.log('2. setTimeout callback')
}, 0)
Promise.resolve().then(() => {
console.log('3. Promise.then')
})
console.log('4. End')
// あなたが想像する出力順序:
// 1. Start
// 4. End
// 2. setTimeout callback ← setTimeout(0)は即時じゃないの?
// 3. Promise.then
// 実際の出力順序:
// 1. Start
// 4. End
// 3. Promise.then ← Promise.thenがsetTimeoutより先に実行される!
// 2. setTimeout callback実行フロー図解:
コールスタック(Call Stack) マクロタスクキュー マイクロタスクキュー
[setTimeout callback] [Promise.then callback]
1. console.log('1. Start')
→ 出力: 1. Start
2. setTimeout(fn, 0)
→ コールバックをマクロタスクキューに追加 ← [setTimeout callback]
3. Promise.resolve().then()
→ コールバックをマイクロタスクキューに追加 ← [Promise.then callback]
4. console.log('4. End')
→ 出力: 4. End
5. コールスタックがクリアされ、マイクロタスクキューをチェック
→ Promise.thenコールバックを発見
→ 実行: console.log('3. Promise.then')
→ 出力: 3. Promise.then
6. マイクロタスクキューがクリアされる
→ UIレンダリングが必要な場合あり(変更があれば)
7. マクロタスクキューをチェック
→ setTimeoutコールバックを発見
→ 実行: console.log('2. setTimeout callback')
→ 出力: 2. setTimeout callback💡 核心的教訓
マイクロタスクはマクロタスクより「緊急」だ。ある操作を「現在のコードブロック終了後、かつUI更新前」にできるだけ早く実行したい場合は、Promise.thenまたはqueueMicrotaskを使う。
setTimeout(0)は即時実行を保証せず、少なくとも現在のコールスタックがクリアされ、マイクロタスクキューがクリアされるまで遅延される。
Event Loop: How JavaScript Executes Code
Code queue
Worker (single thread)
Task queue
Output log
Execution order: not started
Written order: 1, 2, 3, 4, 5
Code is written top to bottom, but it does not always run top to bottom because async work is delayed until the current code finishes.
console.log('1')
setTimeout(() => console.log('2'), 0) // 宏任务
Promise.resolve().then(() => console.log('3')) // 微任务
console.log('4')
// 输出顺序:1 → 4 → 3 → 29. パフォーマンス最適化の実践:ウェブページを「飛ばせ」
レンダリングパイプラインのワークフローを理解したところで、最適化の方法を見ていこう。以下は最も実用的な5つの最適化テクニックだ。
9.1 黄金律:強制同期レイアウトを避ける
問題:レイアウトプロパティの読み取りと書き込みを交互に行うと、レイアウトスラッシングが発生する。
最適化前後の比較を見る
// ❌ 極めて悪い:読み書き交互でレイアウトスラッシングを引き起こす
for (let i = 0; i < elements.length; i++) {
const height = elements[i].offsetHeight // 読み取り → 強制レイアウト
elements[i].style.height = (height * 2) + 'px' // 書き込み → リフローが必要とマーク
// 次のループの読み取りでまた強制レイアウト…悪循環!
}
// ✅ 極めて良い:まず全部読み取り、その後全部書き込み
// ステップ1:バッチ読み取り
const heights = []
for (let i = 0; i < elements.length; i++) {
heights.push(elements[i].offsetHeight)
}
// ステップ2:バッチ書き込み
requestAnimationFrame(() => {
for (let i = 0; i < elements.length; i++) {
elements[i].style.height = (heights[i] * 2) + 'px'
}
})9.2 transformとopacityでアニメーションする
問題:width、height、left、topでアニメーションするとリフローが発生する。
最適化前後の比較を見る
/* ❌ 悪いアニメーション:リフローを引き起こす */
.box {
transition: width 0.3s, left 0.3s;
}
.box.moving {
width: 200px;
left: 100px;
}
/* ✅ 良いアニメーション:合成のみをトリガー */
.box {
transition: transform 0.3s;
}
.box.moving {
transform: translateX(100px) scaleX(2);
}9.3 仮想スクロール:大量データリストの解決策
問題:リスト項目数が数千に達すると、DOMノード数が多すぎてパフォーマンス問題が発生する。
核心思想:ビューポート内の可視リスト項目のみをレンダリングする(少量のバッファを含む)。DOMノード数は固定され、データ総量とは無関係になる。
transform 和 opacity 做动画will-change 提前告知浏览器仮想スクロールの実装を見る
<template>
<div class="virtual-list" @scroll="handleScroll">
<!-- プレースホルダー要素、スクロールバーを支える -->
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<!-- 実際にレンダリングされるリスト項目 -->
<div class="content" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="item in visibleItems"
:key="item.id"
class="item"
:style="{ height: itemHeight + 'px' }"
>
{{ item.name }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
items: Array,
itemHeight: { type: Number, default: 50 }
})
const scrollTop = ref(0)
const buffer = 5 // バッファ数
// 可視領域に表示できる項目数
const visibleCount = computed(() => 10)
// 開始インデックス
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - buffer)
)
// 終了インデックス
const endIndex = computed(() =>
Math.min(props.items.length, startIndex.value + visibleCount.value + buffer * 2)
)
// 現在可視のデータ
const visibleItems = computed(() =>
props.items.slice(startIndex.value, endIndex.value)
)
// 総高さ
const totalHeight = computed(() => props.items.length * props.itemHeight)
// オフセット
const offsetY = computed(() => startIndex.value * props.itemHeight)
const handleScroll = (e) => {
scrollTop.value = e.target.scrollTop
}
</script>9.4 デバウンスとスロットル:イベント発火頻度を減らす
問題:頻繁に発火するイベント(scroll、resizeなど)がパフォーマンス問題を引き起こす。
デバウンスとスロットルの実装を見る
// デバウンス(Debounce):遅延実行。遅延時間内に再発火した場合、タイマーをリセット
function debounce(fn, delay) {
let timer = null
return function (...args) {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
// スロットル(Throttle):固定時間間隔で実行
function throttle(fn, interval) {
let lastTime = 0
return function (...args) {
const now = Date.now()
if (now - lastTime >= interval) {
lastTime = now
fn.apply(this, args)
}
}
}
// 使用例
window.addEventListener('scroll', debounce(handleScroll, 200))
window.addEventListener('resize', throttle(handleResize, 100))9.5 遅延読み込み:重要でないリソースの読み込みを遅延させる
問題:ファーストビューでリソースを読み込みすぎると、ページの表示が遅くなる。
遅延読み込みの実装を見る
// 画像の遅延読み込み
const lazyImages = document.querySelectorAll('img[data-src]')
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src // 実際の画像を読み込む
img.removeAttribute('data-src')
observer.unobserve(img) // 監視を停止
}
})
})
lazyImages.forEach(img => imageObserver.observe(img))10. あなたが今識別できるはずのパフォーマンス問題
ブラウザのレンダリングパイプラインを理解したことで、以下のよくあるパフォーマンス問題を識別できるようになったはずだ:
| 問題コード | 問題点 | AIへの伝え方 |
|---|---|---|
element.style.width = ... | ループ内で頻繁に幅を変更している | 「ここで複数回のリフローが発生します。transformを使うかバッチ処理に変更してください」 |
height = element.offsetHeight | 書き込み直後にレイアウトプロパティを読み取っている | 「これは強制同期レイアウトです。読み取りと書き込みを分離してください」 |
element.className = ... | 頻繁なclass変更でスタイル再計算が発生 | 「classList.add/removeに置き換えて、スタイル計算を減らしてください」 |
アニメーションにwidth/leftを使用 | リフローとリペイントを引き起こし、パフォーマンスが悪い | 「transformとopacityでアニメーションするように変更してください」 |
すべての要素にtranslateZ(0)を追加 | GPUアクセラレーションの乱用でメモリ爆発 | 「アニメーションが必要な要素だけにGPUアクセラレーションを有効にしてください」 |
| リスト項目10000個をすべてレンダリング | DOMノードが多すぎてカクつく | 「仮想スクロールを実装し、可視領域だけをレンダリングしてください」 |
| scrollイベント内で直接DOM操作 | 発火頻度が高すぎてカクつく | 「requestAnimationFrameまたはスロットルで最適化してください」 |
box-shadowでhoverアニメーション | 複雑な影の計算が非常に遅い | 「transformまたは疑似要素に変更し、影のアニメーションを避けてください」 |
各章の「失敗談」をしっかり読んだなら、以下の核心概念も習得しているはずだ:
- レンダリングパイプラインの5段階:DOM/CSSOM → レンダーツリー → レイアウト → ペイント → 合成
- リフロー vs リペイント:リフローが最も高コスト(ジオメトリ変更)、リペイントはその次(外観変更)
- 強制同期レイアウト:読み書き交互はレイアウトスラッシングを引き起こす。必ず分離すること
- GPUアクセラレーション:transformとopacityはGPUが処理し、パフォーマンスが最良
- イベントループ:JavaScriptはシングルスレッドで、タスクキューを通じて非同期を実現する
これらの概念はパフォーマンスのボトルネックを素早く特定するのに役立つ。
💡 パフォーマンス問題に遭遇したときはAIにこう伝えよう
- 「アニメーションがカクつきます。リフローやリペイントが発生していないか確認してください」
- 「スクロールパフォーマンスが悪いです。スロットルまたはrequestAnimationFrameが必要かもしれません」
- 「リストのデータ量が多いときにカクつきます。仮想スクロールが必要です」
- 「頻繁なスタイル変更でパフォーマンス問題が発生しています。transformで最適化してください」
11. まとめ:レンダリングパイプライン最適化の本質
本文の学習を通じて、以下の核心的結論が得られる:
実践から見ると:最適化は多ければ多いほど良いのではなく、「的を射た」最適化であるほど良い。ブラウザのレンダリングパイプラインを理解してこそ、どこに力を入れ、どこで手を抜くべきかがわかる。
コストの視点から見ると:
- パフォーマンスの浪費の大部分は、レイアウトプロパティの頻繁な読み書き交互に起因する。読み書き分離とバッチ処理で解決する必要がある
- 複雑なアニメーション効果がリフローとリペイントを引き起こしている場合、多くの場合「間違ったプロパティ」を使っていることが原因で、
transformとopacityで解決する必要がある - 大量データのリストレンダリングでは、仮想DOMだけでは不十分で、仮想スクロールなどの技術と組み合わせる必要がある
目標は:与えられたブラウザとハードウェアの条件下で、すべてのレンダリングステップの投入が明確なパフォーマンスリターンを持つようにすることだ。
12. 用語对照表
| 英語用語 | 日本語对照 | 説明 |
|---|---|---|
| DOM | ドキュメントオブジェクトモデル | ブラウザがHTML文書を解析して形成したツリー構造。JavaScriptはDOM APIを通じてページ要素を操作できる |
| CSSOM | CSSオブジェクトモデル | ブラウザがCSSを解析して形成したツリー構造。DOMと組み合わせて最終スタイルを計算する |
| Render Tree | レンダーツリー | DOMツリーとCSSOMツリーを統合して作られ、可視ノードのみを含む。後続のレイアウト計算とペイントに使用される |
| Layout | レイアウト | レンダーツリー内の各ノードのジオメトリ情報(位置、サイズ)を計算するプロセス。Reflow(リフロー)とも呼ばれる |
| Reflow | リフロー | 要素のサイズ、位置などのジオメトリプロパティが変更されたとき、ブラウザがレイアウトを再計算するプロセス |
| Paint | ペイント | レイアウト計算後の要素スタイル(色、背景、枠線など)を画面に描画するプロセス |
| Repaint | リペイント | 要素の外観プロパティ(色、背景など)が変更されたがジオメトリプロパティに影響しない場合にトリガーされる描画更新 |
| Composite | 合成 | 複数の描画レイヤー(Layer)を最終的な画面画像に統合するプロセス。通常GPU上で実行される |
| Layer | レイヤー/合成レイヤー | ブラウザがレンダリング最適化のために作成する独立した描画面。個別に変形・合成できる |
| Event Loop | イベントループ | JavaScriptの非同期実行メカニズム。マクロタスクとマイクロタスクの実行をスケジューリングする |
| Call Stack | コールスタック | 現在実行中のJavaScript関数を記録するデータ構造 |
| Macro Task | マクロタスク | イベントループで優先度が低いタスクタイプ。setTimeout、setInterval、I/O操作など |
| Micro Task | マイクロタスク | イベントループで優先度が高いタスクタイプ。Promise.then、MutationObserverなど |
| Forced Synchronous Layout | 強制同期レイアウト | JavaScriptでレイアウトプロパティの読み取りと書き込みを交互に行うことで、ブラウザが即座にレイアウト計算を強制されるパフォーマンス問題 |
| Layout Thrashing | レイアウトスラッシング | 頻繁な強制同期レイアウトによってパフォーマンスが急激に低下する現象 |
| Virtual Scrolling | 仮想スクロール | ビューポート内の可視リスト項目のみをレンダリングする技術。大規模データリストのパフォーマンス最適化に使用 |
| RAF | requestAnimationFrame | ブラウザが提供するAPI。次のリペイント前にアニメーション関連のJavaScriptコードを実行するために使用 |