requestAnimationFrameとブラウザの描画リズム同期
アニメーションがカクつく理由と、滑らかに動かすコツがはっきり分かる。rAFが描画直前に走る仕組み・vsync同期・フレーム落ちに強い時間ベース設計を解説します。
- 1.requestAnimationFrameのコールバックは、イベントループの各反復でマイクロタスク消化後・スタイル計算とペイントの直前にまとめて実行される。だから1フレームにつき1回、描画と歩調を合わせて走り、setTimeoutのような自由なタイマーより無駄な再描画を出さない。
- 2.フレームの生成リズムはディスプレイのvsync(垂直同期)に揃えられ、60Hzなら約16.7ms間隔。rAFはこの間隔の先頭付近で呼ばれるため、1フレーム分の処理時間を超える長タスクがメインスレッドを占有するとrAFが回らず、フレーム落ち(ジャンク)になる。
- 3.コールバックは引数として高精度のタイムスタンプ(DOMHighResTimeStamp)を受け取る。これを使い「経過時間×速度」で位置を決める時間ベース設計にすると、フレームが落ちても見かけの速度が一定に保たれ、端末や負荷の差に左右されない。
rAFは「描画の直前」に呼ばれる
requestAnimationFrame(以下 rAF)は、登録したコールバックを次のフレームを描く直前に1回だけ呼んでもらうAPIです。重要なのは呼ばれるタイミングで、これはイベントループのアルゴリズムに組み込まれています。1反復ごとにブラウザは「マクロタスクを1個実行 → マイクロタスクを空になるまで全消化 → 必要ならレンダリング」を回しますが、rAFのコールバックはこの最後のレンダリング段の入口、すなわちスタイル計算・レイアウト・ペイントの直前でまとめて実行されます。順序の土台は イベントループの内部構造 を前提にします。
この配置には明確な意味があります。コールバックがDOMやスタイルを書き換えても、その結果は同じフレームのレンダリング段でそのまま画面へ反映されるため、書き換えと描画が1フレーム内で完結します。setTimeout(fn, 16) のように描画と無関係なタイマーで動かすと、書き換えとフレーム生成の位相がずれ、1フレームに2回走って無駄になったり、逆に間に合わず飛んだりします。
Promise.then や queueMicrotask はマイクロタスクキューに入り、現在のタスク直後に必ず消化されます。rAFはこれとは別のアニメーションフレームコールバックのリストに入り、各反復のレンダリング段でしか走りません。つまり「マイクロタスクを全部片付けた後・ペイントの前」という1フレームに1回の枠で実行されます。同じ反復内で複数回 rAF を登録しても、それらは次のフレームで一括して順に呼ばれます。
vsyncとフレーム間隔の同期
画面は一定周期で書き換えられます。ディスプレイが内部バッファを走査して表示を更新し終える区切りが垂直同期(vsync)で、リフレッシュレートが60Hzなら約16.7ms(1000 / 60)ごと、120Hzなら約8.3msごとに訪れます。ブラウザのフレーム生成はこのvsyncに合わせて駆動され、rAFのコールバックは各フレームの先頭付近で呼ばれます。
なぜvsyncに揃えるかというと、表示更新の区切りと無関係なタイミングで描き換えても、その更新は次の走査まで画面に出ず、間隔より細かく描いても見えないからです。vsyncより速く描けば無駄、遅ければ取りこぼし。だから「画面が更新できる瞬間に1回だけ、最新状態を用意する」のが合理的で、rAFはそのための入口になっています。
| 駆動方法 | 呼ばれる間隔 | vsyncとの関係 | アニメーション用途 |
|---|---|---|---|
| requestAnimationFrame | 1フレームに1回 | フレーム生成に同期 | 適切(描画直前に最新状態) |
| setTimeout / setInterval | 指定ms(最低4ms) | 無関係(位相がずれる) | 不適切(重複・取りこぼし) |
| バックグラウンドのrAF | 停止または間引き | タブ非表示時に抑制 | 省電力で自動的に止まる |
リフレッシュレートは端末で異なるため、rAFの呼び出し間隔も一定ではありません。60Hzで約16.7ms、120Hzで約8.3ms、可変リフレッシュレート(VRR)ならさらに揺れます。間隔を16.7ms固定だと決め打ちしてはいけない理由がここにあり、後述の時間ベース設計が必須になります。
タブが背面に回ると、ブラウザはrAFのコールバックを停止または大幅に間引きます。setInterval は背面でも(間引きつつ)動き続けるのと対照的です。これは省電力上の利点で、画面に出ていないアニメーションを律儀に描かずに済みます。ただし「rAFは常に一定間隔で来る」前提のロジックは背面復帰時に破綻しうるため、経過時間に基づく更新にしておくと安全です。
長タスクによるフレーム落ち(ジャンク)
rAFが規則正しく呼ばれる前提は、メインスレッドが空いていることです。スタイル計算・レイアウト・ペイント・JS実行はすべて同じメインスレッド上で直列に走るため、1つの長タスクがスレッドを占有すると、その間はrAFも回りません。
フレームの予算は単純です。60Hzなら1フレームあたり約16.7ms。この中でJS実行・スタイル・レイアウト・ペイントを終えてフレームを提出できなければ、そのvsyncに間に合わず、画面は前のフレームのまま据え置かれます。これが**フレーム落ち(dropped frame)/ジャンク(jank)**です。
理想(60Hz, 予算16.7ms):
|--rAF--style--layout--paint--| 提出 |--rAF--...--| 提出 |--...
↑各フレームでrAFが呼ばれ、予算内に提出 → 滑らか
長タスクで詰まる:
|======= 50ms の同期処理(巨大ループ等)=======| ...rAF...
↑この間vsyncを2〜3回跨いでもrAFは呼ばれず、画面は据え置き → カクつき
// 悪い:rAF内で重い同期処理。フレーム予算を食い潰しジャンクの原因になる
function frame() {
heavyLayoutThrash(); // 同期で数十ms。ここでフレームを落とす
requestAnimationFrame(frame);
}
// 良い:重い計算はrAFの外(アイドル時やWorker)へ逃がし、rAF内は描画更新だけ
function frame(now) {
applyPrecomputedState(now); // 軽い反映のみ
requestAnimationFrame(frame);
}
rAF内で left / top / width を書き換えると毎フレームレイアウトが走り、background 等を変えればペイントが走ります。これらは予算を大きく食います。一方 transform / opacity はレイアウトもペイントも起こさずコンポジタ側で完結するため、メインスレッドが多少詰まっても落ちにくくなります。プロパティごとのコストは リフローとリペイントのコスト と レンダリングパイプライン詳説 を参照してください。
メインスレッドを長く占有するタスクは、ユーザー操作への反応性(INP)も悪化させます。フレーム落ちと入力遅延は同じ「メインスレッドの混雑」という根を持つため、計測の観点は Core Web Vitalsの計測アルゴリズム と合わせて捉えると一貫します。
時間ベース設計でフレーム落ちに耐える
フレーム間隔が一定でない以上、「1フレームごとに +1px 動かす」ようなフレーム数ベースの更新は破綻します。120Hzでは60Hzの倍速になり、フレームが落ちれば見かけが遅くなる。正しくは経過時間ベースで位置を決めます。
rAFのコールバックは引数として高精度のタイムスタンプ(DOMHighResTimeStamp、ミリ秒・小数あり、performance.now() と同じ時間基準)を受け取ります。これを使い、前フレームからの経過時間 dt を出して「速度 × 経過時間」で進めれば、フレーム間隔や落ちに依存せず見かけの速度を一定に保てます。
let prev = null;
const SPEED = 0.2; // px / ms(= 200px/秒)
function frame(now) {
if (prev !== null) {
const dt = now - prev; // 前フレームからの実経過時間(ms)
x += SPEED * dt; // フレーム間隔に依存せず一定速度
}
prev = now;
el.style.transform = `translateX(${x}px)`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
この設計なら、60Hzでも120Hzでも、途中で1〜2フレーム落ちても、同じ時間で同じ距離進みます。落ちたフレームの分だけ次の dt が大きくなり、位置が一段とびに進むためです。フレーム数で数える実装だと、落ちた分は単純に遅れてしまいます。
タブを背面に置いて戻すと、その間 rAF が止まっていたため復帰後の最初の dt が数百ms〜数秒になることがあります。これを無加工で「速度 × dt」に入れると、物体が壁をすり抜ける(衝突判定をまたぐ)などの破綻が起きます。対策として dt に上限を設けてクランプする、または固定タイムステップで物理を複数回刻むのが定石です。dt = Math.min(dt, 50) のような上限が実務でよく使われます。
CSSの transition / animation を使えば、これらの時間管理とコンポジタ最適化はブラウザが自動で行います。JS制御が必要な場合(物理・ゲーム・スクロール連動など)にrAFを使い、上記の時間ベース設計を徹底するのが原則です。合成の最適化は ブラウザのレイヤー化とGPUコンポジット を併読してください。
まとめ
rAF問では、(1) コールバックがイベントループの各反復でマイクロタスク消化後・ペイント直前に1フレーム1回実行されること、(2) フレーム生成がvsyncに同期し間隔は端末依存(60Hzで約16.7ms)であること、(3) 長タスクがメインスレッドを占有するとrAFが回らずフレーム落ち(ジャンク)になること、(4) コールバック引数の高精度タイムスタンプで経過時間ベースに設計すれば間隔差やフレーム落ちに耐えられること、の4点が頻出です。
requestAnimationFrame のコールバックは、イベントループの各反復でマイクロタスクを消化した後・スタイル計算とペイントの直前に、1フレームにつき1回だけ実行されます。フレーム生成はvsyncに同期し、間隔はリフレッシュレート依存(60Hzで約16.7ms)です。1フレームの予算を超える長タスクがメインスレッドを占有するとrAFは呼ばれずフレームが落ちます。対策は2つで、rAF内の処理を軽く保ち重い計算を外へ逃がすこと、そしてコールバックが受け取る高精度タイムスタンプから経過時間を求めて「速度 × 経過時間」で更新する時間ベース設計にすることです。順序の根拠は イベントループの内部構造、描画コストは レンダリングパイプライン詳説 と リフローとリペイントのコスト で押さえると、原理から実装まで一本の筋で理解できます。
Web/フロントエンド Article
requestAnimationFrameとブラウザの描画リズム同期を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
アニメーション
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
フレームの生成リズムはディスプレイのvsync(垂直同期)に揃えられ、60Hzなら約16.7ms間隔。rAFはこの間隔の先頭付近で呼ばれるため、1フレーム分の処理時間を超える長タスクがメインスレッドを占有するとrAFが回らず、フレーム落ち(ジャンク)になる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「アニメーション / ブラウザ」に近いか確認する。
- 強みである「requestAnimationFrameのコールバックは、イベントループの各反復でマイクロタスク消化後・スタイル計算とペイントの直前にまとめて実行される。だから1フレームにつき1回、描画と歩調を合わせて走り、setTimeoutのような自由なタイマーより無駄な再描画を出さない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。