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

やあ、お疲れ様。今日も元気にDOMと格闘してるかい?

後輩くんから「なんかアニメーションがカクつくんです……CSSで頑張ってるんですけど」っていう相談をよく受けるんだよね。で、コードを見せてもらうと、大体犯人は `setInterval` や `setTimeout` で無理やりゴリゴリDOMをいじってるパターンか、あるいは「とりあえずJSで動かそうぜ」って言ってアホみたいに重い処理を毎フレーム回してるケースだ。

おいおい、ちょっと待てと。君が使っているそのモダンなWebブラウザ、実は裏側でめちゃくちゃシビアなタイムテーブルを刻んで動いてるんだ。今回は、そのブラウザの心臓部である「レンダリングパイプライン」と `requestAnimationFrame`(rAF)の切っても切れない関係について、実務の現場で即座に役立つ知見を交えて徹底的に解説してやろう。

ここを理解しているかどうかで、君が書くコードの「滑らかさ」が一段も二段も変わってくる。心して聞いてくれ。

—

1. なぜ `setTimeout` や `setInterval` でアニメーションを作ると地獄を見るのか

まず、敵を知ることから始めよう。
「アニメーション=一定間隔で画面を更新する」という発想から、`setInterval(fn, 16)` なんてコードを書いたことはないかい?「1秒間に60回(60fps)、つまり約16ミリ秒ごとに動かせば滑らかになるはずだ」という論理だ。一見すると筋が通っているように思える。

だが、これが大間違いの元凶なんだ。

JavaScriptのタイマー系API(`setTimeout`, `setInterval`)は、ブラウザの「画面を再描画するタイミング(リフレッシュレート)」とは完全に独立して動いている。つまり、ブラウザが「今から画面を描き直すぜ!」という瞬間と、タイマーが発火する瞬間が、綺麗にズレる。

これをやると何が起きるか?

1. フレームのスキップ(カクつき): 16msごとにタイマーが発火しても、ブラウザの描画タイミング(例: 60Hzなら約16.6msごと)と同期しないため、運悪く描画サイクルの「直後」にタイマーが発火すると、次の描画まで何も起きない。結果、同じフレーム内で何度もDOMを書き換えたり、逆に描画に間に合わずにフレームが丸ごとドロップ(コマ落ち)する。
2. 無駄なCPU/GPUの消耗: ブラウザが最小化されていたり、裏のタブに隠れてユーザーに見えていない状態でも、`setInterval` は容赦なくバックグラウンドで回り続ける。バッテリーはグソグソに削られ、ファンは唸りを上げる。これじゃユーザーに嫌われても文句は言えない。

ブラウザのレンダリングエンジンは、私たちが適当に打ったタイマーに合わせて待ってくれたりはしない。私たちがブラウザの「描画の呼吸」に合わせてコードを差し込む必要があるんだ。そのための唯一にして最強の切札が `requestAnimationFrame` なんだよ。

—

2. ブラウザの裏側:レンダリングパイプラインとrAFの真実

では、`requestAnimationFrame` を使うと、ブラウザの裏側で何が起きているのか?
ここを解剖してみよう。Webブラウザ(ChromiumやSafariなど)は、大体以下のようなサイクル(通称:ピクセルパイプライン)を1秒間に60回(または120回)のペースで回そうと必死こいている。

[ VSync (垂直同期シグナル) ]
↓
1. JavaScriptの実行 (ここで rAF のコールバックが呼ばれる!)
2. スタイル計算 (Style)
3. レイアウト / リフロー (Layout)
4. ペイント (Paint)
5. コンポジット / 合成 (Composite)
↓
[ 次の VSync まで待機 ]

このサイクルの最上位にいるのが、ハードウェアのディスプレイから送られてくる VSync(垂直同期シグナル) だ。ブラウザはこのシグナルを合図に「さあ、新しい1フレームを描くぞ!」と号令をかける。

`requestAnimationFrame(callback)` を呼ぶと、ブラウザに対してこう宣言することになる。
> 「次の描画(VSync)が始まる直前のタイミングで、このコールバック関数を絶対に実行してくれ!」

これにより、「JSによる状態の更新」と「ブラウザの描画処理」が完璧に同期する。
余計なレイアウト計算(リフロー)が1フレーム内で複数回走るような無駄なスラッシング(Thrashing)を防ぎ、GPUが最も仕事しやすい状態で画面をピクセルに変換できる。これが、rAFがカクつきを防ぎ、圧倒的な滑らかさを生み出すメカニズムの正体というわけだ。

—

3. 実践:現場で使える「完璧なアニメーションループ」の書き方

理屈は分かったな? じゃあ、実務でそのままコピペして使える、堅牢でクリーンなアニメーション制御のボイラープレートを授けよう。

ただ単純に rAF を呼ぶだけだと、タブを切り替えたときに暴走したり、処理落ちしたときにアニメーションがスピードダウンしたりといった「実務ならではの罠」にハマる。それを綺麗にクリアした実装がこれだ。

/

  • 堅牢なrequestAnimationFrame管理クラス(またはモジュール)
  • 画面のチラつき、タブ非活性時のリソース無駄遣い、フレームレートのブレを完全制御する

/
class SmoothAnimator {
constructor(renderCallback) {
this.renderCallback = renderCallback;
this.rafId = null;
this.isRunning = false;
this.lastTime = 0;

// バインドしてコンテキストを固定
this.loop = this.loop.bind(this);
}

// アニメーションループの本体
loop(currentTime) {
if (!this.isRunning) return;

// 初回フレームの初期化
if (!this.lastTime) {
this.lastTime = currentTime;
}

// 前回のフレームからの経過時間(デルタタイム)を計算
// これにより、端末の性能差によるアニメーション速度のズレを防ぐ
const deltaTime = currentTime – this.lastTime;

// ユーザーに渡す描画処理を実行
// デルタタイムを渡すことで、px/sec などの物理ベースの計算が可能になる
this.renderCallback(deltaTime, currentTime);

// 次のフレームを予約
this.lastTime = currentTime;
this.rafId = requestAnimationFrame(this.loop);
}

// アニメーション開始
start() {
if (this.isRunning) return;
this.isRunning = true;
this.lastTime = 0; // 再開時に時間をリセット
this.rafId = requestAnimationFrame(this.loop);
}

// アニメーション停止
stop() {
if (!this.isRunning) return;
this.isRunning = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
}

// — 【使用例】 実際にDOM要素を滑らかに動かしてみる —

// 対象のDOM要素を取得
const boxElement = document.querySelector(‘.js-target-box’);
let currentPositionX = 0;
const speedPxPerSec = 200; // 1秒間に進むピクセル数(物理ベース)

// アニメーション中に毎フレーム呼ばれるロジック
const myAnimationLogic = (deltaTime) => {
// デルタタイム(ミリ秒)を秒に変換して移動量を算出
// これで、仮に60fpsから30fpsに落ちても、移動の滑らかさは維持される(進み方は等速)
const deltaSec = deltaTime / 1000;
currentPositionX += speedPxPerSec deltaSec;

// 画面端に行ったら折り返す簡易的なロジック
if (currentPositionX > window.innerWidth – 100) {
currentPositionX = 0;
}

// transform を使ってGPUアクセラレーションを効かせる(レイアウトを発生させない!)
boxElement.style.transform = `translateX(${currentPositionX}px)`;
};

// インスタンス化してスタート
const animator = new SmoothAnimator(myAnimationLogic);
animator.start();

// 【実務での配慮】ユーザーが別タブに移動した時はリソースを節約する
document.addEventListener(‘visibilitychange’, () => {
if (document.hidden) {
animator.stop(); // 裏タブではCPUを解放!
console.log(‘タブが非活性になったため、アニメーションを一時停止しました。’);
} else {
animator.start(); // 戻ってきたら再開
console.log(‘タブがアクティブになったため、アニメーションを再開します。’);
}
});

このコードの泥臭いこだわりポイント

1. デルタタイム(Delta Time)の導入:
`currentPositionX += speed 1` のように固定値で足していくと、低スペック端末でフレームレートが落ちた時にアニメーションがスローモーションになってしまう。経過時間をミリ秒単位で測り、そこに掛け合わせることで、「どんな環境でも同じ速度で進む」という物理演算の基本を実装している。
2. `visibilitychange` イベントの活用:
先ほど言った「裏タブでのリソース無駄遣い」をここで完全に防いでいる。`document.hidden` を監視して `cancelAnimationFrame` を叩く。これだけで、ユーザーのPCのバッテリー持ちが劇的に変わり、社内レビューでも「お、ちゃんとわかってるじゃん」と言われるポイントだ。
3. `transform` プロパティの選定:
`left` や `top` をイジると、ブラウザは「レイアウト(リフロー)」からやり直す羽目になり、メインスレッドが悲鳴を上げる。`transform: translateX()` なら、ブラウザは「合成(コンポジット)」フェーズだけで処理を完結させられるため、GPUの力を最大限に引き出せる。

—

4. シニアからの最後の教え:rAFの濫用は禁物

ここまで `requestAnimationFrame` の素晴らしさを語ってきたが、最後にプロとしての戒めを一つ。

「なんでもかんでも rAF で包めばいいというわけではない」

例えば、スクロールイベント(`scroll`)やリサイズイベント(`resize`)のハンドラ内で、DOMの状態を読み取ってすぐ書き換えるようなコードを素朴に書くと、あっという間にレイアウトスラッシング(強制同期レイアウト)の罠にハマる。
スクロールイベント自体はブラウザの別のタイミングで発火するため、そこに rAF を挟んで間引き(Throttling)するのは定石テクニックの一つではあるが、「アニメーションを滑らかに動かす目的以外で、無理やり非同期のスケジュール管理にrAFを持ち出していないか?」という視点は常に持っておいてほしい。

CSSで完結するアニメーションは極力 CSS Transitions / Animations に任せ、JSの制御が必要な複雑なインタラクションやゲームループ、スクロール連動のカスタムエフェクトにおいてのみ、この `requestAnimationFrame` という剣を抜く。

この使い分けができるようになれば、君も立派な「ブラウザの機嫌を取れるフロントエンド・エンジニア」だ。
さあ、エディタに戻って、そのカクつくアニメーションをバターのように滑らかに書き換えてやろうぜ!

コメント

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