タイトルの通りです。React の再レンダリングを介さずにDOMを直接更新する方が高FPSを出せるか実験してみたけど、そんなに変わらなかったという話です。
ことの発端
ストップウォッチを作っていて、こんな感じの ControlledComponent を書いていました。とてもオーソドックスなコードです。
type TimerState = 'idoling' | 'working'; // ControlledStopwatch は prop で受け取った情報を描画するだけ。内部にステートを持たない const ControlledStopwatch: FC<{ onStart: (startAt: number) => void; onFinish: (endAt: number) => void; currentTime: number | null; state: TimerState; }> = ({ onStart, onFinish, state, currentTime }) => { return ( <div> {state === 'idoling' ? ( <button onClick={() => onStart(performance.now())}>start</button> ) : ( <button onClick={() => onFinish(performance.now())}>stop</button> )} {currentTime && <div>{currentTime / 1000} seconds</div>} </div> ); }; const Controlled: FC = () => { const [state, setState] = useState<TimerState>('idoling'); const startAt = useRef(0); const [currentTime, setCurrentTime] = useState<number | null>(null); const fps = 120; useEffect(() => { if (state === 'working') { // 1000/fps ミリ秒ごとに表示を更新する const id = setInterval(() => { setCurrentTime(performance.now() - startAt.current); }, 1000 / fps); return () => clearInterval(id); } }, [fps, state]); return ( <div className='App'> <ControlledStopwatch currentTime={currentTime} state={state} onStart={(val: number) => { setState('working'); startAt.current = val; }} onFinish={(endAt: number) => { setState('idoling'); console.log(endAt - startAt.current); }} /> </div> ); };
このストップウォッチは、1000/fps ミリ秒ごとに setCurrentTime を行い、React の再レンダリングをしています。しかし、書き換えるべき箇所は時刻の部分のみです。ならば 再レンダリングはややコストが高いのでは? と思ったのが検証しようと思ったきっかけです。
UncontrolledComponent で書き直してみる。
UncontrolledComponent は state をコンポーネント内部で持っている(親がステートをコントロールできない)コンポーネントです。一見すると親でステートを持てないのはデメリットにしか見えませんが、逆にいえば 「親がステートを管理しなくていい」コンポーネント です。
親は子のレンダリングに関して一切の責任を負わないため、再レンダリングせずに直接 DOM を更新して書き換えても問題ありません。そのため、高FPSが求められる作業に向いていそうに思いました。結論をいうと大差なかったんですが。
というわけで書き直したデモがこちらになります。
結果: 目に見えた差はなかった
結果は上のデモをみてわかる通り、 目でみてわかるレベルの差はありませんでした 。60fpsで録画して確認してみたところ、どちらも60fps以上はバッチリ出ていたので、差を視認するのはかなり難しいはずです。
というわけで、 高いFPSを求めて UncontrolledComponent に書き直すのはそんなに意味なさそう という検証でした。