ReactでuseRefを使ってCanvasのアニメーションを作る方法

​​React​Canvas,Playwright,requestAnimationFrame,StrictMode,TypeScript,useEffect,useRef

React で Canvas のアニメーションを作るときは、<canvas> の要素と、動かすものの位置を useRef に入れます。useEffect の中で requestAnimationFrame のループを始め、後片付けで cancelAnimationFrame を呼んで止めます。こうすると、毎フレーム描き直しても React の描画は1回も起きず、止めたり外したりしたあとにループが残ることもありません。

筆者は検証環境で、枠の中を跳ね返るボールを作りました。比べるための書き方もいくつか用意し、Chromium の中で requestAnimationFrame の予約の回数、React の描画の回数、ボールの進んだ距離を数えています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、ヘッドレスの Chromium 141 を Playwright 1.56.1 で操作しました。React は開発用のビルドで動かしています。ほかのブラウザや、実機のスマホでは試していません。

ポイント

ボールの位置は JSX に出さないので、state ではなく ref に入れます。経過時間(requestAnimationFrame が渡す時刻の差)から進む量を決め、useEffect の後片付けで cancelAnimationFrame を呼びます。

アニメーションするコンポーネントの全体

検証に使ったコンポーネントです。ボールは 1秒に 120px の速さで進み、枠に当たると向きを変えます。「停止」と「再生」のボタンで、動きを止めたり再開したりできます。

tsx
import { useEffect, useRef, useState } from 'react';
import { createBall, drawBall, moveBall, setupCanvas, type Ball } from './ball';

const SPEED = 120; // 1秒に進む距離(px)

export function BouncingBall() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  // ボールの位置は JSX に出さないので、state ではなく ref に入れる
  const ballRef = useRef<Ball>(createBall());
  const [playing, setPlaying] = useState(true);

  useEffect(() => {
    const ctx = setupCanvas(canvasRef.current!);
    drawBall(ctx, ballRef.current);
    if (!playing) return;

    let id = 0;
    let last: number | null = null;
    const tick = (now: number) => {
      // 前のフレームからの経過時間(秒)。間が空きすぎたときは 0.1 秒で打ち切る
      const dt = last === null ? 0 : Math.min((now - last) / 1000, 0.1);
      last = now;
      moveBall(ballRef.current, SPEED * dt);
      drawBall(ctx, ballRef.current);
      id = requestAnimationFrame(tick); // 次のフレームを予約し直す
    };
    id = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(id); // 止めるときと外すときに、予約を取り消す
  }, [playing]);

  return (
    <div className="demo">
      <canvas ref={canvasRef} />
      <button type="button" onClick={() => setPlaying((p) => !p)}>
        {playing ? '停止' : '再生'}
      </button>
    </div>
  );
}

createBall、moveBall、drawBall、setupCanvas は、React に関係しない関数として別のファイル(ball.ts)に置きました。moveBall は、渡された距離だけボールを進め、壁に当たったら向きを変えます。drawBall は、背景を塗りつぶしてからボールを描きます。setupCanvas の中身は、最後の節で紹介します。

canvasRef は、公式リファレンスにある、ref で DOM を操作する使い方と同じです。useEffect の中では <canvas> がもう画面にあるので、canvasRef.current から描画用のコンテキストを取り出せます。

MDN の requestAnimationFrame のページによると、requestAnimationFrame は、ブラウザが次に画面を描く前にコールバックを1回だけ呼びます。続けて動かすには、コールバックの中でもう一度呼ぶ必要があります。tick の最後で予約し直しているのはそのためです。予約し直すたびに新しい ID が返るので、id を毎回上書きし、後片付けでは最後の ID を取り消します。

ボールの位置を state ではなく ref に入れる

ボールの位置を useState に入れ、フレームごとに setBall で新しい位置を渡す書き方と比べました。描画の回数は、コンポーネントを <Profiler> で囲んで数えています。どちらも、2秒のあいだに requestAnimationFrame のコールバックが 121回呼ばれました。

text
demo=ref 測り始めるまで {"requested":34,"canceled":0} {"秒":2,"フレーム数":121,"アプリの予約回数":121,"取り消し回数":0,"描画回数":0,"1秒に進んだ距離px":120}
demo=state 測り始めるまで {"requested":34,"canceled":0} {"秒":2.01,"フレーム数":121,"アプリの予約回数":121,"取り消し回数":0,"描画回数":120,"1秒に進んだ距離px":119}

1行目が、ref に入れた書き方です。ボールは 1秒に 120px 進みましたが、React の描画は 0回でした。2行目の state に入れた書き方では、見た目の動きは同じでも、フレームごとに描画し直して、2秒で 120回描画しました。公式リファレンスの useRef のページにあるとおり、ref.current を書き換えても描画し直しは起きません。canvas に描く位置は JSX で画面に出す値ではないので、ref に入れて描画を起こさないほうが、むだがありません。useRef と useState の違いそのものは、以前の記事「ReactのuseRefとuseStateを描画回数の違いで使い分ける方法」でも比べています。

ボールの位置を、useEffect の中のふつうの変数に置く書き方もあります。この場合は、止めて再生したときに違いが出ました。停止のボタンで playing が変わると、useEffect がやり直され、そのたびにボールが作り直されるためです。

text
demo=ref 最初の位置 {"x":40,"y":50}、停止したときの位置 {"x":190,"y":162}、再生した直後の位置 {"x":193,"y":164}
demo=local 最初の位置 {"x":40,"y":50}、停止したときの位置 {"x":40,"y":50}、再生した直後の位置 {"x":43,"y":52}

ref に入れたボールは、止めた位置から続けて動きました。useEffect の中の変数にしたボールは、停止を押した時点で最初の位置(x=40、y=50)に戻りました。位置は、canvas の画素からボールの色の点を探して、その中心を測っています。停止中の 0.5秒は、どちらも予約が 0回で、ボールも動きませんでした。

上下に2つの canvas があり、上はボールの位置を ref に入れた書き方、下は effect の中の変数に置いた書き方。上の「停止」を押すとボールが止まり、「再生」でその位置から動き出す。下の「停止」を押すと、位置の表示が x=40 y=50 に変わり、ボールが左上に戻る
ref に入れたボールは止めた位置から再開し、effect の中の変数にしたボールは停止で最初の位置に戻る動き

後片付けで cancelAnimationFrame を呼ぶ

useEffect から後片付けの関数を返さず、cancelAnimationFrame を呼ばない書き方と比べました。予約の回数は、ページの requestAnimationFrame と cancelAnimationFrame を、回数を数える関数で包んで数えています。まず、「表示を切り替える」のボタンでコンポーネントを外し、その後の1秒を数えました。

text
demo=ref 外したあとの canvas の数 0 {"秒":1,"フレーム数":61,"アプリの予約回数":0,"取り消し回数":0,"描画回数":0,"1秒に進んだ距離px":0}
demo=no-cleanup 外したあとの canvas の数 0 {"秒":1,"フレーム数":61,"アプリの予約回数":61,"取り消し回数":0,"描画回数":0,"1秒に進んだ距離px":0}

どちらも canvas は画面から消えています。後片付けを書かない書き方では、その後もフレームごとに予約が続き、1秒で 61回になりました。画面にない canvas に描き続けるループが、裏で残ったことになります。

StrictMode でも比べました。公式リファレンスの useEffect のページにあるとおり、StrictMode では開発中だけ、最初のセットアップの前に、セットアップと後片付けがもう1回ずつ行われます。

text
demo=ref&strict=1 測り始めるまで {"requested":34,"canceled":1} {"秒":2,"フレーム数":120,"アプリの予約回数":120,"取り消し回数":0,"描画回数":0,"1秒に進んだ距離px":120}
demo=no-cleanup&strict=1 測り始めるまで {"requested":66,"canceled":0} {"秒":2,"フレーム数":121,"アプリの予約回数":242,"取り消し回数":0,"描画回数":0,"1秒に進んだ距離px":238}

記事の書き方では、最初のループが後片付けで取り消され、残ったループは1つでした。2秒で予約は 120回、ボールの速さも 1秒に 120px です。後片付けを書かない書き方では、ループが2つ残り、予約が 242回になりました。2つのループが同じボールを進めるので、ボールの速さも 1秒に 238px と、ほぼ2倍になっています。

進む量は、経過時間から決める

tick が受け取る now は、MDN の requestAnimationFrame のページによると、前のフレームの描画が終わった時刻(ミリ秒)です。記事の書き方では、前のフレームとの差に速さを掛けて、進む距離にしています。同じページには、コールバックが呼ばれる回数はたいてい画面のリフレッシュレートに合うこと、経過時間を使わないと、リフレッシュレートの高い画面では速く動いてしまうことが書かれています。

比べるために、経過時間を使わず、1フレームごとに 2px 進める書き方も作りました。60fps なら 1秒に 120px になる値です。

tsx
const tick = () => {
  moveBall(ballRef.current, 2);
  drawBall(ctx, ballRef.current);
  id = requestAnimationFrame(tick);
};

検証環境のヘッドレスの Chromium は、どちらの書き方でも 2秒で 121フレームで、ボールの速さは 1秒に約 120px でした(記事の書き方が 120px、1フレームごとに 2px 進める書き方が 119px)。次に、Chromium に --disable-frame-rate-limit と --disable-gpu-vsync を付けて起動し、1秒あたりのフレーム数の上限(フレームレートの上限)を外して測りました。

text
demo=ref {"秒":2,"フレーム数":7602,"アプリの予約回数":7602,"取り消し回数":0,"描画回数":0,"1秒に進んだ距離px":120}
demo=per-frame {"秒":2,"フレーム数":8484,"アプリの予約回数":8484,"取り消し回数":0,"描画回数":0,"1秒に進んだ距離px":3090}

1行目の記事の書き方は、フレームの数が何十倍にも増えても、1秒に 120px のままでした。2行目の、1フレームごとに 2px 進める書き方は、2秒で 8,484フレームなので、計算では 1秒に約 8,500px 進みます。測った値は 1秒に 3,090px でした。50ミリ秒おきのボールの位置どうしの距離を足して測っているので、そのあいだに壁で何度も跳ね返ると、そのぶんを数えきれず、実際より小さく出ます。それでも、60fps のときの 20倍を超える速さでした。

dt を 0.1秒で打ち切っているのは、間が大きく空いたあとに、ボールが一度に大きく飛ばないようにするためです。MDN のページには、多くのブラウザで、裏に回ったタブや見えない iframe では requestAnimationFrame の呼び出しが止まると書かれています。この検証では、タブを裏に回したときの動きは試していません。

高解像度の画面では canvas の画素数を増やす

setupCanvas では、canvas の画素数を、画面の devicePixelRatio に合わせて増やしています。表示の大きさは CSS で 320×180px に固定し、setTransform で座標を拡大して、描くときは CSS の px のまま扱えるようにしました。

ts
// 画面の解像度(devicePixelRatio)に合わせて、canvas の画素数を決める
export function setupCanvas(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.floor(WIDTH * dpr);
  canvas.height = Math.floor(HEIGHT * dpr);
  canvas.style.width = `${WIDTH}px`;
  canvas.style.height = `${HEIGHT}px`;
  const ctx = canvas.getContext('2d')!;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 以後は CSS の px の座標で描ける
  return ctx;
}

MDN の devicePixelRatio のページにある、canvas の解像度を直す例と同じ考え方です。

MDN の同じページによると、ページを拡大・縮小すると devicePixelRatio の値も変わります。この記事のコンポーネントは、値が変わったときに canvas を作り直す処理を入れていないので、次に useEffect がやり直されるまで、canvas の画素数はそのままです。拡大・縮小したときの動きは、この検証では試していません。

Playwright で devicePixelRatio が 2 の画面を作り、合わせない書き方と比べました。

text
demo=ref {"devicePixelRatio":2,"canvas.width":640,"canvas.height":360,"表示の幅":320,"表示の高さ":180}
demo=no-dpr {"devicePixelRatio":2,"canvas.width":320,"canvas.height":180,"表示の幅":320,"表示の高さ":180}

どちらも表示の大きさは同じ 320×180px です。合わせない書き方では、canvas の画素が縦も横も画面の実際の画素の半分しかなく、引き伸ばして表示されます。

devicePixelRatio が 2 の画面で、ボールのふちを拡大した画像を上下に並べている。上の、devicePixelRatio に合わせた canvas はふちがくっきりしていて、下の、合わせない canvas はふちがぼやけている
devicePixelRatio が 2 の画面で、ボールのふちを比べた画像(上が合わせた場合、下が合わせない場合)
ポイント

canvas の要素と、ボールの位置のように JSX に出さない値は ref に入れ、ループは useEffect で始めて、後片付けの cancelAnimationFrame で止めます。進む量は経過時間から決め、canvas の画素数は devicePixelRatio に合わせます。検証環境で試したところ、この書き方では、毎フレーム描き直しても React の描画は起きませんでした。外したあとにループは残らず、StrictMode でも動くループは1つだけでした。フレームレートの上限を外してフレームの数が何十倍に増えても、ボールの速さは 1秒に 120px のままでした。devicePixelRatio が 2 の画面でも、ボールのふちはぼやけませんでした。

​Posted by 86