こんにちは。チームのコードレビューをしていて、`requestAnimationFrame` の使い方や、何となく実装したアニメーションがカクつく(スタッタリングする)現象に直面し、頭を抱えたことはないでしょうか。
「ローカルのハイスペックマシンでは滑らかなのに、なぜか実機や一般的なオフィス環境だと引っかかる」
「CSSアニメーションをバリバリ書いているのに、スクロールが重い」
こうしたフロントエンドのパフォーマンスの悩みの根底には、大抵の場合、Webブラウザのレンダリングパイプラインと、ハードウェア(ディスプレイ)のV-Sync(垂直同期)との静かなる戦いが隠されています。
今回は、ブラウザが裏側でピクセルを画面に映し出すまでに何を考え、どんなハードウェアの制約と戦っているのか。その深層を、実務的な視点を交えて徹底的に解説します。
—
1. ディスプレイの「鼓動」とV-Syncの正体
まず、私たちが普段何気なく見ているディスプレイの仕組みからおさらいしましょう。
一般的なディスプレイは、上から下へ、左から右へと、ものすごい速度(例えば60Hzなら1秒間に60回、144Hzなら144回)で電子ビームやピクセルの書き換えを行っています。この書き換えのサイクルのことをリフレッシュレートと呼び、60Hzであれば約16.6msごとに1回、画面がリフレッシュされています。
ここで問題になるのが、「画面が書き換わっている真っ最中に、ブラウザが勝手に裏のフレーム(描画データ)を書き換えたらどうなるか?」という点です。
結果として何が起きるかというと、画面の上半分は古いフレーム、下半分は新しいフレームが表示されるという、画面が盛大にズレる現象――「ティアリング(Tearing)」が発生します。ユーザー体験としては最悪の部類です。
これを防ぐための防波堤が V-Sync(垂直同期:Vertical Synchronization) です。
V-Syncが有効な環境下では、ディスプレイが「次のフレームを描き始めていいよ」というタイミング(これを V-Sync 信号、あるいはバーチャルブランク期間と呼びます)を出すまで、GPUやOS、そしてブラウザは描画の反映を待たされます。
[ディスプレイの更新タイミング] |– 16.6ms –|– 16.6ms –|– 16.6ms –|
[V-Sync 信号] ^ ^ ^
[ブラウザのフレーム生成] === 処理 ===> | (待機) === 処理 ===>
フロントエンドエンジニアである私たちが意識すべきなのは、「私たちの書いたJavaScriptやCSSのレンダリングは、このディスプレイの鼓動(V-Sync)のテンポに強制的に従わされている」という厳然たる事実です。
—
2. ブラウザ内部で何が起きているか?(レンダリングのタイムライン)
60Hzのディスプレイ環境下において、ブラウザに与えられた時間は1フレームあたりわずか「16.6ミリ秒」です。この一瞬の間に、ブラウザは以下の途方もないタスクをこなす必要があります。
1. JavaScriptの実行: イベントハンドラやタイマー処理、状態変化の計算。
2. スタイルの再計算 (Recalculate Style): DOMに対するCSSセレクタの適用。
3. レイアウト (Layout / Reflow): 各要素のサイズや位置の計算。
4. ペイント (Paint): テキストや色の塗りつぶし、影などの描画コマンドの生成。
5. 合成 (Composite): レイヤーを重ね合わせ、GPUに転送する準備。
もし、この一連のパイプライン処理が16.6msを超えてしまったらどうなるでしょうか?
例えば、処理に25msかかったとしましょう。最初のV-Syncのタイミングには間に合いません。ブラウザは次のV-Sync(つまり、さらに16.6ms後、合計約33.3ms後)まで描画を諦めて待つことになります。
これが、「フレームドロップ(コマ落ち)」であり、ユーザーが目にする「スタッタリング(カクつき)」の正体です。滑らかに動くはずのアニメーションが、突然ガクッと止まるあの嫌な挙動です。
—
3. 実務で陥りがちな罠:タイマーアニメーションの限界
よくあるアンチパターンとして、`setInterval` や `setTimeout` を使ってアニメーションを実装しているコードを見かけます。
// 【アンチパターン】これだと確実にカクつきます
setInterval(() => {
const box = document.getElementById(‘my-box’);
let currentLeft = parseInt(box.style.left || 0, 10);
box.style.left = (currentLeft + 2) + ‘px’;
}, 1000 / 60); // 60fpsを目指して16msごとに実行を試みる
これの何がダメかと言うと、`setInterval` のタイマー発火タイミングと、ディスプレイのV-Syncのタイミングは全く同期していないからです。
ブラウザのメインスレッドが別の重い処理で埋まっているとタイマーの実行が遅延しますし、仮に正確に発火したとしても、V-Syncの直前に中途半端にDOMをいじってしまい、無駄なレイアウト計算を発生させた挙句、結局次のフレームに間に合わずにドロップする……という最悪のコンボが完成します。
—
4. 解決策:`requestAnimationFrame` を正しく使い倒す
このV-Syncの制約をクリアし、ブラウザのレンダリングエンジンと完全に歩調を合わせるための唯一にして最強のAPIが `requestAnimationFrame (rAF)` です。
rAFは、「ブラウザが次のフレームを描画する直前に、私の関数を呼んでくれ」とブラウザにお願いするための予約チケットです。これを使うことで、V-Syncのタイミングにピタリと寄り添った、無駄のないフレーム生成が可能になります。
ここでは、実務でそのまま使える、FPS(リフレッシュレート)の変動にも強靭な、美しく滑らかなアニメーション制御のサンプルコードを紹介します。
/
- 高度なフレーム制御を持つアニメーションループの例
- 異なるリフレッシュレート(60Hz, 120Hz, 144Hz等)の環境でも
- 経過時間をベースに移動量を計算するため、速度が一定に保たれます。
/
class SmoothAnimator {
constructor(elementId, speedPxPerSec = 300) {
this.element = document.getElementById(elementId);
this.speed = speedPxPerSec; // 1秒あたりの移動ピクセル数
this.currentX = 0;
this.lastTimestamp = null;
this.animationFrameId = null;
// バインドして参照を保持
this.loop = this.loop.bind(this);
}
start() {
if (this.animationFrameId) return;
this.lastTimestamp = performance.now();
this.animationFrameId = requestAnimationFrame(this.loop);
}
stop() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.lastTimestamp = null;
}
loop(timestamp) {
// 初回フレームのデルタタイム(経過時間)算出
const deltaTime = (timestamp – this.lastTimestamp) / 1000; // 秒単位に変換
this.lastTimestamp = timestamp;
// 前回のフレームからの経過時間 × 速度 で移動量を決定
// これにより、120Hz環境でも60Hz環境でも移動スピードが一定になる
this.currentX += this.speed deltaTime;
// 画面外に行ったらループさせるなどの処理
if (this.currentX > window.innerWidth) {
this.currentX = -100;
}
// DOMの更新(極力CSSのtransformを使い、レイアウトを回避する)
// ※実際のプロジェクトではCSS Variablesやtransformを推奨
this.element.style.transform = `translateX(${this.currentX}px)`;
// 次のフレームの描画直前を予約
this.animationFrameId = requestAnimationFrame(this.loop);
}
}
// — 実務での利用例 —
// 1. HTML側に
を用意
// 2. インスタンス化してスタート
// const animator = new SmoothAnimator(‘my-box’, 400);
// animator.start();
このコードのシニア的解説ポイント
1. `performance.now()` の活用: `Date.now()` などのシステム時計ではなく、ミリ秒単位の高精度かつ単調増加するタイマーを使うことで、正確なデルタタイム(フレーム間の時間差)を算出し、リフレッシュレートの差異(60Hz vs 144Hzなど)による速度のバラつきを完全に吸収しています。
2. `transform` プロパティの使用: `left` や `top` などのプロパティをイジると、ブラウザは「Layout(レイアウト再計算)」からやり直すため重くなります。`transform: translateX()` であれば、GPUによる「Composite(合成)」レイヤーだけで処理が完結するため、16.6msの壁を突破しやすくなります。
—
5. まとめ:シニアエンジニアからの実践的アドバイス
V-Syncとリフレッシュレートの仕組みを理解すると、フロントエンドのパフォーマンスチューニングに対するアプローチがガラリと変わります。
- アニメーションに `setInterval` は絶対に使わない(`requestAnimationFrame` 一択)。
- DOMのプロパティを直接いじってレイアウトを強制発火させない(`transform` や `opacity` を活用する)。
- メインスレッドをブロックする重い処理(大量のデータ計算など)は、Web Workersへ逃がす。これによって、16.6msのフレームバジェット(予算)を死守し、V-Syncのタイミングを逃さないようにする。
ブラウザの向こう側で動いているディスプレイの鼓動を感じ取りながら、ユーザーの指先に吸い付くような滑らかなUIを作り上げていきましょう。現場からは以上です、実装ファイトです!

コメント