💬

ReactでrequestAnimationFrameを扱う

2021/11/19に公開

概要

アニメーションでよく使用されるrequestAnimationFrame。非常に便利で使いやすいメソッドなのですが、Reactで扱うには少々癖があったので、今回はそのことについて書いていこうと思います。

完成形

まずは今回解説するコードの完成形。

STARTボタンを押すとカウントの実行。STOPボタンを押すとカウントの停止ができます。シンプルなカウンターですが、この実装を完成として、ここまでの過程を考えていきます。

requestAnimationFrame

まずrequestAnimationFrameでループする簡単な使い方を紹介します。

const loop = () => {
  // 処理
  requestAnimationFrame(step);
};
// ループの実行
loop();

よくみる形ですね。コールバックで再帰的に関数を実行することでループを実現しています。ループを止めるためには再帰呼び出しを止めるか、cancelAnimationFrameを使います。

https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame
https://developer.mozilla.org/ja/docs/Web/API/Window/cancelAnimationFrame

再帰呼び出しを止めるパターン

let count = 0;
const loop = () => {
  // 処理
  if (count < 200) {
    // count が 200 より小さい時だけ
    requestAnimationFrame(loop);
  }
  count++;
};
// ループの開始
loop();

cancelAnimationFrame の使用

let reqid;
let count = 0;
const loop = () => {
  reqid = requestAnimationFrame(loop);
  // 処理
  if (count >= 200) {
    // count が 200 より小さい時だけ
    cancelAnimationFrame(reqid);
  }
  count++;
};
// ループの開始
loop();

ReactでrequestAnimationFrameを使用する

順を追って実装します。

シンプルに実装してみる

まずはシンプルにReactに埋め込んでみます。

const Component = () => {
  const loop = () => {
    // ループしたい処理
    requestAnimationFrame(loop);
  };
  React.useEffect(() => {
    loop();
  }, [loop]);

  //...略
};

一見良いように見えますが、コンポーネントが破棄されたときにループを止める必要がありますね。したがって、useEffectのクリーンアップ関数としてcancelAnimationFrameを使います。

しかし、cancelAnimationFrameを使うためにはrequestAnimationFrameから返されるrequestIDが必要です。requestIDを保持するためにuseRefを使用しましょう。

const Component = () => {
  const reqIdRef = React.useRef();
  const loop = () => {
    // ループしたい処理
    reqIdRef.current = requestAnimationFrame(loop);
  };
  React.useEffect(() => {
    loop();
    return () => cancelAnimationFrame(reqIdRef.current);
  }, []);

  //...略
};

完璧ですね。

再描画を考える

では、次にフレームカウンターを実装してみましょう。

const Component = () => {
  const reqIdRef = React.useRef();
  let counter = 0;

  const loop = () => {
    reqIdRef.current = requestAnimationFrame(loop);
    counter++;
  };

  React.useEffect(() => {
    loop();
    return () => cancelAnimationFrame(reqIdRef.current);
  }, []);

  return <div>{counter}</div>;
};

実際に上記を実装してみるとcounterが更新されません。理由としては単純でuseRefは再描画をトリガーしないからです。解決策として、useStateを使います。

const Component = () => {
  const reqIdRef = React.useRef();
  const [counter, setCounter] = React.useState(0);

  const loop = () => {
    reqIdRef.current = requestAnimationFrame(loop);
    setCounter(pre => ++pre);
  };

  React.useEffect(() => {
    loop();
    return () => cancelAnimationFrame(reqIdRef.current);
  }, []);

  return <div>{counter}</div>;
};

フレームカウンターが実装できました。

Hooksに切り出してみる

このままでは少々煩わしいのでHooksに切り出してみます。

// ループで実行したい処理 を callback関数に渡す
const useAnimationFrame = (callback = () => {}) => {
  const reqIdRef = React.useRef();
  const loop = () => {
    reqIdRef.current = requestAnimationFrame(loop);
    callback();
  };

  React.useEffect(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(reqIdRef.current);
  }, []);
};

const Component = () => {
  const [counter, setCounter] = React.useState(0);

  useAnimationFrame(() => {
    setCounter(prevCount => ++prevCount);
  });

  return (
    <div>
      <div>{counter}</div>
    </div>
  );
};

だいぶスッキリしましたね。機能としてはこれで完成です。

パフォーマンスを考えてみる

リファクタリングとまでは言わないですが、パフォーマンスの点で気になるところがあるので書き換えてみます。

// ループで実行したい処理 を callback関数に渡す
const useAnimationFrame = (callback = () => {}) => {
  const reqIdRef = React.useRef();
  // useCallback で callback 関数が更新された時のみ関数を再生成
  const loop = React.useCallback(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    callback();
  }, [callback]);

  React.useEffect(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(reqIdRef.current);
    // loop を依存配列に
  }, [loop]);
};

全体としてはこうなります。

const useAnimationFrame = (callback = () => {}) => {
  const reqIdRef = React.useRef();
  const loop = React.useCallback(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    callback();
  }, [callback]);

  React.useEffect(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(reqIdRef.current);
  }, [loop]);
};

const Component = () => {
  const [counter, setCounter] = React.useState(0);

  // setCounter するたびに関数を再生成するのを防ぐ
  const countUp = React.useCallback(() => {
    setCounter(prevCount => ++prevCount);
  }, []);
  useAnimationFrame(countUp);

  return (
    <div>
      <div>{counter}</div>
    </div>
  );
};

若干冗長に見えますが、上記でムダな関数の再生成が防げるようになりました。

STARTとSTOPボタンを作る

それでは完成形のようにSTARTとSTOPボタンを実装します。

// 第一引数に boolean をとるように修正
// - true ならループ
// - false なら停止
const useAnimationFrame = (isRunning, callback = () => {}) => {
  const reqIdRef = React.useRef();
  const loop = React.useCallback(() => {
    if (isRunning) {
      // isRunning が true の時だけループ
      reqIdRef.current = requestAnimationFrame(loop);
      callback();
    }
    // isRunning も依存配列に追加
  }, [isRunning, callback]);

  React.useEffect(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(reqIdRef.current);
  }, [loop]);
};

const Component = () => {
  const [counter, setCounter] = React.useState(0);
  const [isRunning, setIsRunning] = React.useState(false);

  const countUp = React.useCallback(() => {
    setCounter(prevCount => ++prevCount);
  }, []);
  useAnimationFrame(isRunning, countUp);

  return (
    <div>
      <div>{counter}</div>
      <button onClick={() => setIsRunning(true)}>START</button>
      <button onClick={() => setIsRunning(false)}>STOP</button>
    </div>
  );
};

引数に isRunning をとるようにhooksを変更し、trueならループ実行、falseならループ停止を実装しました。これで最初に載せたコードの完成となります!

少し応用

ただカウントアップするだけではつまらないので、一例として数字ルーレットアニメーションを実装してみました。少し応用するだけで数字ルーレットアニメーションを簡単に実装できますので、使ってみたい方は参考にしてみてください。

まとめ

ReactにおいてrequestAnimationFrameを扱う方法でした。その性質上、パフォーマンスを考えるとだいぶ癖があるように感じました。諸々検討した上で、問題が発生しないように使用できると良いですね。

参考

https://css-tricks.com/using-requestanimationframe-with-react-hooks/
https://bom-shibuya.hatenablog.com/entry/2020/10/27/182226

Discussion