⏱️

【React.memo】Reactのパフォーマンス改善方法の整理

2022/02/27に公開

はじめに

下記の記事で勉強したことを整理していく。
https://qiita.com/hellokenta/items/6b795501a0a8921bb6b5
https://qiita.com/uhyo/items/5258e04aba380531455a
https://blog.uhy.ooo/entry/2021-02-23/usecallback-custom-hooks/
https://qiita.com/teradonburi/items/5b8f79d26e1b319ac44f

Reactにおけるパフォーマンスの改善とは?

次の2つ。

  1. 無駄なレンダリングを減らすこと
  2. 不要な再計算を減らすこと
     
    この記事では無駄な再レンダリングを減らす方法について整理する。

Reactのレンダリングプロセスについて

無駄なレンダリングを減らすにはまずはレンダリングの内容を理解する必要がある。レンダリングは次の流れで行われる。

画像参照:https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

  1. constructor
    記事においては気にしなくてよい(気になる方は画像参照から確認できます。)
  2. render
    jsxをReact要素に変換してレンダリング出力を得る部分。出力を得たあとは、仮想DOMと差分を計算して、実際に変更すべきDOMのリストを収集する。
  3. ReactがDOMとrefsを更新する
    renderで取得した変更すべきDOMのリストを使ってDOMを更新したあとcomponentDidmountやuseLayoutEffectを実行する。
  4. useEffectを実行

レンダーフェーズとコミットフェーズについて

レンダリングプロセスは「レンダーフェーズ」と「コミットフェーズ」に分けられる。
レンダーフェーズ:1、2
コミットフェーズ:3、4
つまり「レンダリングすること」と「DOMを更新すること」は同じタイミングで行われないということがポイント。
この考えから次の結論が導きだせる。
DOMの更新がされていなくても(見た目に変化がなくても)、レンダリングだけ実行される可能性がある

無駄なレンダリングとは?

無駄なレンダリングを見つけるためにReactで発生するレンダリングについて整理する。

再レンダリング(初期描画以降のレンダリング)のトリガー

最初のレンダリングが完了した後に行われる再レンダリングのトリガーには次の2つがある。

  1. useStateのセッター
  2. useReducerのdispatch

再レンダリングの挙動

Reactの再レンダリングでは親コンポーネントが再レンダリングされると、すべての子コンポーネントを再帰的に再レンダリングする。
子コンポーネントに対してpropsを渡しているとか、propsに変化があったとかは関係なく、必ず再レンダリングされることがポイント。

例えばApp.tsxをルートコンポーネントとして、App.tsxでuseStateのセッターを呼び出した場合、App.tsxより下にあるコンポーネントは問答無用で再レンダリングされるてしまう。

無駄なレンダリングとは?

DOMの変化がないコンポーネントも再レンダリングされてしまうという点が無駄。
記事の冒頭「Reactにおけるパフォーマンスの改善とは?」という項目であげた、無駄なレンダリングとはこの部分を指す。

無駄なレンダリングを取り除く方法

コンポーネント自身がレンダリングする必要があるか判断できるように設定してあげる。
設定する方法は簡単でコンポーネントをReact.memoでラップしてあげれば良い。memo化することでコンポーネントは再レンダリングのタイミングになったらpropsを確認して変化がなければ再レンダリングをスキップする。(propsに変化がないということは状態に変化がないことが確定する。)

再レンダリング発生タイミング3種と考察

  1. useStateのセッター, useReducerのdispathc
    useStateを持つコンポーネントのステートが更新されたら必ず再レンダリングする必要がある。
    Aのステートが更新されたらAは再レンダリング。
  2. 親コンポーネントの再レンダリング
    しつこいようだが、親コンポーネントによる再レンダリングはpropsを渡していなくても発生する。100%不要なレンダリングなのでReact.memoによって防ぐ必要がある。
    A>BでAが再レンダリングされた場合はBも再レンダリング。
  3. propsの更新
    propsが更新されていれば必ず再レンダリングする必要がある。
    A>BでAから渡ってきたpropsが前回と変化があればBは再レンダリング。

再レンダリンをスキップすることで達成されるパフォーマンス改善の本質

あらためてReactのレンダリング動作を確認すると次の通り。
親コンポーネントが再レンダリングされると、その親コンポーネントのすべての子コンポーネントを再帰的に再レンダリングする。

この再帰的という部分が重要で、例えば「Aコンポーネント」に「Bコンポーネント」がぶら下がっており、「Bコンポーネント」には「Cコンポーネント」がぶら下がっているとする。
A>B>C
Aコンポーネントで再レンダリングが発生するとBコンポーネントが再レンダリングされる。Bコンポーネントが再レンダリングされるとCコンポーネントが再レンダリングされる。これが再帰的な再レンダリングでReact.memoしない場合は以上のようなレンダリングになる。
ではReact.memoを使ってパフォーマンス改善を行うとどのように変化するのか。Bコンポーネントをmemo化する。Aコンポーネントが再レンダリングが発生したときBコンポーネントが自身のpropsを確認する。変化がないので再レンダリングをスキップする。するとCコンポーネントが再レンダリングされなくなる。なぜか。再レンダリングのタイミングは3つありこのときCコンポーネントに当てはまるのはもちろん「親コンポーネントの再レンダリング」だ。したがって、Bコンポーネントの再レンダリングがスキップされることでCコンポーネントの再レンダリングもスキップされる。

コードで見るパフォーマンス改善

無駄なレンダリングを取り除くにはpropsの変化を確認するようにコンポーネントを設定する必要があること、またそれを実現するのにはReact.memoを利用することは前の項までで記載してきた。ここからは実際にコードでパフォーマンス改善方法を確認していく。

React.memoの使い方

React.memo()の引数に関数コンポーネントを渡してあげればpropsの検証が行われるようになる。
memo()を書くことで親コンポーネントの再レンダリングが行われても、propsの変化がなければ再レンダリングはスキップされ、スキップされれば再帰的な再レンダリングは行われないため、無駄な再レンダリングは抑えられる。

React.memoの落とし穴

実はReact.memoだけでは再レンダリングを完璧に抑えることができない。
なぜならReact.memoによるpropsの更新検証は値の比較だけで行われるのではなく、値の参照先の比較も行われるからだ。
次のようなコードはReact.memoをしていても再レンダリングの対象となる。

const MemoizedChildComponent = React.memo(ChildComponent);

function ParentComponent() {
  const onClick = () => {
    console.log("Button clicked");
  };

  const data = { a: 1, b: 2 };

  return <MemoizedChildComponent onClick={onClick} data={data} />;
}

ParentComponentが再レンダリングされると、onClickとdataが再定義される。
もちろん再レンダリングされてもonClickとdataの中身が再レンダリング前と変化することはないが、値の参照先が変わってしまう。onClickとdataがMemoizedChildComponentにpropsとして渡されReact.memoによって検証されるとき、参照先が前回と異なるため、propsに更新があった と判断されMemoizedChildComponentは再レンダリングされてしまう。

参照先を変えない方法

次のhookを利用することで参照先の変更タイミングを制御することができる。

hook名 引数に渡す値
useMemo 複雑な計算とオブジェクト
useCallback コールバック関数
const MemoizedChildComponent = React.memo(ChildComponent);

function ParentComponent() {
  const onClick = useCallback(() => {
    console.log("Button clicked");
  },[]);

  const data = useMemo(()=>{ a: 1, b: 2 },[]);

  return <MemoizedChildComponent onClick={onClick} data={data} />;
}

これで、React.memoが機能する。このように、React.memo化されたコンポーネントのpropsはuseMemoやuseCallbackを利用する必要があることを意識する必要がある。このことからuseMemoやuseCallback等のhookはReact.memoと一緒に利用することが基本となることがわかる。

無駄な再レンダリングを減らす方法についての整理は以上になる。

[tips]すべてのコンポーネントをReact.memoすれば良いのでは?

React.memoをするということは再レンダリングする前にpropsが前回と変わっているか検証するコストが生まれる。これは、結構コストのかかる処理のようで、適材適所でReact.memoを書かないと逆にパフォーマンスが落ちてしまう。

[tips]useStateのメモ化はどうやるの?

useStateはメモ化しなくてよい。なぜならuseStateはデフォルトでメモ化されている。ただし、useStateのセッターはpropsとして渡さないことが基本であり関数にラップして利用すると思う。のでその関数はuseCallbackでラップして上げる必要がある。ここらへんは実際書くと忘れて詰まることがあるので注意。

Discussion