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

こんにちは。チームのコードレビューをしていて、未だに「アニメーションやっちゃう? じゃあ取り敢えず `setInterval` か `setTimeout` で16ミリ秒ごとにゴリゴリ動かそうか」なんてコードを見かけると、思わずコーヒーカップを置く手が止まってしまうシニアエンジニアの私です。

君たち、ブラウザの気持ちを考えたことがあるかい?

「画面をこう変えたい」という人間の欲望と、「それをいかに滑らかに、省電力で、ユーザーをイライラさせずに描画するか」というブラウザの戦いは、一筋縄ではいかないドラマなんだ。今回は、フロントエンドの中級から一段上のアーキテクトへ駆け上がるために避けて通れない、`requestAnimationFrame`(rAF)とブラウザのレンダリングサイクルの同期について、裏側の泥臭い仕組みも含めて徹底的に解説しよう。

—

なぜ `setInterval` や `setTimeout` ではダメなのか?

まず大前提として、JavaScriptのタイマー系APIは「アニメーションを描画するため」に生まれていない。`setTimeout(fn, 16)` は、「16ミリ秒(約60fps)経ったらタスクキューにコールバックをブチ込む」という命令でしかない。

ここで重要なのは、「タスクキューに入る」ことと「実際にブラウザが画面を塗り替える(描画する)」タイミングは、完全に他人事だという点だ。

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

ブラウザのメインスレッドは、常に忙しい。
1. ユーザーのクリックやスクロールイベントの処理
2. ネットワークからのデータ受信とJavaScriptの実行
3. HTMLのパースとDOMの構築

これらをこなした上で、ブラウザは一定のリズム(通常は60Hz=約16.6msごと、高リフレッシュレートなら120Hzや144Hz)で、以下のレンダリングパイプライン(更新サイクル)を回している。

[スタイル計算 (Recalculate Style)]
↓
[レイアウト構築 (Layout / Reflow)]
↓
[ペイント (Paint)]
↓
[合成 (Composite)]

もし、`setTimeout` を使って適当なタイミングでDOMを書き換えるとどうなるか?
運悪く、ブラウザの描画サイクルの「直後」にタイマーが発火してJavaScriptが走ってしまうと、次の描画サイクルが来るまで無駄に待つことになる。最悪なのは、1つの描画フレームの中で何度もDOMを書き換えるハメになり、無駄なLayout(リフロー)やPaintが発生する「フレーム落ち(Jank)」を引き起こすことだ。カクつくアニメーションの出来上がり、というわけだ。

—

救世主 `requestAnimationFrame` の仕組み

ここで登場するのが `requestAnimationFrame` だ。こいつの仕様はシンプルだが、やっていることは極めてスマート。

> 「ブラウザ君、次の画面描画の直前に、俺のこの関数を実行してくれ。そこでDOMをいじるからさ」

ブラウザは賢いので、リフレッシュレートのタイミング(60Hzなら約16.6ms、120Hzなら約8.3ms)に正確に同期して、描画の直前に登録されたコールバックを一斉に呼び出す。

さらに美しいのが、タブが非アクティブ(バックグラウンド)の時には、このサイクルが勝手に止まる(あるいは極端に頻度が落ちる)点だ。`setInterval` だと裏タブに移動しても延々とCPUを焼き続けるが、rAFならブラウザの省電力機能と完璧に協調動作してくれる。モバイルバッテリーに優しいフロントエンド、これぞプロの仕事だよね。

—

実践:美しく同期されたアニメーションの実装パターン

百聞は一見にしかず。実務でそのまま使える、綺麗で拡張性の高いアニメーションのテンプレートコードを見せよう。

ここでは、単純な「要素を滑らかに右へ移動させる」処理を、`requestAnimationFrame` を使って安全かつ正確に制御する例だ。

/

  • スムーズなアニメーションを制御するクラス
  • 実務ではコンポーネントのライフサイクルや状態管理と組み合わせて使います

/
class SmoothAnimator {
constructor(element) {
this.element = element;
this.animationId = null;
this.currentPosition = 0;
this.targetPosition = 300; // 目標位置 (px)
this.duration = 1000; // 完了までの時間 (ms)
this.startTime = null;

// 2重起動を防ぐためにコンテキストをバインドしておく
this.step = this.step.bind(this);
}

// アニメーションを開始するトリガーメソッド
start() {
if (this.animationId) return; // 既に走っていれば何もしない

this.startTime = null; // 開始時間をリセット
this.animationId = requestAnimationFrame(this.step);
}

// 毎フレーム呼ばれるメインループ
step(timestamp) {
// 初回フレーム時に開始時間を記録
if (!this.startTime) {
this.startTime = timestamp;
}

// 経過時間を計算
const elapsed = timestamp – this.startTime;
const progress = Math.min(elapsed / this.duration, 1); // 0から1に正規化

// イージング関数(今回はシンプルにイーズアウトを使用)
const easeProgress = this.easeOutQuad(progress);

// 現在の位置を計算
const currentX = this.currentPosition + (this.targetPosition – this.currentPosition) easeProgress;

// DOMの更新(この直後にブラウザのレンダリングパイプラインが走るため非常に効率的)
this.element.style.transform = `translateX(${currentX}px)`;

// まだアニメーションが完了していなければ、次のフレームを要求
if (progress < 1) { this.animationId = requestAnimationFrame(this.step); } else { // 終了処理 this.stop(); } } // 簡易的なイージング関数(加速度の緩急をつける) easeOutQuad(t) { return t (2 - t); } // アニメーションを安全に停止・クリーンアップするメソッド stop() { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } } } // --- 使い方(エディタに貼り付けてすぐに検証できます) --- / // HTML側に適当な要素がある想定 const box = document.querySelector('.my-box'); const animator = new SmoothAnimator(box); // ボタンクリックなどでアニメーション発火 document.querySelector('.start-btn').addEventListener('click', () => {
animator.start();
});
/

このコードの何が優れているのか?(シニアの視点)

1. `timestamp` の活用:
`requestAnimationFrame` はコールバックの引数に「高精度なタイムスタンプ(ミリ秒)」を渡してくれる。これを基準に経過時間を計算するため、仮にユーザーのPCが重くてフレームレートが落ちたとしても、アニメーションの「全体の再生時間(duration)」が狂うことなく、辻褄を合わせてゴールに到達できる。`setInterval` だとこうはいかない。
2. `cancelAnimationFrame` によるメモリリーク・無駄処理の防止:
コンポーネントのアンウント時や、アニメーションの途中でキャンセルが必要な場合に備えて、必ずIDを保持してキャンセルできるようにしている。
3. GPUアクセラレーションの意識:
`style.left` などをイジると毎回 Layout(リフロー)が発生してブラウザが泣く。そのため、`transform: translateX()` を使って「合成 (Composite)」フェーズだけで処理が完結するように書いている。これぞパフォーマンスチューニングの基本だ。

—

実務でハマりがちな罠とアンチパターン

最後に、現場で後輩がやりがちな「やってはいけないミス」を共有しておく。

  • レイアウト・スラッシング(Layout Thrashing)を引き起こすコード

// ❌ 最悪のアンチパターン:読み取りと書き込みを交互にやるな!
requestAnimationFrame(() => {
const height1 = element1.offsetHeight; // 読み取り (Layout強制発生)
element1.style.height = (height1 + 10) + ‘px’; // 書き込み

const height2 = element2.offsetHeight; // 読み取り (再びLayout強制発生!)
element2.style.height = (height2 + 10) + ‘px’; // 書き込み
});

ブラウザは、JavaScriptから「DOMの寸法(`offsetHeight`など)」を求められた瞬間、それまでに溜まっていた変更を無理やり計算(強制レイアウト)させられる。これを1つのフレーム内で何度もやると、CPUが悲鳴を上げる。
鉄則:「読むときはまとめて読む(Read)」、その後に「書き込む(Write)」。これを分離する癖をつけよう。

—

まとめ

`requestAnimationFrame` は、単に「アニメーションを滑らかにする魔法のAPI」ではない。それは、「WebブラウザのレンダリングエンジンとJavaScriptの世界を調和させるための作法」だ。

裏側の仕組み(スタイル計算 → レイアウト → ペイント → 合成)を理解し、ブラウザの呼吸に合わせてコードを書く。これができるようになると、君の書くフロントエンドコードは見違えるほど軽快になり、ユーザー体験(UX)の向上に直結する。

さあ、今日のデプロイが終わったら、自分の書いたコードの中にある `setTimeout` によるアニメーションを全部 `requestAnimationFrame` に書き換える旅に出ようか。健闘を祈る!

コメント

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