【実務・中級編】 requestAnimationFrameの実行タイミングとレンダリング同期 – Webブラウザの仕組み実践ガイド

こんにちは。フロントエンドチームのシニアアーキテクトとして、日々ブラウザという巨大で気まぐれなブラックボックスと格闘している者です。

君もそろそろ、`setTimeout`や`setInterval`でアニメーションを無理やり動かそうとしてカクつかせたり、意図しないタイミングでDOMをいじって強制レイアウト(レイアウトスラッシング)を引き起こし、「なんかこの画面、重いすね…」とプロダクトマネージャーに冷や汗をかかされた経験がある頃じゃないかな?

今回は、そんなブラウザのレンダリングパイプラインの心臓部であり、滑らかな60fps(あるいはそれ以上)の体験を生み出すための魔法のAPI――`requestAnimationFrame`(rAF)の実行タイミングとレンダリング同期の仕組みについて、裏側の泥臭い話も含めて徹底的に解説しよう。

公式ドキュメントの「ブラウザの描画タイミングに合わせてコールバックを実行します」なんてお決まりの文句はもう卒業だ。今日でブラウザのメインスレッドのスケジュールを完全に手懐けよう。

—

1. そもそもブラウザの裏側で何が起きているのか?(レンダリングパイプラインの真実)

まずは、ブラウザが1秒間に画面を何回描き替えているかを思い出してほしい。一般的なディスプレイなら60Hz、つまり約16.6ミリ秒ごとに1フレームの更新サイクル(VSync)が回っている。

ブラウザのメインスレッドは、この16.6msのフレームバジェット(予算)の中で、以下のタスクを猛烈なスピードでこなしている。

1. 入力イベントの処理(`click`, `keydown` など)
2. タイマーの処理(`setTimeout`, `setInterval`)
3. ネットワークレスポンスの処理
4. DOMの変更と、CSSOMの構築・再計算(Recalc Style)
5. レイアウト(Layout / Reflow):各要素の位置とサイズの計算
6. ペイント(Paint / Rasterize):ピクセルへの変換
7. コンポジット(Composite):レイヤーの合成とGPUへの転送

ここで重要なのは、`setTimeout`はブラウザの描画タイミングなど一切お構いなしに発火するという点だ。
例えば、16.6msのフレームの「真ん中」で`setTimeout`のコールバックが実行され、そこでDOMを書き換えたとする。すると、ブラウザは次のフレームまで待つか、最悪の場合、同じフレーム内で無理やりレイアウトを再計算させられる(これがパフォーマンスキラーの「強制同期レイアウト」だ)。

rAFはどこに割り込むのか?

ここで登場するのが`requestAnimationFrame`だ。
rAFは、「次の再描画(Repaint)が始まる直前」に実行されるキューに自分のタスクを登録する。

ブラウザの内部ループをイメージしてみよう:

[ VSync (画面更新の合図) ]
↓
1. 入力イベント・タスクの消化 (Task Queue)
↓
2. requestAnimationFrame のコールバック実行 ←★ここ!
↓
3. DOMの変更を反映 (Style / Layout / Paint)
↓
4. 次の VSync までメインスレッドは待機(またはアイドル処理)

つまり、rAFのコールバック内でDOMを読み書きすれば、そのフレームの「スタイル計算・レイアウト」の直前に綺麗に滑り込むことができる。これ以上なく効率的で、無駄な再計算を一切発生させない理想的なタイミングなのだ。

—

2. メインスレッドのタスクスケジューリングにおける優先順位

実務でパフォーマンスチューニングをするときに絶対に知っておくべきなのが、ブラウザがどのタスクを優先して実行するのかという「力学」だ。

ブラウザのメインスレッドは、ざっくり言うと次のような優先順位で動いている。

1. ユーザー入力(User Input):タップ、スクロール、キー入力。最優先。これが遅れると「アプリが重い」と感じる。
2. requestAnimationFrame (rAF):画面描画の直前。視覚的な滑らかさを保つために非常に高い優先度を持つ。
3. マクロタスク(`setTimeout`, `MessageChannel`など):空き時間に実行される。
4. マイクロタスク(`Promise.then`, `MutationObserver`など):現在のタスクの直後、他のタスクに移行する前に必ず消化される。
5. requestIdleCallback (rIC):マジで暇なときにだけ実行される(低優先度)。

ここで「おや?」と思った鋭い後輩なら正解だ。
もしrAFのコールバックの中で、重い計算処理や巨大なDOMの走査を行ったらどうなるか?

答えは簡単。rAFの処理が終わらないと、ブラウザはレイアウトやペイントに進めない。
つまり、rAF内で重い処理をすると、次のフレームのVSyncに間に合わず、盛大にフレームドロップ(コマ落ち)して画面がカクつく。rAFは「描画の準備をする場所」であって、「重い演算をする場所」ではないということを肝に銘じておいてほしい。

—

3. 【実践】現場で使える!rAFを使ったスマートなUI制御パターン

理屈はこれくらいにして、実務でそのまま使えるコードを見せよう。
今回は、よくある「スクロール位置に追従するヘッダーの軽量化」や「アニメーションのフレーム制御」を例に取るな。

悪例:`scroll` イベントで直接DOMをいじる(アンチパターン)

// 絶対にやってはいけない。スクロールのたびにブラウザが悲鳴を上げる。
window.addEventListener(‘scroll’, () => {
const scrollTop = window.scrollY;
// この瞬間にDOMを読む(Layout)し、書き換える(Style)
headerElement.style.transform = `translateY(${scrollTop > 100 ? -100 : 0}px)`;
});

これだと、1秒間に何十回も発生する`scroll`イベントの度に、ブラウザのメインスレッドがレイアウト計算を強制され、スクロールがガクガクになる。

模範解答:`requestAnimationFrame` によるイベントのデバウンス(スロットリング)

ブラウザに優しい、プロのコードを見てほしい。

/

  • requestAnimationFrameを活用した高性能なスクロールオブザーバー

/
class StickyHeaderController {
constructor(headerEl) {
this.header = headerEl;
this.latestKnownScrollY = 0;
this.ticking = false; // フラグでフレーム毎の重複実行をガード

this.init();
}

init() {
window.addEventListener(‘scroll’, () => {
// 現在のスクロール位置を保持するだけ(ここでは重い処理は一切しない)
this.latestKnownScrollY = window.scrollY;
this.requestTick();
}, { passive: true }); // パッシブリスナーでスクロールパフォーマンスをさらに向上
}

requestTick() {
if (!this.ticking) {
// 次の描画サイクルに処理を予約する
requestAnimationFrame(() => {
this.updateDOM();
this.ticking = false; // 処理が終わったらフラグを戻す
});
this.ticking = true;
}
}

updateDOM() {
// この中で初めてDOMの読み書きを行う
// rAFの直前なので、レイアウトスラッシングが起きない
const shouldHide = this.latestKnownScrollY > 100;

// ブラウザのコンポジター層だけで処理させたいので transform を使うのがコツ
this.header.style.transform = shouldHide ? ‘translateY(-100px)’ : ‘translateY(0px)’;
}
}

// 現場でのイニタイズ例
const header = document.querySelector(‘.site-header’);
if (header) {
new StickyHeaderController(header);
}

このコードの美しいポイントは、`ticking`というフラグを使って、1つのフレーム内で何回スクロールイベントが発生しようとも、DOMの更新処理は必ず1フレームに1回しか走らないように間引き(スロットリング)している点だ。

—

4. シニアからの実践的なTips・注意点

最後に、現場でハマりがちな罠と、それを回避するための知見をいくつか共有しておこう。

1. 非表示タブでの挙動に注意する

  • ブラウザはタブが非表示(バックグラウンド)になると、リソース節約のためにrAFの実行頻度を極端に落とす(あるいは停止する)仕様になっている。
  • もしrAFを「ゲームのロジックのメインループ」や「厳密なタイマー」として使っていると、タブを切り替えた瞬間に時間が狂う。絶対的な時間管理には`Date.now()`や`performance.now()`の差分を組み込む設計にすること。

2. キャンセル処理(`cancelAnimationFrame`)を忘れない

  • コンポーネントのアンマウント時や、アニメーションの途中で不要になったrAFは、必ず`cancelAnimationFrame(id)`で破棄しなさい。メモリリークや、すでに消滅したDOM要素への無駄なアクセスエラーを防ぐ基本中の基本だ。

3. CSS Animations / Transitions との使い分け

  • 単純なフェードインや移動なら、JavaScriptでrAFを回すよりもCSS Animations(あるいはWeb Animations API)を使った方が、ブラウザのコンポジットスレッド(別スレッド)で処理されるため圧倒的に滑らかだ。
  • JSでrAFを使うべきなのは、「複数の値が複雑に絡み合うインタラクティブなアニメーション」や「物理演算を伴う挙動」のときだけだと覚えておこう。

—

まとめ

`requestAnimationFrame`は、単なる「アニメーション用の便利関数」ではない。
「ブラウザのレンダリングサイクルとJavaScriptの実行タイミングを完璧に同期させるための、極めてプリミティブで強力なコントロールパネル」だ。

この仕組みを理解していれば、「なぜこの画面はカクつくのか」「どうすればメインスレッドを解放できるのか」が、ブラウザの内部構造から論理的に導き出せるようになる。

さあ、今日の学びを活かして、君のプロダクトのUIをバターのように滑らかな極上の体験に仕上げてくれ。何か詰まったら、いつでもコードレビューに持ってきなさい。応援しているよ!

コメント

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