MemoizationによるReactのパフォーマンスチューニング

この記事はFIXER 2nd Advent Calendar 2020Qiita Advent Calendar2020 React #2の12/6の記事になります。

今回はmemoizationによるReactのレンダリングパフォーマンスの改善について書いていこうと思います。

memoizationとは?

memoization(メモ化)は関数の呼び出しと実行結果を保持しておくことで処理の高速化を図る手法です。中にはmemoizationではなくて、memo r izationじゃないの?と思う人がいると思います(自分も途中まで間違えていました)。暗記の意味を持った英語としてはmemo r izationで正しいですが、技術用語としてはmemo r izationを元として作られた造語であるmemoizationが使用されます。

実測

Javascriptにはmemoization用のライブラリがいくつか存在します。
汎用ライブラリのlodashもこの機能を備えています。
ReactにもそれぞれReact.memo()React.useMemo()が存在しています。しかし、これらは最新のpropsと前回のpropsのみを比較しているため複数結果の保持はできません。
なので今回は複数保持できる下記のライブラリを使用しました。

moize

複数あるmemoizationライブラリの中でも今回はパフォーマンスに優れているmoizeを使用します。
パフォーマンスに加えReactのコンポーネントに対応しているという点でも優れています。

では、実際にメモ化をした場合としなかった場合の違いを測っていきたいと思います。処理の重さの違いを確かめるためにこのような関数を用意します。

function calc(num1, num2) {
  let result = 1;
  for (let i = 0; i < num1; i++) {
    result *= num2;
  }
  return result;
}

moize()の引数に関数を指定することでメモ化の対象とします。

const moizedCalc = moize(calc);

この関数を100回実行した際の合計時間を測ります。

for (let i = 0; i < 100; i++) {
  moizedCalc(100, 3);
}

結果

実行にかかった時間はhrtimeを使用して5回測定する形で行いました。

メモ化なし (ms)メモ化あり (ms)
0.42230.1842
0.20030.1263
0.17910.1104
0.22570.1349
0.37680.1243

この程度の重さの関数であればメモ化をした際に、メモ化をしていないものと比べ大きく処理速度の差が出ています。

次は同じ関数の引数を1に変えて試してみます。

for (let i = 0; i < 100; i++) {
  moizedCalc(1, 1);
}

結果

メモ化なし (ms)メモ化あり (ms)
0.02130.1784
0.02470.1890
0.02150.1134
0.02540.1973
0.04930.1737

ライブラリを挟むことによるメモ化や結果の引き出しによって処理が発生してしまうため、逆にこのような処理の軽い場合ではメモ化の対象としたほうが時間がかかってしまいます。

また極端に引数が多い場合も比較する要素が増え、かえって処理に時間がかかってしまうため注意です。

コンポーネントのメモ化

色々前置きがありましたが、本題のReactのコンポーネントにおけるメモ化について紹介していこうと思います。
moize.react()もしくはisReactオプションを使用することでReactのコンポーネントをメモ化することができます。

今回はtextnum×numで表示するコンポーネントを用意しました。

export const TextRenderer = ({ text, num }) => {
  return (
    <table>
      {[...Array(num)].map(() => {
        return (
          <td>
            {[...Array(num)].map(() => {
              return <tr>{text}</tr>
            })}
          </td>
        );
      })}
    </table>
  );
};

export const MemorizedTextRenderer = moize.react(TextRenderer);

今回は比較のためにメモ化の対象とする前のコンポーネントもexportしています。

このコンポーネントを↓の要領でボタンを1回押すと1秒おきに["a", "b", "c", "d", "e", "f"]の順に表示するように実装しました。

  const texts = ["a", "b", "c", "d", "e", "f"];
  const [text, setText] = useState("");

  const sleep = (ms) => {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  const start = async () => {
    for (let t of texts) {
      setText(t);
      await sleep(1000);
    }
  }

  return (
    <div className="App">
      <button onClick={start}>start</button>
      <TestRender text={text}></TestRender>
      <MemorizedTestRender text={text}></MemorizedTestRender>
    </div>
  );

moizeはコンポーネントに渡されたpropsを元にキャッシュを引き出しています。

レンダリングにかかった時間はデベロッパーツールのProfilerタブを使って調べていきます。
なお、測定に影響を及ぼさないようにReact DevTools以外の拡張機能は無効化しておきます。

結果

上がメモ化なし、下がメモ化ありの結果です。
メモ化ありの方が明らかにレンダリングにかかる時間が短くなっていることがわかります。

メモ化なし
a (ms)b (ms)c (ms)d (ms)e (ms)f (ms)
1.10.72.02.52.62.5
1.11.13.02.12.01.7
メモ化あり
a (ms)b (ms)c (ms)d (ms)e (ms)f (ms)
1.51.40.72.21.41.4
0.10.20.20.20.10.1

注意点

便利な機能ですが実行結果を保持しているということは、パターンや、対象となる関数が増えるにつれてメモリを消費してしまうということになります。Reactコンポーネントも同様です。測ってないので具体的な数値までわかりませんが、、、

防ぐためには↓のようにオプションで保持しておく数に制限をかけておく必要があります。

moize(calc, { maxSize: 5 });
moize.react(TextRenderer, { maxSize: 5 });

こうすることによって直近5回までの結果を保持しておくように制限をかけることができます。

おわり

今回使用したコンポーネントは親となる要素をメモ化していましたが、例えば複数コンポーネントで使用される子コンポーネントをメモ化することで効果的に活用できると思います。
便利ですが、メモリの使用量や呼び出される頻度や処理の重さなど様々な要素を考慮して使用する必要がありそうですね。

react: https://github.com/facebook/react
moize: https://github.com/planttheidea/moize

リポジトリ: https://github.com/mugi111/memoization-react-demo

FIXER Inc. 蛭沼 拓視
  • FIXER Inc. 蛭沼 拓視
  • common Lisp / javascript / kotlin / 組み込み C / Ruby / Rust とか 普段はフロントエンド