2022/07/04

Reactメモ化について

こんにちは、ばんがです。
Reactのメモ化の機能について、パフォーマンス改善に使うらしいけど...程度の知識しかなかったのでざっくり調べてみました。

メモ化とは?

関数の計算結果をキャッシュしておいて、再度その関数が呼び出された時にはキャッシュを利用することで、再度関数が呼び出されないようにする、というプログラムの最適化手法のようです。

Reactの公式にはメモ化のWikipediaへのリンクが貼られていました。
https://en.wikipedia.org/wiki/Memoization

より具体的には、前回の計算結果とその時の引数を保持しておいて、引数が同じであればキャッシュした結果を返す、引数が異なれば再計算される、という挙動になるようです。

Reactでのメモ化

Reactでメモ化する方法をみていきます。

まず1つめ。

React.memo

コンポーネントをラップし、コンポーネントのレンダリング結果をメモ化します。
コンポーネントに同じpropsを渡して同じレンダリング結果になる場合は、これを利用してパフォーマンスの向上が図れます。

まずは簡単なコードで動きをみてみます。

メモ化してないパターンです。
親(Parent)コンポーネントから子(Child)コンポーネントを呼び出しています。

import React, {useState} from "react";

const Parent = () => {
  const [count, setCount] = useState(0)

  return (
    <>
      <button onClick={() => setCount(prev => prev + 1)}>カウントを増やす</button>:
      {count}
      <Child name='child component'/>
    </>
  )
}

const Child = (props) => {
  console.log("子コンポーネント")
  return (
    <div>{props.name}</div>
  )
}


親コンポーネントのstateが変わるたびに、stateに依存しないChildコンポーネントが再レンダリングされているのがわかります。
確かに、Childのレンダリングが遅い場合だと結構重くなっちゃいそうですね。


じゃあ次にChildをメモ化したパターンを見てみます。
React.memoでラップしました。

const Child = React.memo((props) => {
  console.log("子コンポーネント")
  return (
    <div>{props.name}</div>
  )
})


この場合の挙動はこんな感じ。
コンソールに子コンポーネントが出てこないので、再レンダリングされてないのがわかります。

複雑なオブジェクトのpropsの場合、再レンダーされる場合もある

React.memoでのpropsの比較は浅い比較なので、複雑な、例えばネストされたオブジェクトなどのpropsの場合は再レンダーされてしまう場合があります。
例えば、以下のような同じデータを持つオブジェクトを考えます。

const objA = { a: { b: '1' } }
const objB = { a: { b: '1' } }


これをChildコンポーネントのpropsに渡してみましょう。
countの偶奇によって渡すオブジェクトを変えるようにします。

<Child name='child component' targetObj={count % 2 === 0 ? objA : objB}/>


するとどうでしょうか?
渡しているオブジェクトの値は同じなのにもかかわらず、Childコンポーネントは際レンダリングされています。
浅い比較では、プロパティにオブジェクトがある場合、オブジェクトの参照先を比較するので、このように参照先が異なる場合は不一致の判定をされてしまうわけです。


このような複雑なオブジェクトでの等価性判定を制御する場合は、memoの第二引数に比較用の関数を指定します。
例えば、以下のようにtargetObjのネスト中のプロパティ同士を比較するように変更すると、等価判定されて再レンダリングされないようになりました。(あまり使う機会はなさそうですが...)

const areEqual = (prevProps, nextProps) => {
  return prevProps.targetObj.a.b === nextProps.targetObj.a.b;
}

const Child = React.memo((props: { name: string; targetObj: any }) => {
  console.log("子コンポーネント")
  return (
    <div>{props.name}</div>
  )
}, areEqual)


useMemo

メモ化したい値を計算する関数を渡すことで、その計算結果をメモ化してくれるhookです。
また、第2引数に依存配列を渡すことで、その配列の要素が変化した場合に再計算を行ってくれます。

例えば、以下のようにレンダリング中に高価な計算 calculationを走らせなければならないとします。

const calculation = (arg: number) => {
  console.log("高価な計算")
  return arg * 2;
}

const Parent = () => {
  const [count, setCount] = useState(0)
  const [arg, setArg] = useState(1)
  const result = calculation(arg)

  return (
    <>
      <button onClick={() => setCount(prev => prev + 1)}>カウントを増やす</button>:{count}
      <button onClick={() => setArg(prev => prev + 1)}>引数を増やす</button>:{arg}
      <div>
        高価な計算結果:{result}
      </div>
    </>
  )
}


calculationはargにのみ依存し、countには依存しませんが、countを増やした場合でも再レンダリングとともに実行されてしまいます。


では次にcalculationがargにのみ依存するようにメモ化してみましょう。

const result = useMemo(() => calculation(arg), [arg])


引数を変化させたときだけ再計算が走り、countを増やした時は計算されないようになりました!


これはとても簡単な例なので恩恵がわかりづらいですが、もっと重い計算を走らせる必要がある場合などはパフォーマンス改善が期待できそうです。

useCallback

メモ化したコールバック関数を返すフックです。
これ単体ではあまり利用しないようで、React.memo等でメモ化したコンポーネントの再レンダリングを防ぐために利用するのが主な使い方のようです。

const memoizedCallback = useCallback(() => {
  callback(a);
}, [a]);


コンポーネントにコールバック関数を渡す場合、そのコンポーネントをReact.memoでメモ化していても再レンダリングされてしまいます。
例えば以下のような場合、Childに渡しているpropsは変わっていないはずなのに、子コンポーネント(React.memoしている
)が再レンダリングされてしまいます。

const Parent = () => {
  const [count, setCount] = useState(0)
  const callback = () => {
    console.log("コールバック!")
  }

  return (
    <>
      <button onClick={() => setCount(prev => prev + 1)}>カウントを増やす</button>:{count}
      <Child callback={callback} name="名前が入ります"/>
    </>
  )
}


const Child = React.memo((props: { callback: any, name: string}) => {
  console.log("子コンポーネント")
  return (
    <div>{props.name}</div>
  )
})


親コンポーネントでcountを増やすと、親コンポーネントの再レンダリングが走ってcallback関数も再生成されます。
関数のようなオブジェクトをpropsに指定している場合、その参照先が同一かどうかで等価性の判断がされます。再生成されたcallback関数は、以前のcallback関数とは参照先が異なるので、React.memoの等価性判断で等価ではないと判断され、再レンダリングが走ってしまう、ということです。

このような場合に使うのがuseCallbackです。
以下のように先程のコールバック関数をメモ化し、メモ化したコールバックをメモ化したコンポーネントに渡すことで、子コンポーネントの再レンダリングを防ぐことができます。

const callback = useCallback(() => {
  console.log("コールバック!")
}, [])


まとめ

今回調べてみて、そもそもuseMemoやuseCallbackが何をするものなのか、メモ化がどうパフォーマンス改善につながるのか、がざっくり理解できました。
今後React書く際はメモ化できるとこがないか探してしまいそうですね。(何でもかんでも使えば良いもんでもないそうですが^^;)
それでは。