Reactのパフォーマンス改善を勉強会で開催しました

はじめまして、新卒フロントエンドエンジニアのぺいです。

JX通信社でフロントエンドの開発はReactが主流になっており、React Hooksを使った開発が欠かせません。hooksは便利な反面、適材適所使い所を理解していないと逆にパフォーマンスが悪くなってしまう場合があります。そこで今回は普段フロントエンドを書かない人も勉強会に参加するのを考慮し簡単な改善から応用としてReactで書かれたFASTALERT *1の改善まで行ってもらいました。

前提条件

react 17.0.1

勉強会の内容

最終的な目標として考えていた、どのようにボトルネックを発見するかを体験してもらいたかったので、簡単なパフォーマンス改善体験以外はFASTALERT本体の改善を実際に行ってもらいました。

座学では、なぜ不必要なコンポーネントが再レンダリングされてしまうのか、どのように改善するのかを行いました。

再レンダリングされているコンポーネントを見つける

  • React devツールを入れる
  • Componentsの設定からハイライトにチェックマークを入れる

f:id:PeeI:20210711151821p:plain

  • 適当に操作をすると再レンダリングされた箇所が以下のようにハイライトされる。

f:id:PeeI:20210711154018g:plain

座学では、上記のように 1ずつ増えるボタンと2ずつ増えるボタンを用意して、+2は子のコンポーネントで作りました。

以下は実際に座学で使用したコードです。以降はこのコードをリファクタリングしていきます。

// カスタムhooks
const useCounter = () => {
  const [count, setCount] = useState(0);

  const onClickIncrement = () => {
    setCount((prev) => prev + 1);
  }

  const onClickIncrementDouble = () => {
    setCount((prev) => prev + 2);
  }

  return { count, onClickIncrement, onClickIncrementDouble };
};
// 親のコンポーネント
const Component: React.FC = () => {
  const { count, onClickIncrement, onClickIncrementDouble } = useCounter();

  return (
    <>
      <button onClick={onClickIncrement}>+1</button>
      <ChildComponent onClick={onClickIncrementDouble} />
      <div>現在のカウント:{count}</div>
    </>
  );
};
// 子のコンポーネント
interface Props {
  onClick: () => void;
}

const ChildComponent: React.FC<Props> = ({ onClick }) => {
  console.log("子のコンポーネントが描画されました。");
  return <button onClick={onClick}>+2</button>;
};

本来は+2が子のコンポーネントになってるので、+1の親のボタンをクリックしても再レンダリングされてほしくはありません。しかし、子のコンポーネントまでハイライトで再レンダリングされている様子がわかります。

試しにハイライトによる確認だけではなく、Profiler による確認もしてみましょう。

f:id:PeeI:20210711161532p:plain

  • React dev ツールを入れている状態で、Profilerを選択する
  • 左端にある🔵ボタンを押してprofilingをスタートする
  • 適当に操作を行う(今回は+1をクリックする)
  • 🔴を押してprofilingを止める

そうすると Component(親)の配下に黄色で ChildComponent がレンダリングされている様子が分かります。

Profilerの詳しい説明に関してはこちらを参考にしてください。

なぜ再レンダリングされてしまうのか

結論から述べると、Reactはレンダリングされる際に propsが変わったかどうかは気にしていません

なので子のコンポーネントも無条件にレンダリングされてしまいます。

以降ではどのように、再レンダリングしないようにするかについて述べます。

改善方法

親のコンポーネントがレンダリングされると子も無条件にレンダリングされてしまうので、これをストップさせるには

  • 子のコンポーネントをメモ化する
  • 子に渡してる関数がある場合は関数をメモ化する

をしてあげる必要があります。

コンポーネントのメモ化

classコンポーネントでは、shouldComponentUpdate を使い、無駄なレンダリングを止めることができます。 shouldComponentUpdate は以下の図のようにrenderが呼ばれるより先に呼ばれ、falseを返すことでレンダリングを止めることができます。

f:id:PeeI:20210711164517p:plain

参照

shouldComponentUpdate は自分で比較式を書いてfalseを返すかどうかを決める必要があるのに対して、PureComponent は、自ら比較式を書かなくとも浅い比較(ShallowEqual)をしてくれるので公式でもレンダーを抑止するためには、shouldComponentUpdateではなくPureComponentを使うよう推奨しています。

両者ともclassコンポーネントで使われるのに対して、functionコンポーネントの場合は上記と同じような機能を持った React.memo というものがあります。

以下は先ほどの子のコンポーネントをメモ化させたものです。

import { memo } from "react";

const ChildComponent: React.FC<Props> = memo(({ onClick }) => {
  console.log("子のコンポーネントが描画されました。");
  return <button onClick={onClick}>+2</button>;
});

メモ化させたことで、propsに変更がない時はレンダリングを止めることができました。

shouldComponentUpdateはfalseを返すことで等しいという意味になりますが、React.memoはその逆でtrueを返すこと等しいということになります。詳しくは

しかし、このままでは まだ 無駄なレンダリングを止めることができません。

関数のメモ化

上記でコンポーネントをメモ化したにも関わらず、無駄なレンダリングを止めることができないのはなぜでしょうか。

それはコールバック関数をメモ化してない点にあります。

今回は、親のComponentからChildComponentにpropsとしてonClickIncrementDouble を渡しています。

const onClickIncrementDouble = () => {
  setCount((prev) => prev + 2);
}

親コンポーネント側で上記のように関数を作成するとレンダリングする度に新しい関数の参照が作成されるので、せっかくコンポーネントをメモ化させてもpropsで渡された関数が以前の参照と異なるので、レンダリングされてしまいます。

そこで同じ参照を再利用するコールバック関数の useCallback を使います。

使い方は第一引数に関数を渡して、第二引数に依存関係を配列で渡してあげます

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

useCallbackを使うことで関数が不変値化されるので、メモ化したコンポーネントに関数を渡す時、同じ参照を渡していることになるので、メモ化されたコンポーネントでShallow Equalityされた時、前回と同じ結果を返すことができます。

ChildComponent に渡している onClickIncrementDouble をuseCallbackでラップし、Profilerで確認した結果以下のようになりました。

f:id:PeeI:20210803123525p:plain

それぞれメモ化したことで、ChildComponentは再レンダリングされなくなりました。

最終的な変更箇所

// カスタムhooks
 const useCounter = () => {
   const [count, setCount] = useState(0);

-  const onClickIncrement = () => {
+  const onClickIncrement = useCallback(() => {
     setCount((prev) => prev + 1);
-  };
+  }, []);

-  const onClickIncrementDouble = () => {
+  const onClickIncrementDouble = useCallback(() => {
     setCount((prev) => prev + 2);
-  };
+  }, []);

   return { count, onClickIncrement, onClickIncrementDouble };
 };
// 子のコンポーネント
-const ChildComponent: React.FC<Props> = ({ onClick }) => {
+const ChildComponent: React.FC<Props> = memo(({ onClick }) => {
   return <button onClick={onClick}>+2</button>;
-};
+});

毎回コンポーネントや関数をメモ化すべきなのか

今回は、再レンダリングを防ぐ方法を講義で行いました。その中で、毎回メモ化する必要があるのか という質問をされました。

結論からいうとその必要はないと思います。

逆にメモ化が必要な時は以下の時といえます。

  • 数千件・数万件のデータを計算するロジックが含まれた関数など
  • 一秒毎にレンダリングされるコンポーネントがあるなど
  • カスタムhooksを作る時

コストの高い計算

例えば、数千件・数万件のデータを加工しないといけない時、メモ化されていないとレンダリングが走る度に関数が作られ非常に重くなることがあると思います。そのような場合は、useMemo などを使いメモ化することをお勧めします。

無駄なレンダリング

例えば、画面上部にタイマーが設置されており、1秒毎に他のコンポーネントも再レンダリングされてしまう場合は無駄なレンダリングと言えます。他にも今回紹介したReact devツールを使い無駄だと思う箇所はメモ化してあげる必要があるでしょう。

カスタムhooks

講義編で説明したように、useCallbackはメモ化されているコンポーネントに関数を渡したい時に力を発揮します。逆を言うとメモ化されていないコンポーネントにメモ化した関数を渡しても意味はないです。

ではカスタムhooksで関数を定義する場合はどうでしょうか。カスタムhooksは使われるコンポーネントのことを知りません。そして、そのカスタムhooksを使うコンポーネント側もカスタムhooksの内部事情をしりません。カプセル化されている状態では関数がメモ化されているかどうかを知らないので、関数を useCallback でメモ化してあげている方が汎用的です。

最後に

勉強会中、メモ化に関する議論が最も多く上がっていました。パフォーマンスに関しては必ずこうすべきという銀の弾丸のようなものはないので、各プロジェクトにあった改善が求められると思います。今回勉強会を開催して色々な意見が出てきたので、またパフォーマンス改善に関する勉強会を開催して知見を深めたいと思いました。

参考