ReactでrequestAnimationFrameを扱う
概要
アニメーションでよく使用されるrequestAnimationFrame
。非常に便利で使いやすいメソッドなのですが、Reactで扱うには少々癖があったので、今回はそのことについて書いていこうと思います。
完成形
まずは今回解説するコードの完成形。
STARTボタンを押すとカウントの実行。STOPボタンを押すとカウントの停止ができます。シンプルなカウンターですが、この実装を完成として、ここまでの過程を考えていきます。
requestAnimationFrame
まずrequestAnimationFrame
でループする簡単な使い方を紹介します。
const loop = () => {
// 処理
requestAnimationFrame(step);
};
// ループの実行
loop();
よくみる形ですね。コールバックで再帰的に関数を実行することでループを実現しています。ループを止めるためには再帰呼び出しを止めるか、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
を扱う方法でした。その性質上、パフォーマンスを考えるとだいぶ癖があるように感じました。諸々検討した上で、問題が発生しないように使用できると良いですね。
参考
Discussion