おう、諸君!フロントエンドの現場で日々奮闘している諸君に、今日はブラウザの描画処理、特に `requestAnimationFrame` について、現場のリアルな感覚を交えながら、ガッツリ解説していこうじゃないか。
「リフロー」とか「リペイント」とか、聞くだけでちょっとゾッとするかもしれないが、これがWebページのパフォーマンスを支える根幹なんだ。そして、その描画サイクルを賢く使うための強力な味方が `requestAnimationFrame` だ。
単なるAPIの説明に終始するつもりはない。ブラウザの裏側で何が起こっているのか、なぜ `requestAnimationFrame` が重要なのか、そしてどうすれば君たちのコードがもっとスマートになるのか、そんな話を、温かいコーヒーでも飲みながら、じっくり聞いてもらおうじゃないか。
—
💥 ブラウザの描画サイクル:知っておくべき「裏側」の話
まず、君たちが書いたHTML、CSS、JavaScriptが、どうやって画面に絵として現れるのか、そのプロセスをざっくりと理解しておこう。これは、パフォーマンスチューニングの基本中の基本だ。
ブラウザは、君たちのコードを受け取ると、いくつかの段階を経て画面を描画する。主な流れはこんな感じだ。
1. JavaScript実行: まず、JavaScriptが実行される。DOMの操作、イベントハンドリング、アニメーションの計算など、動的な処理はここで行われる。
2. スタイル計算: JavaScriptの実行結果や、CSSファイルに基づいて、各要素の最終的なスタイルが計算される。
3. レイアウト(リフロー): 要素のサイズや位置が変更された場合、ブラウザは「この要素の配置はどうなる?」「他の要素に影響はないか?」と、ページ全体のレイアウトを再計算する必要がある。この処理を リフロー(Reflow) または レイアウト(Layout) と呼ぶ。これは結構重い処理だ。
4. ペイント(リペイント): レイアウトが決まったら、次は要素の見た目(色、背景、ボーダーなど)を描画する。この処理を ペイント(Paint) または リペイント(Repaint) と呼ぶ。
5. コンポジット(Composite): 最後に、複数のレイヤーに分かれた描画結果を重ね合わせて、最終的な画面を生成する。これを コンポジット(Composite) と呼ぶ。GPUが使われることが多く、比較的軽い処理だ。
なぜリフローとリペイントが問題なのか?
君たちが普段何気なくやっているDOM操作やスタイル変更は、この描画サイクルをトリガーする。特に、リフローは、ページ全体のレイアウトを再計算するため、CPU負荷が高く、パフォーマンスのボトルネックになりやすい。
例えば、ループ処理の中で要素の `offsetWidth` や `offsetHeight` といったレイアウト情報にアクセスし、その後でスタイルを変更するようなコードを書くと、ブラウザは「あれ?この要素の幅はどうなったんだ?」「じゃあ、この要素の位置も再計算しないと…」と、何度もリフローを繰り返す羽目になる。これが、ブラウザがカクカクする原因の一つだ。
ブラウザは「描画のタイミング」を狙っている
ここで、ブラウザの賢いところを紹介しよう。ブラウザは、無駄なリフローやリペイントを避けるために、描画のタイミングをうまく管理している。
君たちがJavaScriptでDOMを操作したり、スタイルを変更したりしても、ブラウザはすぐにリフローやリペイントを実行するわけではない。多くの場合、次の描画フレームのタイミングまで、これらの変更を「保留」しておき、まとめて処理しようとするんだ。
これは、例えば「要素Aの位置を10px動かし、次に要素Bの幅を50px広げる」という2つの操作があった場合、それぞれでリフローを起こすのではなく、まとめて「これらの変更を適用した結果、最終的にどうなるか?」を一度だけ計算しようとする、というイメージだ。
しかし、問題は、君たちのJavaScriptコードが、このブラウザの「保留」と「まとめて処理」のサイクルを理解せずに、勝手に描画サイクルを頻繁にトリガーしてしまうことにある。
例えば、以下のようなコードを考えてみてくれ。
// 良くない例:ループ内でレイアウト情報にアクセスし、スタイルを変更している
const element = document.getElementById(‘myElement’);
element.style.width = ‘100px’; // スタイル変更
// ここでレイアウト情報にアクセスすると、ブラウザは直前のスタイル変更を確定させるためにリフローを起こす可能性がある
console.log(element.offsetWidth); // レイアウト情報へのアクセス!
element.style.height = ‘100px’; // 再度スタイル変更
console.log(element.offsetHeight); // 再度レイアウト情報へのアクセス!
このコードでは、スタイル変更の直後にレイアウト情報(`offsetWidth`, `offsetHeight`)にアクセスしている。ブラウザは、その情報が最新であることを保証するために、直前のスタイル変更によるリフローを強制的に実行してしまう。そして、次のスタイル変更の後でも、また同じことが起きる。結果として、本来なら1回で済むかもしれないリフローが、何度も実行されることになる。
🚀 `requestAnimationFrame` を使って、描画サイクルを「最適化」する
さて、ここで満を持して登場するのが `requestAnimationFrame` だ。これは、ブラウザの描画サイクル(通常は60fps、つまり1秒間に約60回)に合わせて、君たちのJavaScriptコードを実行するためのAPIなんだ。
`requestAnimationFrame` の「約束事」
`requestAnimationFrame` は、ブラウザが次に画面を更新する(描画する)直前に、指定したコールバック関数を実行してくれる。
requestAnimationFrame(callback);
この `callback` 関数には、`performance.now()` のような高精度のタイムスタンプが引数として渡される。これは、アニメーションの進行状況を正確に計測するのに役立つ。
なぜ `requestAnimationFrame` が「賢い」のか?
1. 描画タイミングとの同期: ブラウザの描画タイミングに合わせることで、無駄な描画処理を削減できる。例えば、画面に表示されていない要素のアニメーションや、ユーザーがタブを切り替えてブラウザが非アクティブになっている間のアニメーションは、実行されないか、低速になる。これにより、CPUリソースを節約できる。
2. リフロー/リペイントの効率化: `requestAnimationFrame` のコールバック関数内でDOM操作やスタイル変更を行うことで、ブラウザはそれらの変更を「次の描画フレームのためにまとめて処理しよう」と判断しやすくなる。つまり、複数の変更を一度のリフロー/リペイントで済ませることができる可能性が高まるんだ。
3. アニメーションに最適: 滑らかなアニメーションを実現するには、固定の時間間隔で更新するのではなく、ブラウザの描画フレームに同期するのが最も効果的だ。`requestAnimationFrame` はまさにそれを実現してくれる。
「悪魔のループ」からの脱却:`requestAnimationFrame` の実践的な使い方
ここからは、現場で使える具体的なコード例を見ていこう。
例1:シンプルなフェードインアニメーション
要素を徐々に表示させるフェードインアニメーションを考えてみよう。
悪い例(setTimeoutを使う場合):
setTimeoutで一定間隔でスタイルを変更すると、描画タイミングがずれたり、リフローが頻繁に発生したりする可能性がある。
// 良くない例:setTimeoutでアニメーション
const element = document.getElementById(‘myFadeElement’);
let opacity = 0;
const interval = 20; // 20msごとに更新
const duration = 1000; // 1秒で完了
function fadeIn() {
opacity += interval / duration;
if (opacity > 1) {
opacity = 1;
element.style.opacity = opacity;
return;
}
element.style.opacity = opacity;
setTimeout(fadeIn, interval); // 次の更新をスケジュール
}
fadeIn();
良い例(requestAnimationFrameを使う場合):
`requestAnimationFrame` を使うことで、ブラウザの描画タイミングに同期した、より滑らかで効率的なアニメーションが実現できる。
// 良い例:requestAnimationFrameでフェードインアニメーション
const element = document.getElementById(‘myFadeElement’);
let start = null; // アニメーション開始時のタイムスタンプ
const duration = 1000; // アニメーションの所要時間(ミリ秒)
function step(timestamp) {
if (!start) {
start = timestamp; // アニメーション開始時のタイムスタンプを記録
}
const elapsed = timestamp – start; // 開始からの経過時間
let opacity = elapsed / duration; // 0から1の間の値
// 時間を超えたらアニメーション終了
if (opacity > 1) {
opacity = 1;
}
element.style.opacity = opacity; // opacityスタイルを適用
// まだアニメーションが完了していなければ、次のフレームでもこの関数を呼び出す
if (elapsed < duration) {
requestAnimationFrame(step);
} else {
console.log('フェードインアニメーション完了!');
// 必要であれば、ここでアニメーション完了後の処理を行う
}
}
// アニメーションを開始
requestAnimationFrame(step);
解説:
- `step` 関数は、ブラウザが次の描画フレームを準備するたびに呼び出される。
- `start` 変数でアニメーションの開始時刻を記録し、`timestamp`(現在の描画フレームのタイムスタンプ)との差分から経過時間を計算する。
- 経過時間と `duration` を比較することで、アニメーションがどの程度進んでいるかを正確に把握できる。
- `element.style.opacity = opacity;` でスタイルを変更しているが、これは `requestAnimationFrame` のコールバック内で行われているため、ブラウザはこれを次の描画フレームでまとめて処理しようとする。
- `elapsed < duration` の間は `requestAnimationFrame(step)` を再度呼び出し、アニメーションを継続させる。
例2:DOM操作とスタイル変更のバッチ処理
複数の要素のスタイルを変更したり、DOMを追加・削除したりする場合、それらをまとめて `requestAnimationFrame` の中で行うことで、リフロー/リペイントの回数を減らすことができる。
良くない例:
// 良くない例:DOM操作やスタイル変更がバラバラに実行されている
const container = document.getElementById(‘myContainer’);
const items = Array.from(container.children);
// 1回目のDOM操作(リフロー発生の可能性)
items.forEach((item, index) => {
item.textContent = `Item ${index + 1}`;
});
// 2回目のスタイル変更(リフロー発生の可能性)
items.forEach(item => {
item.style.color = ‘blue’;
});
// 3回目のDOM追加(リフロー発生の可能性)
const newItem = document.createElement(‘div’);
newItem.textContent = ‘New Item’;
container.appendChild(newItem);
良い例:
// 良い例:requestAnimationFrameでDOM操作とスタイル変更をまとめる
const container = document.getElementById(‘myContainer’);
const items = Array.from(container.children);
requestAnimationFrame(() => {
// 1. DOM操作(テキスト内容の更新)
items.forEach((item, index) => {
item.textContent = `Item ${index + 1}`;
});
// 2. スタイル変更
items.forEach(item => {
item.style.color = ‘blue’;
});
// 3. DOM追加
const newItem = document.createElement(‘div’);
newItem.textContent = ‘New Item’;
container.appendChild(newItem);
console.log(‘DOM操作とスタイル変更をまとめて実行しました!’);
});
解説:
- この例では、複数のDOM操作とスタイル変更を1つの `requestAnimationFrame` コールバック関数内にまとめて記述している。
- ブラウザは、このコールバック関数が完了した後、次の描画フレームでこれらの変更をまとめて処理しようとする。これにより、本来なら複数回発生していたかもしれないリフロー/リペイントが、1回で済む可能性が高まる。
- これは、特に大量の要素を操作したり、複雑なDOM構造を変更したりする際に、パフォーマンス向上に大きく貢献する。
注意点:`requestAnimationFrame` は魔法ではない
`requestAnimationFrame` は強力だが、万能ではない。いくつかの注意点がある。
- DOMへのアクセス: `requestAnimationFrame` のコールバック内で、最初にレイアウト情報(`offsetWidth` など)にアクセスし、その後にスタイルを変更する、というパターンは、やはりリフローをトリガーする可能性がある。DOM操作やスタイル変更は、できるだけまとめて行うように心がけよう。
- 無限ループの回避: アニメーションが完了したら、必ず `requestAnimationFrame` の再帰呼び出しを停止すること。そうしないと、ブラウザのリソースを無駄に消費し続けることになる。
- ブラウザの互換性: ほとんどのモダンブラウザでサポートされているが、念のため古いブラウザへの対応が必要な場合は、Polyfillを検討する必要があるかもしれない。
✨ まとめ:現場で活きる `requestAnimationFrame` の心得
さて、ここまで `requestAnimationFrame` の仕組みと、その実践的な使い方について話してきた。最後に、現場で君たちがこの知識をどう活かせるか、心得としてまとめておこう。
- アニメーションは `requestAnimationFrame` で: CSSアニメーションやTransitionで実現できない、複雑なJavaScriptベースのアニメーションは、迷わず `requestAnimationFrame` を使おう。滑らかさ、効率性、すべてにおいて最適だ。
- DOM操作・スタイル変更の「バッチ処理」: 複数のDOM要素の更新やスタイル変更を行う場合、それらを `requestAnimationFrame` のコールバック関数にまとめて記述することを習慣づけよう。これにより、無駄なリフロー/リペイントを劇的に減らせる。
- 「読み込み」と「書き込み」を分ける: DOMのレイアウト情報(`offsetWidth`, `scrollHeight` など)の「読み込み」と、DOMへの変更(スタイル変更、要素の追加・削除など)の「書き込み」は、できるだけ分離して行うのが鉄則だ。`requestAnimationFrame` の中で、まず全ての読み込みを済ませ、その後で全ての書き込みを行う、というパターンが理想的だ。
- パフォーマンス測定を怠るな: どんなに理論的に優れていても、実際のパフォーマンスは環境やコードの書き方によって変わる。Chrome DevToolsのPerformanceタブなどを活用して、実際にコードのパフォーマンスを測定し、ボトルネックを特定する習慣をつけよう。
`requestAnimationFrame` は、ブラウザの描画サイクルという「呼吸」に合わせて、君たちのコードを「最適」に実行するための、まさに呼吸法のようなものだ。この呼吸法をマスターすれば、君たちの作るWebアプリケーションは、より滑らかに、より軽快に、そしてユーザーをより満足させるものになるはずだ。
さあ、今日から君たちのコードに `requestAnimationFrame` を積極的に取り入れて、パフォーマンスチューニングの世界をさらに深めていってくれ!健闘を祈る!

コメント