【React】パフォーマンスの最適化について

この記事は、 KENTEM TechBlog アドベントカレンダー2023 12日目、12月18日の記事です。

こんにちは、E.Kです。

Reactを学び始めてまだ2週間程度の超初心者ですが、 パフォーマンスの最適化についてご紹介させていただければと思います。

はじめに

画面更新の流れ

Reactでは画面を更新すると、以下の流れで進んでいきます。

  1. トリガー:何かしらのきっかけによりレンダリングの予約を行うこと。
  2. レンダリング:コンポーネントを呼び出すこと。
  3. コミット:DOMへの更新を行うこと。

上記の仕組みについて、もう少し詳しく説明していきます。

トリガー

レンダリングがトリガーされるタイミングとしては、以下の3つです。

  1. 初回
  2. 状態(state)の値が変更されたとき
  3. 親コンポーネントが再レンダリングされた時

2については、①stateで保持している値②setState()によって渡された値に差が生じた場合、レンダリングがスケジュールされます。 その差が生じているかどうかについては、Object.is(①, ②)の結果が同じかどうかで確認することができます。

レンダリング

初回レンダリングではルートコンポーネントを呼び出し、再レンダリング時では、トリガーとなったコンポーネントを呼び出します。

再レンダリング時に気にしていただきたいのが、stateの差分を検知したコンポーネントの子コンポーネントも再レンダリングされてしまうことです。 差分を検知したコンポーネントでしかstateが使われていない場合でも、子コンポーネントがレンダリングされてしまうので、この不要なレンダリングをなくすことで、パフォーマンスの最適化をすることができます。

コンポーネントツリー

コミット

レンダリング後、ReactはReact要素の差分のみをDOMに反映します。

最適化の方法

レンダリング処理において、パフォーマンスを最適化するものを3つご紹介します。

  • React.memo

メモ化したいコンポーネントに対して使用する。 受け取ったpropsの値が同じであれば、再レンダリングは行わない。

  • useCallback

コンポーネント内で定義した関数をメモ化(≒キャッシュ化)して再利用することで、レンダリング時に再定義されない。 ただし、第2引数で設定した依存配列の要素が変化した場合のみ再計算される。

  • useMemo

コンポーネントだけでなく値をメモ化することが可能で、コストの高い処理の時に使用される。 useCallbackと同様に、第2引数で設定した依存配列の要素が変化した場合のみ再計算される。 ただしデメリットとして、useMemo自体の実行にもコストがかかるため、重い処理に適用するのが良い。

上記3つの方法について、実際にサンプルコードを用いて検証したいと思います。

実践

以下に親と子で異なるcountの状態を管理するアプリケーションを用意しました。 レンダリング処理が行われた際に、そのコンポーネント名をコンソールに出力するようなコードとなっているので、こちらで確認していきます。

最適化前

import { useState } from "react";
import Child from "./components/Child";
import "./Parent.css";

const Parent = () => {
  console.log("parent");
  const [parentCount, setParentCount] = useState(0);
  const [childCount, setChildCount] = useState(0);
  return (
    <div className="parent">
      <div>
        <h3>親コンポーネント</h3>
        <button onClick={() => setParentCount((prev) => prev + 1)}>
          親のcountボタン
        </button>
        <br></br>
        <button onClick={() => setChildCount((prev) => prev + 1)}>
          子のcountボタン
        </button>
        <div>親のcount状態:{parentCount}</div>
      </div>
      <Child count={childCount} />
    </div>
  );
};
export default Parent;
const Child = ({ count }) => {
  console.log("child");
  return (
    <div className="child">
      <h3>子コンポーネント</h3>
      <span>子のcount状態:{count}</span>
    </div>
  );
};
export default Child;

コンソール画面を開いて子コンポーネントのcountボタンをクリックすると、以下のような結果になります。

これは、子のボタンを押したときにParent.js内のsetChildCount()が呼ばれて状態更新がされているので、parentとchildが共に呼ばれているのは正しい結果となっています。 続いて、親コンポーネントのcountボタンをクリックしてみます。

結果を見ると、親のcount状態は親コンポーネントでしか管理していないにも関わらず、子コンポーネントの方も再レンダリングされていることが分かります。

この不要なレンダリングを防止するために、React.memoを用いて最適化を行っていきます。

React.memo

下記のように、メモ化したいコンポーネントをReact.memo()で囲うように記述します。

import React from "react";

const Child = React.memo(({ count }) => {
  console.log("child");
  return (
    <div className="child">
      <h3>子コンポーネント</h3>
      <span>子のcount状態:{count}</span>
    </div>
  );
});
export default Child;

実行して親コンポーネントのcountボタンをクリックします。

すると、parentのみが出力され、子コンポーネントは再レンダリングされなくなったのが分かると思います。 このようにReact.memoを用いることで、渡ってきたpropsの値に変更がない場合に再レンダリング処理を防止し、最適化を行うことができます。

useCallback

続いて、useCallbackを用いた最適化をご紹介します。 その前に、ソースコードを少し変更します。Parent.js内で子コンポーネントのcountボタンを持っていましたが、Child.jsで持たせるようにします。

const Parent = () => {
  console.log("parent");
  const [parentCount, setParentCount] = useState(0);
  const [childCount, setChildCount] = useState(0);
  const clickHandler = () => {
    setChildCount((prev) => prev + 1);
  };
  return (
    <div className="parent">
      <div>
        <h3>親コンポーネント</h3>
        <button onClick={() => setParentCount((prev) => prev + 1)}>
          親のcountボタン
        </button>
        <div>親のcount状態:{parentCount}</div>
      </div>
      <Child count={childCount} onClick={clickHandler} />
    </div>
  );
};
export default Parent;
const Child = React.memo(({ count, onClick }) => {
  console.log("child");
  return (
    <div className="child">
      <h3>子コンポーネント</h3>
      <button onClick={onClick}>子のcountボタン</button>
      <div>子のcount状態:{count}</div>
    </div>
  );
});
export default Child;

この状態で実行して、親コンポーネントのcountボタンを押してみます。

すると、parentとchildの両方がコンソール出力されているのがわかります。 先ほどReact.memoで子コンポーネントの再レンダリングを防止したにもかかわらず、また再レンダリングが走ってしまっていることがわかります。

これは、初回レンダリング時に子コンポーネントに渡されたclickHandler()と親コンポーネントが再レンダリングされた際に子コンポーネントへ渡されたclickHandler()が異なるためです。もう少し細かく話すと、親コンポーネントが再レンダリングされた際にclickHandler()が再度定義されるため、初回レンダリング時に渡されたclickHandler()と再定義されたclickHandler()は別物として処理されてしまいます。

そこで、useCallbackを用いてメモ化したい関数を囲い、レンダリング時に再定義されないようにします。 ※useCallbackはuseCallback(メモ化したい関数, 依存配列)と記述し、第2引数にセットした値が変更された場合に第1引数の関数が再計算されるようになりますが、今回はその例を割愛させていただきます。

import { useState, useCallback } from "react";
import Child from "./components/Child";
import "./Parent.css";

const Parent = () => {
  console.log("parent");
  const [parentCount, setParentCount] = useState(0);
  const [childCount, setChildCount] = useState(0);
  const clickHandler = useCallback(() => {
    setChildCount((prev) => prev + 1);
  }, []);
  return (
    <div className="parent">
      <div>
        <h3>親コンポーネント</h3>
        <button onClick={() => setParentCount((prev) => prev + 1)}>
          親のcountボタン
        </button>
        <div>親のcount状態:{parentCount}</div>
      </div>
      <Child count={childCount} onClick={clickHandler} />
    </div>
  );
};
export default Parent;

これで実行して、親コンポーネントのcountボタンをクリックします。

コンソール結果を見ると、parentのみが出力されて、子コンポーネントが再レンダリング処理されないようになったことが分かります。 このようにuseCallbackを用いることで、定義した関数をメモ化して再利用し、再レンダリング時に再度定義させないことで、パフォーマンスの最適化を図ることができます。

useMemo

最後にuseMemoについてご紹介します。 React.memoがメモ化したいコンポーネントに行うのに対し、useMemoはメモ化したいに対して行います。 実際コードに書くと以下のようになります。値に対してメモ化していることがわかるように、メモ化した値の処理が走った時に「child useMemo」とコンソール出力されるようにしておきます。

import { useMemo } from "react";

const Child = ({ count, onClick }) => {
  console.log("child");
  return useMemo(() => {
    console.log("child useMemo");
    return (
      <div className="child">
        <h3>子コンポーネント</h3>
        <button onClick={onClick}>子のcountボタン</button>
        <div>子のcount状態:{count}</div>
      </div>
    );
  }, [count]);
};
export default Child;

第2引数にはuseCallbackと同様に依存配列をセットします。今回はcountが変更されたときに再レンダリングしてほしいので、countをセットしています。onClickについては、その関数の中でcountのstateに依存しているわけではないので今回セットは不要です。この状態で実行し、親コンポーネントのcountボタンをクリックします。

コンソール結果を見ると、useMemoで囲われた部分が再レンダリングされていないことが分かります。ただ、子コンポーネントに対しては再レンダリングされているところについては注意してください。 このようにuseMemoを使うことで、コンポーネント単位ではなくJSX等の値単位でも、メモ化して最適化を行うことが可能です。

まとめ

このように、Reactではパフォーマンスを最適化するためのものがいくつか用意されています。今回紹介できませんでしたが、他にもReact18から導入された「useTransition」や「useDeferredValue」というUIのパフォーマンスを最適化するものもあるみたいです。もし興味のある方は調べてみてください。

KENTEMでは、様々な拠点でエンジニアを大募集しています!
建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。
https://hrmos.co/pages/kentem2211/jobs/A0008hrmos.co hrmos.co hrmos.co hrmos.co hrmos.co hrmos.co hrmos.co hrmos.co