はじめに
はじめまして。株式会社スタメンでエンジニアをしております永井です。 今回の記事ではReactでメモ化によるパフォーマンスを意識した実装方法について書きたいと思います。
なぜパフォーマンスを意識した実装が大切なのでしょうか。
なぜなら、ユーザーのある操作に対するレスポンスの速度を高めることは、UXの文脈において非常に重要な要素だからです。例えば、100ms未満のレスポンスに関してはユーザーは瞬時に感じられますが、100ms ~ 300msではすでに遅いと感じてしまいます。遅いことにストレスを感じたユーザーは、別のサービスにリプレイスしてしまうかもしれません。
Reactでパフォーマンスを出すには、バンドルサイズを減らすなど、いくつか方法はありますが、基本的な戦略としては不要なレンダリングを抑えることだと思います。
この不要なレンダリングを抑制するためには、Reactがどのように機能するかを理解する必要があります。理解しないまま改善を行うと、却ってパフォーマンスに悪影響が出る可能性もあります。
そのため、パフォーマンス改善に繋がるメモ化等のメソッドを説明する前に、このReactがレンダリングにおいてどのように機能しているかを説明したいと思います。
仮想DOMによるReactの更新処理
ReactではDOMの更新処理を、仮想DOMによる差分更新処理に任せることで、パフォーマンスを高めています。
具体的に言うと、実際のDOMをJavascriptオブジェクトの形式に変換したツリーデータをメモリ上に作成し、コンポーネントの状態に変更がある度に、実際のDOMを更新するのではなく仮想DOMを更新します。
更新された仮想DOMと古い仮想DOMを比較し差分を検出することで、実際のDOMにレンダリングを行います。 こうすることで実際のDOMでは必要最低限の箇所のみレンダリングを行うことが可能になります。
例えば以下のようなコードがあるとします。
const Page = () => { return ( <div> <p>Counter App</p> <Counter /> </div> ) } const Counter = () => { const [count, setCount] = useState(0) return ( <div> <span>{count}</span> <button onClick={() => setCount(count + 1)}>+</button> </div> ) }
仮想DOMとしては、初回レンダリング時にPageとCounterコンポーネントで記述されたJSXを元に、Javascriptで構築した仮想DOMツリーを生成します。そして、+
を押すと、count
のstateが更新されてレンダリングが走り、count
の箇所が2
となった新しい仮想DOMツリーを生成します。そして新旧2つの仮想DOMツリーを比較して差分を検出し、差分があった箇所である<span>{count}</span>
だけを実際のDOMに反映します。
このように、仮想DOMの概念によって必要最低限の箇所のみDOMを変更することができます。
Reactのメモ化
仮想DOMによる差分更新によって、変更箇所のみをレンダリングすることができます。しかし、配下のコンポーネントが再描画されるため、不必要な箇所まで再レンダリングされてしまいます。 そこで、Reactによるメモ化によってコンポーネントに変更がない場合はレンダリングされないようにしましょう。
React.memo
Reactの高階関数であるReact.memo
はコンポーネントをメモ化する上でよく使われる手法です。
例えば以下のようなコードがあるとします。
const Parent = () => { const [parentName, setParentName] = useState('') return ( <div> <span>parent is {parentName}</span> <input type="text" onChange={e => setParentName(e.target.value)} /> <Child /> </div> ) } const Child = () => { return ( <div> <span>I am Child</span> </div> ) }
この時、<input //... />
に文字を入力するとsetParentName
が発火してname
が更新されます。stateが更新されるのでParentコンポーネントはレンダリングされますが、Childコンポーネントはどうでしょうか?
試しにconsole.log('child')
を仕込んでみましょう。
const Child = () => { console.log('child') return ( <div> <span>I am Child</span> </div> ) }
再び<input //... />
に文字を入力してみましょう。すると...
=> child
「child」と表示されてしまいました。つまり、文字を入力する度に発生するParentコンポーネントのレンダリングに付随して、Childコンポーネントも毎回レンダリングされてしまっているのです。
本来であればこのレンダリングは不要なので、パフォーマンスを考慮するのであれば防ぎたいレンダリングです。
このレンダリングを抑えるために、React.memo
を使います。
const Child = React.memo(() => { console.log('child') const return ( <div> <span>I am Child</span> </div> ) })
このコードではReact.memo
でコンポーネントをラップすることで、Childコンポーネントに渡すpropsに変更がない場合に、レンダリングをスキップしています。Parentコンポーネントにある<input //... />
で再び文字を入力してみると、コンソールには何も出力されていないことが確認できると思います。
このReact.memo
でpropsの前後の値を比較してレンダリングするかを決定している訳ですが、この比較は浅い比較で行われます。所謂、オブジェクトのインスタンスにおける参照が異なるかどうかを見ています。
※ React.memo
は第2引数に何も指定しないと、デフォルトでは浅い比較で行われます。第2引数に比較関数を渡すことでレンダリングをカスタムで制御することができますが、基本的には等価性のチェックにはコストが掛かるので避けたいです。
このように、React.memo
を使用することで、本来変更されていないコンポーネントのレンダリングを抑えることができますが、一つ落とし穴があります。
次のコードを見てみてください。
const Parent = () => { const [parentName, setParentName] = useState('') const [childName, setChildName] = useState('') const childNameHandler = (e: React.ChangeEvent<HTMLInputElement>) => { setChildName(e.target.value) } return ( <div> <span>parent is {parentName}</span> <input type="text" onChange={e => setParentName(e.target.value)} /> <Child name={childName} onChange={childNameHandler} /> </div> ) } const Child = React.memo(({ name, onChange }) => { console.log('child') return ( <div> <span>child is {name}</span> <input type="text" onChange={onChange} /> </div> ) })
変更した箇所としては、Childコンポーネントにstateのname
と関数のchildNameHandler
propsで渡しています。
このコードで再びParentコンポーネントにある<input //... />
に文字を入力してみましょう。すると...
=> child
React.memo
でメモ化しているのにも関わらず、再びレンダリングされてしまいました。この原因は、childNameHandler
関数にあります。
アロー関数はレンダリングの度に新しい関数オブジェクトを生成しますが、この関数オブジェクトの再生成によって、propsとして渡しているchildNameHandler
の参照が変更されてしまい、Childコンポーネントがレンダリングされてしまうのです。((() => {}) !== (() => {})
であるため)
これを防ぐ方法としてuseCallback
があります。
useCallback
useCallback
を用いて改善したコードは以下のようになります。
const Parent = () => { const [parentName, setParentName] = useState('') const [childName, setChildName] = useState('') const childNameHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { setChildName(e.target.value) }, []) return ( <div> <span>parent is {parentName}</span> <input type="text" onChange={e => setParentName(e.target.value)} /> <Child name={childName} onChange={childNameHandler} /> </div> ) }
useCallback
は、メモ化された関数オブジェクトを返すhooks APIです。第2引数にしている配列は依存配列で、配列内のいずれかの値が変更されると、新しく関数オブジェクトを生成します。
useCallback
を使用する場合は、基本的にReact.memo
によって最適化されたコンポーネントにpropsとして渡す場合に限定するべきです。
React.memo
を使用していないコンポーネントにuseCallback
によってメモ化した関数を渡したとしても、親コンポーネントがレンダリングされると子コンポーネントはレンダリングされてしまうからです。
また、React.memo
によってメモ化されたコンポーネントに渡さない場合にもuseCallback
でメモ化するのは避けた方が良いと言われています。これはuseCallback
の実行コストは関数オブジェクトの再生成のコストよりも高いと言われているからです。
useMemo
useMemo
は関数の返り値をメモ化する際に使用します(useCallback
は関数自体をメモ化します)
例えば、以下のような関数があるとします。(こんなコードは現実には存在しないと思いますがあくまでサンプルということで)
const someCalculate = () => { let number = 0; while(number <= 1000) { console.log(number) number++ } return count * number }
非常にシンプルなコードですが、変数number
が1000になるまで1ずつ足していき、最終的に変数count
と乗算するというものです。
useMemo
ではこの計算結果をメモ化することができ、計算自体をスキップすることができます。(以下のコード)
const memorized = useMemo(() => { let number = 0; while(number < 1000) { console.log(number) number++ } return count * number }, [count])
useMemo
の依存配列にはcount
を入れています。countが変化すると再計算する必要がありますが、count
が不変の場合は計算結果をメモ化して利用することができます。
最後に
Reactでのメモ化について今回書きました。普通にReactで実装していても画面自体は出来てしまうのですが、Reactのレンダリングやメモ化について知らないと、実はかなりパフォーマンスが悪い実装になってしまうことは往々にしてあると思います。
弊社のプロダクトであるTUNAGでも、フロントエンド領域においてパフォーマンスを最適化しきれていない部分がまだまだあるので、徐々に最適化できればと思います。
スタメンでは一緒に働くエンジニアを募集しています。 興味がある方は、ぜひ採用サイトからご連絡ください!