LoginSignup
1
0

More than 1 year has passed since last update.

React Props親子バケツリレーでのパフォーマンス改善

Last updated at Posted at 2022-08-28

Reactのパフォーマンス改善との戦い

現在のReactでは、アプリケーションのパフォーマンスにuseMemouseCallbackReact.memoのメモ化を駆使して最適化を行いますが、どれをメモ化するかとかしないだとか結構な頻度で議論になったりするので最近正直面倒だと感じて大変ですね...
(メモ化するかしないかで議論になるものは大抵どっちにしてもほとんど大差ないのでどっちでも良いと言われればそれはそう)

今回は業務で経験したPropsの親子バケツリレーで起こる問題とその解決した方法を紹介したいと思います。

※ 2022年3月29日にReactv18が正式リリースされました

アプリケーション作成

とりあえずデモ用に似たようなパターンのアプリケーションをサクッと作りました。
ディレクトリ構成は以下のようになっています。

src/
├── components
│   ├── App.tsx
│   ├── Child.tsx
│   └── Grandchild.tsx
├── hooks
│   └── index.ts
├── index.css
├── index.tsx
├── styles.css
└── types
    └── index.ts

デモ処理説明

まず最初に、ダミーデータを取得してState管理するuseDataSourceというカスタムフックを爆誕させます。

hooks/index.ts
import { useState, useEffect, useCallback } from "react";
import { DataSource, SelectPhoto } from "../types";

export const useDataSource = () => {
  const [dataSource, setDataSource] = useState<DataSource[]>([]);
  const [selectPhoto, setSelectPhoto] = useState<SelectPhoto>({
    userId: 0,
    photo: "",
  });

  useEffect(() => {
    const fetchJsonData: DataSource[] = [
      {
        id: 1,
        name: "山田 太郎",
        address: {
          city: "東京都",
        },
        photos: [
          "https://source.unsplash.com/random/200x200?sig=1",
          "https://source.unsplash.com/random/200x200?sig=2",
          "https://source.unsplash.com/random/200x200?sig=3",
        ],
      },
      {
        id: 2,
        name: "鈴木 次郎",
        address: {
          city: "大阪府",
        },
        photos: [
          "https://source.unsplash.com/random/200x200?sig=4",
          "https://source.unsplash.com/random/200x200?sig=5",
          "https://source.unsplash.com/random/200x200?sig=6",
        ],
      },
      {
        id: 3,
        name: "佐藤 三郎",
        address: {
          city: "北海道",
        },
        photos: [
          "https://source.unsplash.com/random/200x200?sig=1",
          "https://source.unsplash.com/random/200x200?sig=4",
        ],
      },
      {
        id: 4,
        name: "田中 四郎",
        address: {
          city: "沖縄県",
        },
        photos: [
          "https://source.unsplash.com/random/200x200?sig=5",
          "https://source.unsplash.com/random/200x200?sig=6",
        ],
      },
      {
        id: 5,
        name: "高橋 五郎",
        address: {
          city: "福岡県",
        },
        photos: ["https://source.unsplash.com/random/200x200?sig=3"],
      },
    ];
    setDataSource(fetchJsonData);
  }, []);

  const handleSelectPhoto = useCallback((id: number, photo: string) => {
    setSelectPhoto({
      userId: id,
      photo,
    });
  }, []);

  return {
    dataSource,
    selectPhoto,
    handleSelectPhoto,
  };
};

ちなみに型はこんな感じ

types/index.ts
export type DataSource = {
  id: number;
  name: string;
  address: {
    city: string;
  };
  photos: string[];
};

export type SelectPhoto = {
  userId: number;
  photo: string;
};

今回はAPIとかではなく直接Jsonデータを入れています。
写真データは、リロードするたびにランダムな画像に切り替わるこちらのサイトの画像を使いました。(便利ね)
このユーザーたちは、idは一人一人ユニークですが、photosの中身の写真URLは他のユーザーと同じものもあったりします。
戻り値で返すhandleSelectPhoto関数は毎回再生成されないようにuseCallbackでラップしメモ化します。

次に一番親であるApp.tsxは、useDataSourceというカスタムフックを呼び出してそのデータをmapで回して子コンポーネントであるChild.tsxに渡しています。

components/App.tsx
import "../styles.css";
import { Child } from "./Child";
import { useDataSource } from "../hooks";

export const App = () => {
  const { dataSource, selectPhoto, handleSelectPhoto } = useDataSource();
  console.log("親 Component", selectPhoto);
  return (
    <div className="App">
      <div className="parent">
        {dataSource.map((user) => {
          return (
            <div className="child" key={user.id}>
              <Child
                user={user}
                selectPhoto={selectPhoto}
                handleSelectPhoto={handleSelectPhoto}
              />
            </div>
          );
        })}
      </div>
      <div>
        <div>選択中の絵</div>
        <img
          className="selectedPhoto"
          src={
            selectPhoto.photo
              ? selectPhoto.photo
              : "https://suryacipta.com/wp-content/themes/consultix/images/no-image-found-360x250.png"
          }
          alt=""
        />
      </div>
    </div>
  );
};

まぁ、よくあるやつですね。

次に、Child.tsxはというとまたまたGrandchild.tsxphotosをmapで回し、渡しています。

components/Child.tsx
import { FC } from "react";
import { DataSource, SelectPhoto } from "../types";
import "../styles.css";
import { Grandchild } from "./Grandchild";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Child: FC<Props> = ({ user, selectPhoto, handleSelectPhoto }) => {
  console.log("子 Component", user.name);
  return (
    <div className="child">
      <div>{`名前: ${user.name}`}</div>
      <div>{`街: ${user.address.city}`}</div>
      <div className="photos">
        {user.photos.map((photo) => {
          return (
            <Grandchild
              key={`${user.id}-${photo}`}
              user={user}
              selectPhoto={selectPhoto}
              photo={photo}
              handleSelectPhoto={handleSelectPhoto}
            />
          );
        })}
      </div>
    </div>
  );
};

最後にGrandchild.tsxは、対象の写真をクリックすると選択されている写真として赤い枠線が付けられる仕様になっています。

components/Grandchild.tsx
import { FC } from "react";
import { DataSource, SelectPhoto } from "../types";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  photo: string;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Grandchild: FC<Props> = ({
  user,
  selectPhoto,
  photo,
  handleSelectPhoto,
}) => {
  console.log("孫 Component", user.address.city);
  return (
    <div
      className={
        selectPhoto.userId === user.id && selectPhoto.photo === photo
          ? "selectedPhotoWarper"
          : "photoWarper"
      }
    >
      <img
        className="photo"
        src={photo}
        alt="ダミー写真"
        onClick={() => {
          handleSelectPhoto(user.id, photo);
        }}
      />
    </div>
  );
};

実際のアプリケーションはこんな感じになりました。
rifakutamae-2.gif
なかなかいい感じです。

これのどこが良くないのでしょうか?

実はこのアプリケーションは写真をクリックするたびに親コンポーネントをはじめ、全ての親子コンポーネントが再レンダリングされてしまっています。

rifakutamae-3.gif

さぁ、ここからこのアプリケーションのパフォーマンス改善との戦いです。

バトル開始

まずはとりあえず、手っ取り早く子供コンポーネントたちのメモ化をしちゃいましょう。

コンポーネントをメモ化するには、React.memoでコンポーネント本体をまるっとラップします。

components/Child.tsx
import { FC, memo } from "react";
import { DataSource, SelectPhoto } from "../types";
import "../styles.css";
import { Grandchild } from "./Grandchild";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Child: FC<Props> = memo(({ user, selectPhoto, handleSelectPhoto }) => {
  console.log("子 Component", user.name);
  return (
    <div className="child">
      <div>{`名前: ${user.name}`}</div>
      <div>{`街: ${user.address.city}`}</div>
      <div className="photos">
        {user.photos.map((photo) => {
          return (
            <Grandchild
              key={`${user.id}-${photo}`}
              user={user}
              selectPhoto={selectPhoto}
              photo={photo}
              handleSelectPhoto={handleSelectPhoto}
            />
          );
        })}
      </div>
    </div>
  );
});
Child.displayName = "Child";
components/Grandchild.tsx
import { FC, memo } from "react";
import { DataSource, SelectPhoto } from "../types";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  photo: string;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Grandchild: FC<Props> = memo(({
  user,
  selectPhoto,
  photo,
  handleSelectPhoto,
}) => {
  console.log("孫 Component", user.address.city);
  return (
    <div
      className={
        selectPhoto.userId === user.id && selectPhoto.photo === photo
          ? "selectedPhotoWarper"
          : "photoWarper"
      }
    >
      <img
        className="photo"
        src={photo}
        alt="ダミー写真"
        onClick={() => {
          handleSelectPhoto(user.id, photo);
        }}
      />
    </div>
  );
});
Grandchild.displayName = "Grandchild";

ちなみにコンポーネントの最後に定義しているdisplayNameはコンポーネントに名前をつけるためです。
eslint入れてると、react/display-nameで以下のように怒られます。

Component definition is missing display name

既に名前ついてんじゃん!と思うかもですが、React.memoでコンポーネントをラップすると、React.memoのコールバック関数としてコンポーネントを返して元々コンポーネント名であった変数に代入している形になるので実質コンポーネントが名前がない状態になっています。
名前をつけないといけない理由としては、

デバッグのために、全てのコンポーネントに名前を付けることが推奨されるため

らしいです。

ReactDeveloperToolsComponentsを見るとコンポーネント自体がメモ化されていることがわかりますね。
スクリーンショット 2022-08-28 8.56.20.png

ただ、実はこの状態では以前の状態と変わりなく、相変わらず全てのコンポーネントがレンダリングされます。

何故メモ化しているのに再レンダリングされるのか

React.memoは渡されるPropsの等価性(値が等価であること)をチェックして再レンダリングの判断をしていていて、この等価性はどの程度なのかというと
厳密等価演算子(===)で比較しています。
なので、React.memo内部では

// 前回のProps === 現在のProps
prevProps === currentProps

上記のようなことがレンダリングのたびに行われているということになります。
この比較でReactが「Propsが変わっているからレンダリング対象だな」と判断して再レンダリングが起きているわけですね。

どこが変更になっているか調査

今回の場合はそこまでPropsの値が少ないのでどの値が変更になってると判断されてレンダリングが行われているのがわかりますが、Propsの値が10個くらいになってくると変更されている値の特定が難しくなってきます。

そこでオススメなのが、このReact.memoの第二引数で比較関数を取ることができるのですが、その比較関数内でPropsの中身を一つずつ厳密等価演算子で比較して真偽値がfalseになるものが値が変更になっているものになるのでそれで確かめるという手があります。
ただ、このReact.memoの第二引数はバグにつながる可能性が高いので必要がない限り定義しないことが推薦されています。なのでこの検証が終わり次第削除する方がよさそうです。

components/Child.tsx
import { FC, memo } from "react";
import { DataSource, SelectPhoto } from "../types";
import "../styles.css";
import { Grandchild } from "./Grandchild";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Child: FC<Props> = memo(
  ({ user, selectPhoto, handleSelectPhoto }) => {
    console.log("子 Component userId", user.id);
    return (
      <div className="child">
        <div>{`名前: ${user.name}`}</div>
        <div>{`街: ${user.address.city}`}</div>
        <div className="photos">
          {user.photos.map((photo) => {
            return (
              <Grandchild
                key={`${user.id}-${photo}`}
                user={user}
                selectPhoto={selectPhoto}
                photo={photo}
                handleSelectPhoto={handleSelectPhoto}
              />
            );
          })}
        </div>
      </div>
    );
  },
  // このReact.memoの第二引数には、前回のPropsと今回のPropsを受け取ることができる
  // 戻り値はboolean型で、trueを返すと再レンダリングを行わない
  (prevProps, nextProps) => {
    console.log("user: ", prevProps.user === nextProps.user);
    console.log("selectPhoto: ", prevProps.selectPhoto === nextProps.selectPhoto);
    console.log("handleSelectPhoto: ", prevProps.handleSelectPhoto === nextProps.handleSelectPhoto);
    return (
      prevProps.user === nextProps.user &&
      prevProps.selectPhoto === nextProps.selectPhoto &&
      prevProps.handleSelectPhoto === nextProps.handleSelectPhoto
    );
  }
);
Child.displayName = "Child";

これを実際に操作して検証してみると
rifakutamae-4.gif
レンダリングと同時にReact.memoの第二引数で記載しているconsole.logが実行されています。
そして、logの内容ですが

user: true
selectPhoto: false
handleSelectPhoto: true

となっており、現在のselectPhotoと前回のselectPhotoが変更されていると判断され、レンダリング対象になっているみたいです。(それはそうですが)
ちなみに、handleSelectPhotouseCallbackでラップしているため、レンダリングのたびに関数が再生成されず同じ関数として扱われます。
もし、useCallbackでラップしていなかったらhandleSelectPhotoも常にfalseになります。

ただ、現在、このselectPhotoは、孫であるGrandchild.tsxにも渡さないといけないため、今の設計を改める必要がありそうですね。

対応策

原因が判明したため、ここからパフォーマンス改善していきたいと思います。

解決策としては、再レンダリングの原因であるselectPhotoを変更に関係ないChild.tsxに渡さないというのが一番よさそうです。

なので、親であるApp.tsxからChild.tsxにPropsに渡す際にやり方を変えてみようと思います。
App.tsxを以下のように変更しました。

components/App.tsx
import "../styles.css";
import { Child } from "./Child";
import { useDataSource } from "../hooks";

export const App = () => {
  const { dataSource, selectPhoto, handleSelectPhoto } = useDataSource();
  return (
    <div className="App">
      <div className="parent">
        {dataSource.map((user) => {
          return (
            <div className="child" key={user.id}>
              <Child
                user={user}
                handleSelectPhoto={handleSelectPhoto}
                // UserIdが一致した場合だけselectPhotoを渡して、その他のChildにはelectPhotoにundefinedを渡す
                {...(selectPhoto.userId === user.id
                  ? {
                      selectPhoto,
                    }
                  : {
                      selectPhoto: undefined,
                    })}
              />
            </div>
          );
        })}
      </div>
      <div>
        <div>選択中の絵</div>
        <img
          className="selectedPhoto"
          src={
            selectPhoto.photo
              ? selectPhoto.photo
              : "https://suryacipta.com/wp-content/themes/consultix/images/no-image-found-360x250.png"
          }
          alt=""
        />
      </div>
    </div>
  );
};

実は、コンポーネントに渡すPropsは直接オブジェクトで渡すこともできるので、dataSourceで回しているユーザーデータで、現在の選択しているユーザーIDと一致したもののみChild.tsxselectPhotoを渡し、その他のChild.tsxにはselectPhotoをundefinedで渡しています。

こうすることでselectPhoto: undefinedを渡されたChild.tsxPropsの変更がないと判断され再レンダリングを防ぐことができ、クリックされる度にレンダリングされるのは、前回のクリックされていたユーザー分のChild.tsxと新しくクリックされたユーザー分のChild.tsxのみになりました。
rifakutamae-5.gif

selectPhoto: undefinedで渡したChild.tsxReact.memoで記載しているconsole.log

user:  true
selectPhoto:  true
handleSelectPhoto:  true

と全てtrueになっており、レンダリングスキップ対象と判断されていますね。

さらに最適化

実はまだ改善の余地がありそうです。
今度はユーザーが持っている全てのGrandchild.tsxが再レンダリングされていることが分かります。
sample-1.gif
ここもほとんど同じやり方で、原因調査を行いそれを踏まえて対策を行います。
今回は変わっているのはselectPhotoのみなのでChild.tsxと同じ方法を取ります。

ただ、今回はユーザーのphotoデータを回しているため、photoが一つ一つ特定出来るのでselectPhotoを渡すのではなく、isSelectという真偽値の値で渡したいと思います。

components/Child.tsx
import { FC, memo } from "react";
import { DataSource, SelectPhoto } from "../types";
import "../styles.css";
import { Grandchild } from "./Grandchild";

type Props = {
  user: DataSource;
  selectPhoto: SelectPhoto | undefined;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Child: FC<Props> = memo(
  ({ user, selectPhoto, handleSelectPhoto }) => {
    console.log("子 Component userId", user.id);
    return (
      <div className="child">
        <div>{`名前: ${user.name}`}</div>
        <div>{`街: ${user.address.city}`}</div>
        <div className="photos">
          {user.photos.map((photo) => {
            return (
              <Grandchild
                key={`${user.id}-${photo}`}
                user={user}
                photo={photo}
                handleSelectPhoto={handleSelectPhoto}
                // 選択されているかどうかのみを渡す
                {...(selectPhoto && selectPhoto.photo === photo
                  ? {
                      isSelect: true,
                    }
                  : {
                      isSelect: false,
                    })}
              />
            );
          })}
        </div>
      </div>
    );
  }
);
Child.displayName = "Child";
components/Grandchild.tsx
import { FC, memo } from "react";
import { DataSource } from "../types";

type Props = {
  user: DataSource;
  isSelect: boolean;
  photo: string;
  handleSelectPhoto: (id: number, photo: string) => void;
};

export const Grandchild: FC<Props> = memo(
  ({ user, isSelect, photo, handleSelectPhoto }) => {
    console.log("孫 Component photo", photo);
    return (
      <div className={isSelect ? "selectedPhotoWarper" : "photoWarper"}>
        <img
          className="photo"
          src={photo}
          alt="ダミー写真"
          onClick={() => {
            handleSelectPhoto(user.id, photo);
          }}
        />
      </div>
    );
  }
);
Grandchild.displayName = "Grandchild";

これでChild.tsxと同じくGrandchild.tsxも前回の写真と現在の写真のみ再レンダリングされるようになり、かなり最適化されたと思います。
sample-2.gif

最後に

当時、Reactのことをよくわかっていなかった自分でしたが、メモ化の仕組みをしっかり勉強することでなんとなくですが、理解度が高まった気がしています。

ただ、いつかこういったメモ化を色々考えるのはめんどくさいので考えなくて良い時代が来ると良いですね

1
0
0

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
1
0