LoginSignup
192
192

More than 1 year has passed since last update.

Reactパフォーマンス最適化まとめ

Last updated at Posted at 2022-10-03

はじめに

自分は2021年に新卒でWeb系の開発会社にフロントエンジニアとして入社し2022年で2年目になります。

実務ではReact×TypeScriptを利用したフロント周りの開発をメインで行なっています。

今回は、現場で経験したReactアプリのパフォーマンス最適化についてまとめていきます。

この記事の対象者

  • Reactの初心者から中級者
  • Reactのパフォーマンス最適化について学びたい人

この記事の目標

  • Reactのレンダリングの仕組みを理解する
  • Reactのパフォーマンス最適化の方法を知る
  • React.memo, useCallback, useMemoについて理解する

おことわり

  • React.memo, useCallback, useMemoを使うコストについての詳しい解説
  • パフォーマンスの数値的な計測は行いません

上記の2点に関しては参考記事を該当箇所で貼ります。

Reactのレンダリングについて

Reactのパフォーマンス最適化をするにあたってReactのレンダリングの仕組みについて解説します。

まずレンダリングとは、React Docs BETAでは下記のように説明されています。(DeepL翻訳を使用済み)

「レンダリング」とは、React がコンポーネントを呼び出すことです。

  • 最初のレンダリングでは、React はルート コンポーネントを呼び出します。
  • それ以降のレンダリングでは、React はレンダリングのトリガーとなった状態更新の関数コンポーネントを呼び出します。

一言で、Reactが関数コンポーネントを呼び出すことをレンダリングと呼んでいます。

レンダリングの仕組みはReact Docs BETAでは下記のように記載されています。

  1. レンダリングをトリガーする(客の注文を厨房に届ける)
  2. コンポーネントのレンダリング(厨房で注文を準備する)
  3. DOMにコミットする(テーブルに注文を置く)

上記のレンダリングの仕組みを少し噛み砕いて説明していきます。

1. レンダリングをトリガーする

レンダリングのトリガーとなるイベントは下記の2つです

  • 初回レンダリング
  • 画面更新時の再レンダリング

再レンダリングは、stateの更新関数setStateを利用して状態を更新し、コンポーネントの状態を更新することで、次回レンダリングをスケジューリングします。(差分を取得し更新)

2. Reactがコンポーネントをレンダリングする

レンダリングが開始されると、Reactコンポーネントを呼び出し画面に表示する内容を決定します。この時点ではDOMへの反映処理は行っていません。

  • 初回レンダリング: Reactはルートコンポーネントを呼び出す
  • それ以降のレンダリング: 1でトリガーとなった状態更新の関数コンポーネントを呼び出す

更新されたコンポーネントが子コンポーネントを持っている場合、Reactはその子コンポーネントを次にレンダリングしていく。この処理は再起的に行われます。

スクリーンショット 2022-09-29 9.20.05.jpg

今回の場合は親コンポーネント(Parent)でstateの更新が発生した場合、子コンポーネントA, 子コンポーネントB, 子コンポーネントC, 子コンポーネントDでもレンダリングが起こります。

コードでは下記のようになります。

Parent.tsx
// 状態更新が発火する親コンポーネント
export const Parent = () => {
  const [count, setCount] = useState<number>(0);
  const onClick = () => {
    setCount(count + 1);
  };
  return (
    <>
      <button onClick={onClick}>+1</button>
      <ChildA />
      <ChildB />
    </>
  );
};
ChildA.tsx
export const ChildA = () => {
  return (
    <>
      <p>子コンポーネントA</p>
      <ChildC />
    </>
  );
};
ChildB.tsx
export const ChildB = () => {
  return (
    <>
      <p>子コンポーネントA</p>
      <ChildD />
    </>
  );
};

後で詳しく解説をしますが、Parentコンポーネントのstateが更新された場合、Parentでしか使われていない(ChildAからChildDでは依存していない)のに、ChildAからChildDもレンダリング処理が実行されてしまっています。

この不要なレンダリングをなくすことでReactのパフォーマンスを最適化することができます。

3. ReactがDOMに変更をコミット

コンポーネントがレンダリングをした後に、ReactはDOMへの反映を行い更新後のブラウザは画面を再描画します。

以上をまとめるとReactアプリの画面更新は下記の3ステップで行われます

  • レンダリングをトリガー
  • コンポーネントをレンダリング
  • DOMへ変更をコミット

State更新の回避について

React公式ドキュメントより、

現在値と同じ値で更新を行った場合、React は子のレンダーや副作用の実行を回避して処理を終了します。

setStateの引数に現在のstateと同じ値を入れた場合はレンダリングが回避されます。

コードの具体例を見てみます。

export const Parent = () => {
  const [count, setCount] = useState<number>(0);
  const onClick = () => {
    setCount(1);
  };
  console.log("レンダリング");
  return (
    <>
      <button onClick={onClick}>+1</button>
      <p>count: {count}</p>
    </>
  );
};

上記のコンポーネントでは初回レンダリング及び、ボタンクリック時にcountが1に更新されるのでレンダリングが発火します。

それ以降にボタンをクリックしても、現在のstateが1なのに対し、setStateの引数の値も1なので現在地と更新の値が同じであることらレンダリングが発生しません。(回避される)

Reactのパフォーマンス最適化

Reactのパフォーマンス最適化をする上で今回は下記の3つを紹介します。

  • React.memo
  • useCallback
  • useMemo

それぞれ詳しく見ていきます。

React.memo

React.memoは公式ドキュメントで下記のように解説されています。

もしあるコンポーネントが同じ props を与えられたときに同じ結果をレンダーするなら、結果を記憶してパフォーマンスを向上させるためにそれを React.memo でラップすることができます。つまり、React はコンポーネントのレンダーをスキップし、最後のレンダー結果を再利用します。

少し噛み砕くと、React.memoで子コンポーネントでラップすることで、子コンポーネントで受け取る親コンポーネントからのpropsにおいて、値の変更がなかった場合は子コンポーネントはレンダリングがスキップさせることができます。

つまり不要なレンダリングが発火せずにパフォーマンスを上げることができます。

【React.memoの構文】

React.memo(メモ化したいコンポーネント);

親と子で異なるcountを管理および状態更新をするようなコンポーネントを例に確認していきます。

スクリーンショット 2022-09-30 7.19.15.jpg
【React.memoを使わない場合】

Parent.tsx
export const Parent = () => {
  const [parentCount, setParentCount] = useState<number>(0);
  const [childCount, setChildCount] = useState<number>(0);

  const addParentCount = () => {
    setParentCount(parentCount + 1);
  };
  const addChildCount = () => {
    setChildCount(childCount + 1);
  };
  return (
    <>
      <button onClick={addParentCount}>親のカウントを+1</button>
      <p>親のカウント: {parentCount}</p>
      <button onClick={addChildCount}>子のカウントを+1</button>
      <Child count={childCount} />
    </>
  );
};
Child.tsx
type ChildProps = {
  count: number;
};

export const Child: React.FC<ChildProps> = ({ count }) => {
  return (
    <>
      <p>子のcount:{count}</p>
    </>
  );
};

子コンポーネントのカウントを増やすボタンをクリックすると、下記のように親と子の両方レンダリングされていることが確認できます。

スクリーンショット 2022-09-30 7.39.44.jpg

同様に親コンポーネントのカウントを増やすボタンをクリックしてみます。すると同じく親と子の両方がレンダリングされることが確認できます。

スクリーンショット 2022-09-30 7.41.10.jpg

ここで親コンポーネントのparentCountが更新された時、子コンポーネントに依存している値は更新されていないにも関わらずレンダリング処理が走ってしまっています。

この不要なレンダリングを回避するために子コンポーネントをReact.memoでラップします。

Child.tsx
export const Child: React.FC<ChildProps> = ({ count }) => {
  console.log("子供コンポーネントのレンダリング");
  return (
    <>
      <p>子のカウント:{count}</p>
    </>
  );
};

export const ChildMemo = React.memo(Child);

再度、親コンポーネントのカウントを増やすボタンをクリックすると親コンポーネントだけがレンダリングされており、値の更新がないChildMemoコンポーネントはレンダリングされていないことが確認できます。

スクリーンショット 2022-09-30 7.45.52.jpg

以上のようにReact.memoを利用することで、propsで渡ってくる値に変更がない時に不用なレンダリングを回避させパフォーマンスの最適化をおこなうことができます。

useCallback

useCallback公式ドキュメントで下記のように解説されています。

インラインのコールバックとそれが依存している値の配列を渡してください。useCallback はそのコールバックをメモ化したものを返し、その関数は依存配列の要素のいずれかが変化した場合にのみ変化します。
これは、不必要なレンダーを避けるために(例えば shouldComponentUpdate などを使って)参照の同一性を見るよう最適化されたコンポーネントにコールバックを渡す場合に便利です。

少し噛み砕くと、useCallbackの第二引数で指定した依存配列の要素のいずれかが変化した場合のみ、メモ化した値を再計算する。

つまり依存配列の要素が変化しなかった場合は、不必要なレンダリングを避けることができる。

【useCallbackの構文】

useCallback(コールバック関数, 依存配列);

具体的にコードを例に見ていきます。

子コンポーネントにcountに加えて、countの状態を更新する(+1する)関数(onClickChild)をpropsで親から受け取れるようにする。

Child.tsx
type ChildProps = {
  count: number;
  onClickChild: () => void;
};

export const Child: React.FC<ChildProps> = ({ count, onClickChild }) => {
  console.log("子供コンポーネントのレンダリング");
  return (
    <>
      <button onClick={onClickChild}>子のカウントを+1</button>

      <p>子のカウント:{count}</p>
    </>
  );
};
export const ChildMemo = React.memo(Child);
parent.tsx
export const Parent = () => {
  const [parentCount, setParentCount] = useState<number>(0);
  const [childCount, setChildCount] = useState<number>(0);

  const addParentCount = () => {
    setParentCount(parentCount + 1);
  };
  const addChildCount = () => {
    setChildCount(childCount + 1);
  };
  console.log("親コンポーネントのレンダリング");

  return (
    <>
      <button onClick={addParentCount}>親のカウントを+1</button>
      <p>親のカウント: {parentCount}</p>
      <ChildMemo count={childCount} onClickChild={addChildCount} />
    </>
  );
};

スクリーンショット 2022-09-30 22.36.42.jpg

この状態で子のカウントを増やすボタンをクリック(onClickChild)すると、コンソルより親と子コンポーネントの両方がレンダリングされているのが確認できます。

スクリーンショット 2022-09-30 22.37.10.jpg

次に子コンポーネントには依存していない親のcountを更新するボタンをクリックします。

スクリーンショット 2022-09-30 22.38.32.jpg

すると、先ほどコンポーネントをmemo化したのにもかかわらず子コンポーネントもレンダリングされてしまっていることが確認できます。

その理由としては、propsで受け取っているonClickChildが親コンポーネントが再レンダリングされるたびに再計算され、結果としてpropsonClickChildが渡されるタイミングに新しい関数が渡ってきたReact側が認識し、子コンポーネントでもレンダリングが走っている。

そのため、コンポーネント本体をReact.memoでメモ化してもレンダリングが走ってしまう。

この不要なレンダリングを回避するために、propsで受け渡されている関数(onClickChild)をuseCallbackでラップしメモ化する。

Parent.tsx
export const Parent = () => {
  const [parentCount, setParentCount] = useState<number>(0);
  const [childCount, setChildCount] = useState<number>(0);

  const addParentCount = () => {
    setParentCount(parentCount + 1);
  };
  // メモ化
  const addChildCount = useCallback(() => {
    setChildCount(childCount + 1);
  }, []);
  console.log("親コンポーネントのレンダリング");

  return (
    <>
      <button onClick={addParentCount}>親のカウントを+1</button>
      <p>親のカウント: {parentCount}</p>
      <ChildMemo count={childCount} onClickChild={addChildCount} />
    </>
  );
};

同様に親のカウントを増やすボタンを押すと、子のレンダリングが回避されていることが確認できます。

スクリーンショット 2022-09-30 22.49.25.jpg

しかし、子コンポーネントのカウントを増やすボタンをクリックしても1から増えないという問題が起こってしまっています。

スクリーンショット 2022-09-30 22.50.33.jpg

これは、先ほど定義したuseCallbackの第二引数において空配列を渡しているため、初回レンダリング時にuseCallbackでラップした関数がReact内部に保持され続けることが理由で起きている。

コード的には下記のようにsetChildCountの値が1で保持し続けられるので、1から値が更新されないようになってしまっている。

  const [childCount, setChildCount] = useState<number>(0);

  const addChildCount = useCallback(() => {
    // setChildCount(childCount + 1);
    // setChildCount(0 + 1);
       setChildCount(1);
  }, []);

これを防ぐために、useCallbackの第二引数の依存配列に依存するstate(今回はchildCount)を入れることで、childCountの値が更新されたタイミングでuseCallbackの中で定義した関数が再計算される。

  const [childCount, setChildCount] = useState<number>(0);

  const addChildCount = useCallback(() => {
    setChildCount(childCount + 1);
    // 1回目ボタンがクリックされた時
    // setChildCount(1);
    // 2回目ボタンがクリックされた時
    // setChildCount(1+1);
    // 3回目ボタンがクリックされた時
    // setChildCount(2+1);
  }, [childCount]);

以上のようにuseCallbackを使うことで関数をpropsで受け渡す際も不要なレンダリングを回避することができる。

useMemo

useMemoは公式ドキュメントでは下記のように解説されています。

useMemo は依存配列の要素のいずれかが変化した場合にのみメモ化された値を再計算します。この最適化によりレンダー毎に高価な計算が実行されるのを避けることができます。

React.memoはコンポーネントを、useCallbackはコールバック関数をメモ化していましたが、useMemoでは計算結果の値(数値やレンダー結果)をメモ化し、不要なレンダリングを回避することができます。

【useMemoの構文】

useMemo(() => メモ化したい計算ロジック, 依存配列);

具体的に先ほどReact.memoを利用してmemo化したコンポーネント内のJSXをuseMemoを使ってメモ化していきます。

【先ほどReact.memoでメモ化したChildコンポーネント】

type ChildProps = {
  count: number;
  onClickChild: () => void;
};

export const Child: React.FC<ChildProps> = ({ count, onClickChild }) => {
  console.log("子供コンポーネントのレンダリング");
  return (
    <>
      <button onClick={onClickChild}>子のカウントを+1</button>
      <p>子のカウント:{count}</p>
    </>
  );
};

export const ChildMemo = React.memo(Child);

【useMemoでJSXをメモ化】

type ChildProps = {
  count: number;
  onClickChild: () => void;
};

export const Child: React.FC<ChildProps> = ({ count, onClickChild }) => {
  console.log("子供コンポーネントのレンダリング");
  return useMemo(() => {
    console.log("メモ化した値");
    return (
      <>
        <button onClick={onClickChild}>子のカウントを+1</button>
        <p>子のカウント:{count}</p>
      </>
    );
  }, [count, onClickChild]);
};

メモ化の確認がわかりやすいようにuseMemoの内と外にconsoleを入れました。

親コンポーネントのカウント増やしてみます挙動を確認してみます。

スクリーンショット 2022-10-03 8.23.21.jpg

すると、メモ化されていない(useMemoの外側にある)console.log("子供コンポーネントのレンダリング");が実行されていることを確認できます。

先ほど紹介したReact.memoではコンポーネント全体をメモ化していたので、親のカウントを実行した際は子コンポーネントはレンダリングされていませんでした。

スクリーンショット 2022-10-03 8.26.32.jpg

次に子コンポーネントのcountを増やすボタンをクリックしてみます。

スクリーンショット 2022-10-03 8.27.33.jpg

するとuseMemoでメモ化したJSXの値も実行されていることが確認できます。

最後に

いかがだったでしょうか。今回はReactのパフォーマンスを最適化するための基本的な方法をまとめました。

ぜひ今回紹介した手法を用いて、よりパフォーマンスがよい開発をしていただければと思います。

次回は下記のようなデータ通信におけるパフォーマンスの最適化について紹介していきたいと思っています。

  • useQueryやuseSWRを利用したフェッチ処理の最適化

他にもReact周りの記事を出しているので読んでいただけると嬉しいです。

192
192
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
192
192