1. ホーム
  2. JavaScript

【JavaScript】requestAnimationFrame の使い方|描画に同期したなめらかなアニメーションを作る方法

Share

数値をカウントアップさせる、スクロール位置に合わせて要素を動かす、canvas に絵を描き続ける。こうした「毎フレーム少しずつ変える」処理を setInterval() で書くと、カクついたり、ブラウザーの描画とずれて無駄な計算が走ったりします。requestAnimationFrame() はブラウザーの描画タイミングに合わせて関数を呼んでくれる仕組みで、なめらかで無駄のないアニメーションが書けます。この記事では基本の書き方から、ループの作り方、タイムスタンプを使った時間基準のアニメーション、スクロールイベントの間引きまで、動かせるデモ付きで解説します。

ブラウザーの描画に合わせて呼んでもらう

ブラウザーは、画面を1秒間に決まった回数だけ描き直しています。一般的なディスプレイなら 60 回、高リフレッシュレートの端末なら 120 回といった具合です。この描き直しの直前に呼んでほしい関数を予約するのが requestAnimationFrame() です。

main.js
// 次の描画の直前に一度だけ呼ばれる
requestAnimationFrame( ( timestamp ) => {
	console.log( timestamp ); // このフレームが始まった時刻(ミリ秒)
} );

setInterval( fn, 16 ) と比べたときの違いは、大きく3つあります。

1つ目は描画と同期することです。setInterval() の 16 ミリ秒は描画タイミングとは無関係なので、1フレームの間に2回呼ばれたり、逆に1回も呼ばれなかったりします。実際に画面に出ない計算をしたり、コマ落ちして見えたりする原因になります。requestAnimationFrame() は必ず描画の直前に1回呼ばれます。

2つ目はディスプレイに追従することです。120Hz の端末では 1 秒間に 120 回呼ばれるため、その端末なりのなめらかさが自然に出ます。setInterval() で 16 ミリ秒に固定していると、せっかくの性能を使い切れません。

3つ目は見えていないときは止まることです。タブが非アクティブになったり、ブラウザーが最小化されたりすると、コールバックは呼ばれなくなります。誰も見ていない画面のために CPU とバッテリーを使い続けることがありません。

ループにするには自分で予約し直す

requestAnimationFrame() は1回きりの予約です。setInterval() のように繰り返してはくれないので、アニメーションを続けたいならコールバックの中で再度予約します。

main.js
let x = 0;
let rafId = null;

function loop() {
	x += 2;
	box.style.transform = `translateX(${ x }px)`;

	// 終了条件を満たすまで、自分で次を予約する
	if ( x < 300 ) {
		rafId = requestAnimationFrame( loop );
	}
}

rafId = requestAnimationFrame( loop );

戻り値の rafId は予約の識別子です。途中で止めたいときは cancelAnimationFrame( rafId ) に渡します。clearInterval() と同じ関係です。

main.js
stopButton.addEventListener( 'click', () => {
	cancelAnimationFrame( rafId );
} );

タイムスタンプを使って時間基準で動かす

上の例のように「1フレームにつき 2px 進める」と書くと、端末によって速度が変わってしまいます。60Hz の端末では 1 秒で 120px 進みますが、120Hz の端末では 240px 進んでしまうからです。

これを防ぐのが、コールバックに渡されるタイムスタンプです。そのフレームが始まった時刻がミリ秒単位で入っているので、開始時刻との差から「どれだけ時間が経ったか」を求め、それをもとに位置や数値を決めます。こうすればフレームレートに関係なく、決めた時間で決めた地点に到達します。

実際に数値のカウントアップで見てみましょう。1.6 秒かけて 12,800 まで数え上げ、終わりに向かってゆるやかに減速させています。

<div class="counter">
  <p class="counter__value">0</p>
  <div class="counter__bar"><span></span></div>
  <button class="counter__start">カウントアップ開始</button>
</div>
.counter {
  font-family: sans-serif;
  text-align: center;
}

.counter__value {
  font-size: 56px;
  font-weight: bold;
  margin: 0;
  color: #2f6fed;
  font-variant-numeric: tabular-nums;
}

.counter__bar {
  height: 10px;
  background: #eee;
  border-radius: 5px;
  overflow: hidden;
  margin: 16px 0;
}

.counter__bar span {
  display: block;
  height: 100%;
  width: 0;
  background: #2f6fed;
}

.counter__start {
  padding: 10px 22px;
  border: none;
  border-radius: 6px;
  background: #2f6fed;
  color: #fff;
  font-size: 15px;
  cursor: pointer;
}
const valueEl = document.querySelector('.counter__value');
const barEl = document.querySelector('.counter__bar span');
const button = document.querySelector('.counter__start');

const GOAL = 12800;       // 目標の数値
const DURATION = 1600;    // かける時間(ミリ秒)

let rafId = null;

function start() {
  // 実行中のアニメーションがあれば止める
  cancelAnimationFrame(rafId);

  let startTime = null;

  function step(timestamp) {
    // 最初のフレームの時刻を基準にする
    if (startTime === null) {
      startTime = timestamp;
    }

    // 経過時間から進み具合(0〜1)を求める
    const elapsed = timestamp - startTime;
    const progress = Math.min(elapsed / DURATION, 1);

    // 終わりに向かってゆるやかに減速させる
    const eased = 1 - Math.pow(1 - progress, 3);

    valueEl.textContent = Math.round(GOAL * eased).toLocaleString();
    barEl.style.width = `${progress * 100}%`;

    // 1 に達するまで次のフレームを予約し続ける
    if (progress < 1) {
      rafId = requestAnimationFrame(step);
    }
  }

  rafId = requestAnimationFrame(step);
}

button.addEventListener('click', start);
Preview

コードの要は次の3行です。

main.js
const elapsed  = timestamp - startTime;                // 経過時間
const progress = Math.min( elapsed / DURATION, 1 );    // 0〜1 の進み具合
const eased    = 1 - Math.pow( 1 - progress, 3 );      // イージング

progress は 0 から 1 へ進む進捗率です。Math.min() で 1 を超えないようにしているのは、フレームの間隔によっては指定時間をわずかに過ぎてから最後のコールバックが呼ばれるためで、これが無いと目標値を通り越してしまいます。

eased はイージングです。progress をそのまま使うと一定速度(linear)になりますが、上の式を通すと終盤がゆっくりになり、CSS の ease-out に近い動きになります。Math.pow() の指数を大きくするほど減速が強くなります。

最初のフレームで基準時刻を決める

デモでは startTime を null で初期化し、最初のコールバックで timestamp を代入しています。performance.now() や Date.now() で開始時刻を取ってもよさそうですが、予約してから最初のフレームが来るまでにわずかな間があくため、そのぶん最初のフレームが飛んだように見えることがあります。渡ってくる timestamp を基準にすれば必ず 0 から始まります。

スクロールイベントの間引きにも使える

scroll や resize は1秒間に何十回も発火します。そのたびに要素の位置を計算して DOM を書き換えると、描画が追いつかず動きが重くなります。requestAnimationFrame() を挟んで、1フレームにつき1回だけ処理するようにすると軽くなります。

main.js
let ticking = false;

function onScroll() {
	// すでに次のフレームを予約済みなら何もしない
	if ( ticking ) {
		return;
	}
	ticking = true;

	requestAnimationFrame( () => {
		const y = window.scrollY;
		header.classList.toggle( 'is-fixed', y > 100 );
		ticking = false;
	} );
}

window.addEventListener( 'scroll', onScroll, { passive: true } );

ticking というフラグで、予約が済んでいる間は新しい予約をしないようにしています。スクロールイベントが 1 フレームの間に 5 回発火しても、実際の処理は 1 回だけです。{ passive: true } は「このハンドラーでは preventDefault() を呼ばない」とブラウザーに伝えるもので、スクロールの応答性が上がります。

ただし、要素が画面に入ったかどうかを見たいだけなら IntersectionObserver、要素のサイズ変化を見たいだけなら ResizeObserver のほうが適しています。スクロール量に応じて連続的に何かを変えたいときの手段として覚えておいてください。

期待どおりに動かないとき

一度しか実行されない

コールバックの中で requestAnimationFrame() を呼び直していない可能性が高いです。setInterval() と違って繰り返し実行はされません。ループにするなら、終了条件に達するまで自分で予約し続ける必要があります。

アニメーションが二重に走って速くなる

開始用のボタンを続けて押すと、ループが2本同時に走ります。それぞれが毎フレーム値を更新するので、見た目の速度が倍になります。デモのように、開始時に cancelAnimationFrame( rafId ) を呼んで前のループを止めてください。rafId が null や undefined でもエラーにはならないので、初回でも安全に呼べます。

タブを切り替えて戻すと一気に進む

非アクティブの間コールバックは止まりますが、timestamp は進み続けます。そのため、戻ってきた最初のフレームで「10 秒経過した」と判定され、アニメーションが一気に終端まで飛ぶことがあります。

デモのカウントアップのように「指定時間で終わる」ものは、終わっているのが自然なので問題ありません。ゲームのように連続して動かし続けるものでは、前フレームとの差分(デルタタイム)に上限を設けて対処します。

main.js
let prev = null;

function loop( timestamp ) {
	if ( prev !== null ) {
		// 1フレームの差分は最大 100ms までに抑える
		const delta = Math.min( timestamp - prev, 100 );
		x += speed * delta / 1000;
	}
	prev = timestamp;

	requestAnimationFrame( loop );
}

そもそも CSS で書けないか考える

単純な移動やフェードなら、transition や @keyframes、element.animate() のほうが適しています。これらはブラウザー側で最適化され、場合によってはメインスレッドを使わずに動きます。requestAnimationFrame() が要るのは、毎フレームの値を JavaScript で計算しなければならないときです。数値のカウントアップ、canvas への描画、物理演算、スクロール量に連動した処理などが該当します。

まとめ

requestAnimationFrame() は、次の描画の直前にコールバックを呼んでもらう仕組みです。描画と同期するのでコマ落ちが起きにくく、端末のリフレッシュレートに追従し、見えていないときは自動で止まります。アニメーションを JavaScript で書くなら setInterval() ではなくこちらを使ってください。

1回きりの予約なので、ループにするならコールバックの中で呼び直します。止めるときは戻り値を cancelAnimationFrame() に渡します。そして「1フレームあたり◯px」ではなく、渡ってくる timestamp から経過時間を求めて値を決めること。これだけで、どの端末でも同じ速さで動くアニメーションになります。

スクロールイベントの間引きにも使えますが、CSS で書ける動きは CSS に任せるのが基本です。毎フレームの値を JavaScript で計算する必要がある場面かどうかを見極めたうえで使ってみてください。

参考ページ