こんにちは。NCDCのテックリード加納です。
近年のReactでは、Functional Componentの考え方で、component側では状態を持ってないような書き方が主流ですが、一秒に何度も状態の更新が行われるような場合や、大量のデータを扱う場合に、Functional Componentの考え方では対応しきれないケースが出てきます。
この記事ではそのような場合において良さそうな構造を紹介します。
状態の更新によりレンダリングの遅れが生じる
Functional Componentの考え方の良いところは、レンダリング結果が予測しやすいこと。
それによってComponentをシンプルに書きやすいことが利点です。
一方で、この考え方で対応しきれないケースでは、Functional Componentのために必要な処理をいちいち経由することで、レンダリングに遅れが生じてしまいます。
とくに、一秒に何度も状態の更新が行われるケースでは、データがReactのフレームワークを経由することで人の目にわかるほど、場合によっては操作感が損なわれる程の遅れが生じます。
また、大量のデータを扱う場合はデータ(状態)の更新が起こるたびに、レンダリングに無駄な時間がかかったり、操作開始までに必要以上に待つ必要が出てしまいます。
そこで、これらの状態をReactのフレームワークの外に置くことで、良い性能やシンプルなComponent構造を維持することを目指します。
(canvasでアニメーション、一度に数百以上の要素の管理など)
Classコンポーネントは使えないのか?
じゃあClassコンポーネントでいいじゃないかという考え方もあるが、フレームワークを経由することには変わらず、性能を追求するためには、 Reactに気を使った書き方をする必要があります。
言い換えると、React向きでない部品についてもReact向けに無理やり実装する必要があるのです。
カスタムhooksとClassを用いて遅延を防ぐ
そこで、今回目指す「優れた性能とシンプルなComponent構造の維持」を実現する案として、以下の図のような構造を考えます。
状態管理用Class
大量・高速な処理を必要とする部分を扱うための、状態管理用Classを用意する。
このClassはReact Componentから受け取ったhtmlのrefを内部的に保持し、描画を直接制御する。
そのためのデータは、メソッドを通じて受け取り、外部連携が必要なデータは、イベントを通じてReact側に出力する。
このClass内ではReactに依存するようなコードにならないようにし、Reactを使っていない環境でも利用できるようにする。
domとの連携など、Reactで書きやすい部分は極力このClassには書かない。
Custom Hooks
上記Classのインスタンスの生成・保持や、refの生成、React側で必要な状態の保持などを行う。
useState, useEffectなどを利用し、メソッドコールやイベントのlistenなどの方式と変換をするイメージ。
また、最低限必要な状態のみをReactの世界とやり取りすることで、高速化し、見通しを良くする。
インターフェースに徹し、余計なロジックなどは持たない。
React Component
React Componentは従来どおりFunctionalなものとし、Reactで制御した方が扱いやすいもの(button、通常のラベルなど)を制御する。
対して、大量・高速な処理を必要とする部分や、連携して表示制御するものに関しては、Custom Hooksから状態を受け取る。
まとめ
Reactを使ったシンプルなコードによるUIの構築と、大量・高速な処理を必要とするUIを両立するための構成を紹介しました。
この考え方は他のフレームワークに応用が可能で、Reactを前提としないライブラリを利用する際にも適用できます。
NCDCではこのように、単にUIを実装するだけでなく「シンプルでメンテナンス性が良く、かつ性能も良いもの」を作ることを大切にしています。
同じ志を持った仲間を募集していますので、共感してくれたエンジニアがいればぜひ採用情報ページをご覧ください。
サンプルコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import EventEmitter from 'eventemitter3'; class TestA extends EventEmitter { constructor(dom, paramA) { this.w = dom.clientWidth; this.h = dom.clientHeight; /* global performance */ this.lastTime = performance.now() / 1000; this.renderer = new WebGLRenderer({ alpha: true }); this.renderer.setSize(this.w, this.h); } rotate() { this.mesh.rotation.x = Math.PI * 100; } render() { requestAnimationFrame(() => { this.render(); }); /* global performance */ const time = performance.now() / 1000; if (time - this.lastTime > 1000) { this.emit('seconds', time); } const rpm = 30; this.mesh.rotation.x = time * Math.PI * rpm / 60; this.renderer.render(this.scene, this.camera); } destroy() { this.unbindAll(); } } export default TestA; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
import { useState, useEffect, useRef } from 'react'; import TestA from './testA.js'; const useCanvasRenderer = ({ paramA, someFunc }) => { const [instance, setInstance] = useState(null); const [seconds, setSeconds] = useState(0); const domRef = useRef(null); useEffect(() => { if (!domRef) { return; } const inst = new TestA(domRef.current, paramA); inst.on('seconds', (time) => { setSeconds(time); }); inst.on('eventA', () => { someFunc(); }); setInstance(inst); return () => { instance && instance.destroy(); } }, [domRef, someFunc]); const someAction = () => { instance && instance.rotate(); }; return { seconds, someAction, domRef }; }; export default useCanvasRenderer; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
export default ({ someFunc, someText }) => { const { domRef, seconds, someAction } = useCanvasRenderer({ paramA: someText, someFunc }); return ( <div> <button onClick={ () => someAction() }>ActionA</button> <span>{ seconds }</span> <canvas ref={domRef} /> </div> ); } |