【テクニカル・上級編】 requestAnimationFrameとレンダリングループ – Webブラウザの仕組み実践ガイド

画面がカクつく夜に:`requestAnimationFrame`とレンダリングループの深淵

おい、フロントエンドエンジニアの皆さん。夜な夜なユーザーからの「なんかこのアニメーション、カクつくんだけど」というチケットに頭を抱えてはいないか?

「`setTimeout`や`setInterval`で16ミリ秒(60fps)ごとにDOMをいじってるのに、なんでだよ!」と叫び出したくなったことがあるなら、君はすでにブラウザの機嫌を損ねる一歩手前に立っている。

今日は、BlinkやWebKitといったモダンブラウザのレンダリングエンジンが裏側でどうやって心臓ビートを刻んでいるのか、その中枢である`requestAnimationFrame`(以下、rAF)とレンダリングループの仕組みを、限界まで解剖して見せよう。これを読めば、なぜ「タイマー駆動」が悪であり、なぜrAFが神聖不可侵なのか、その理由が骨身に染みるはずだ。

—

1. ブラウザの心臓部:Vsyncとレンダリングパイプラインの同期

まず大前提として、ブラウザは単独で気ままに画面を塗り替えているわけではない。ハードウェア(ディスプレイ)のリフレッシュレート、例えば一般的な60Hzなら約16.6ms、120Hzなら約8.3msごとにやってくる Vsync(垂直同期信号) という絶対的なリズムに支配されている。

ブラウザのメインスレッドは、このVsyncのタイミングに合わせて、1つのフレーム(Frame)を画面に描き出すための巨大なパイプラインを回している。その一連の流れはざっくりこうだ。

1. 入力イベントの処理 (`touchstart`, `mousemove` などのコールバック)
2. タイマーの実行 (`setTimeout`, `setInterval`)
3. 通信のコールバック
4. ライフサイクルフック (`resize`, `scroll` など)
5. `requestAnimationFrame` の実行 ← ★ここが最重要
6. レイアウト(Reflow / Layout)
7. ペイント(Paint)
8. 合成(Compositing)

多くの素人がやりがちなミスは、ステップ2の`setTimeout`や`setInterval`でDOMを書き換えようとすることだ。これらはVsyncのタイミングとは完全に非同期で発火する。そのため、運が悪いとVsyncの直前(あるいは最中)に重いDOM操作が走り、レイアウト計算が間に合わず、フレームが盛大にドロップする(これが「コマ落ち」「カクつき」の正体だ)。

一方で、`rAF`はステップ5に配置されている。つまり、「次の画面描画の直前に必ず実行される」ことがブラウザのエンジンレベルで保証されているのだ。

—

2. rAFのコールバックキューと実行順序の魔術

では、rAFの内部では一体何が起きているのか?

ブラウザのメインスレッドには、rAF専用のコールバックリスト(Queue)が存在する。`requestAnimationFrame(callback)`を呼ぶと、そのコールバックはこのリストの末尾にプッシュされる。

ここで、シニアエンジニアなら知っておくべき極めて重要な事実がある。「rAFのコールバック内でさらに`requestAnimationFrame`を呼んだ場合、その新しいコールバックは 同じフレーム では実行されず、次のフレーム のリストに回される」 という仕様だ。

これにより、無限ループによる暴走を防ぎつつ、綺麗に1フレームに1回の実行を維持できる。しかし、もしこのrAFのコールバック内で無慈悲にも重いDOMの読み取り(`offsetHeight`の強制取得などによるLayout Thrashing)を行ったらどうなるか?

[ Vsync ]
↓
[ rAF開始 ]
├─ ユーザーのrAFコールバック (ここで offsetHeight を読み取る!)
│ └─ ブラウザは「あ、スタイルの変更が汚染されたな」と検知
↓
[ 強制レイアウト (Force Layout / Reflow) ] ← メインスレッドがここで爆死
↓
[ ペイント & 合成 ]
↓
[ 次の Vsync (フレームレート低下) ]

rAFを使っていればパフォーマンスが担保されるわけではない。rAFはあくまで「描画の直前にコードを挟むためのフック」に過ぎず、その中で何をするかは完全にエンジニアの腕に委ねられているのだ。

—

3. メモリ効率とガベージコレクション(GC)の悪夢

アニメーションやインフィニティスクロールの実装で、rAFのなかにオブジェクトリテラルや無名関数を平気でばら撒いているコードを見かけると、私は背筋が凍る思いがする。

// 【アンチパターン】毎フレームGCを誘発する最悪のコード
function badLoop() {
requestAnimationFrame(() => {
const style = { transform: `translate3d(${window.scrollX}px, 0, 0)` }; // 毎フレームオブジェクト生成
element.style.transform = style.transform;
badLoop();
});
}

これをやるとどうなるか? 60fpsなら1秒間に60回、1分間で3,600回ものゴミ(Garbage)がヒープメモリ上に生成される。V8エンジンなどのモダンJSエンジンは優秀だが、不要になったオブジェクトの回収(GC)には少なからずCPUリソースを消費する。

GCが走った瞬間にメインスレッドが数ミリ秒〜数十ミリ秒間ストップする。これがアニメーション中の突然の「フリーズ感」の原因だ。

堅牢な実装:メモリの使い回し(Object Pooling)と状態の分離

プロのアーキテクトなら、メモリ割り当て(Allocation)をレンダリングループの「外」に追い出し、フレーム内でのアロケーションを完全にゼロにする。

// 【推奨パターン】メモリ効率とパフォーマンスを極限まで高めたループ
class OptimizedAnimator {
constructor(targetElement) {
this.target = targetElement;
this.rafId = null;

// 状態変数をクラスのプロパティとして事前確保(GCを発生させない)
this.currentX = 0;
this.targetX = 0;

// バインド済みのコールバックを保持して不要なクロージャー生成を防ぐ
this._update = this._update.bind(this);
}

start() {
if (!this.rafId) {
this.rafId = requestAnimationFrame(this._update);
}
}

stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}

_update(timestamp) {
// 簡易的な線形補間(LERP)による滑らかな追従
const diff = this.targetX – this.currentX;
this.currentX += diff 0.1;

// DOMの書き換えのみを行う(不要なオブジェクト生成はゼロ)
// ※ テンプレートリテラルの生成コストも最小限に抑える
this.target.style.transform = `translate3d(${this.currentX}px, 0px, 0px)`;

// 次のフレームを要求
this.rafId = requestAnimationFrame(this._update);
}

setTargetX(x) {
this.targetX = x;
}
}

// 使用例
const box = document.getElementById(‘my-box’);
const animator = new OptimizedAnimator(box);
animator.start();

// 外部からのインタラクション例
window.addEventListener(‘mousemove’, (e) => {
animator.setTargetX(e.clientX);
}, { passive: true });

このコードを見てほしい。ループが回り始めてから終わるまで、メモリの新規割り当て(`new` や `{}` の生成)が一切発生しない。V8のメモリヒープは完全に安定し、GCの介入する隙を与えない。これがプロの書くコードだ。

—

4. 非同期の競合と `cancelAnimationFrame` の重要性

SPA(Single Page Application)全盛の現代において、忘れてはならないのが「コンポーネントの破棄とrAFの競合」だ。

ユーザーがページ A からページ B に遷移した際、ページ A のアニメーションを駆動していたrAFのループが走りっぱなしになっていたらどうなるか? 存在しないDOM要素を操作しようとしてエラーになるか、最悪の場合、バックグラウンドで無駄にCPUを焼き続け、モバイル端末のバッテリーをゴリゴリ削ることになる。

さらに、短時間に何度もイベント(リサイズやスクロール)が発火した際、古いrAFリクエストをキャンセルせずに新しいものを積み上げると、フレームがズレて意図しないチラつき(Flicker)を引き起こす。

デバウンスとrAFの組み合わせによる制御

スクロールイベントなどの高頻度イベントを綺麗に捌くには、以下のようにフラグ管理と`cancelAnimationFrame`を組み合わせるのが鉄則だ。

let ticking = false;
let latestScrollY = 0;

window.addEventListener(‘scroll’, () => {
// 最新のスクロール位置だけを保持(イベントコールバック内では重い処理をしない)
latestScrollY = window.scrollY;

if (!ticking) {
// まだフレームの予約が入っていなければ予約を入れる
requestAnimationFrame(() => {
// 実際のレンダリング処理
updateHeaderStyle(latestScrollY);

// 処理が終わったらフラグを戻し、次のイベントを受け付ける準備をする
ticking = false;
});

ticking = true;
}
}, { passive: true });

function updateScrollY(scrollY) {
// DOMの読み書きをここに集約
header.style.transform = `translateY(${Math.min(scrollY, 200)}px)`;
}

このパターンは Throttle(スロットル)のrAF版 と呼ぶべきもので、ブラウザの描画能力(60fps/120fps)以上の頻度で無駄な関数実行が行われるのを完璧にブロックしてくれる。

—

5. まとめ:ブラウザと「対話」せよ

`requestAnimationFrame` は、単なる「アニメーション用の便利関数」ではない。それは、Webブラウザという巨大で複雑なレンダリングエンジンと、フロントエンドコードが唯一調和するための架け橋である。

ブラウザがいつレイアウトを計算し、いつ画面を塗り替えようとしているのか。そのタイムラインを常に頭の中に描き、メインスレッドを汚さず、メモリを無駄に食わず、Vsyncの鼓動に完璧にシンクロさせること。

そこまで意識を巡らせて書かれたコードだけが、ユーザーのデバイス上で息をするように滑らかに動く、真に堅牢なWebアプリケーションを生み出すのだ。

さあ、今夜は自分の書いたコードのレンダリングループを見直しに行こうか。ブラウザが軽快に唸り声を上げる音が、君にも聞こえるはずだ。

コメント

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