Reactのパフォーマンス改善との戦い
現在のReactでは、アプリケーションのパフォーマンスにuseMemo
やuseCallback
やReact.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
というカスタムフックを爆誕させます。
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,
};
};
ちなみに型はこんな感じ
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
に渡しています。
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.tsx
にphotos
をmapで回し、渡しています。
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
は、対象の写真をクリックすると選択されている写真として赤い枠線が付けられる仕様になっています。
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>
);
};
実際のアプリケーションはこんな感じになりました。
なかなかいい感じです。
これのどこが良くないのでしょうか?
実はこのアプリケーションは写真をクリックするたびに親コンポーネントをはじめ、全ての親子コンポーネントが再レンダリングされてしまっています。
さぁ、ここからこのアプリケーションのパフォーマンス改善との戦いです。
バトル開始
まずはとりあえず、手っ取り早く子供コンポーネントたちのメモ化をしちゃいましょう。
コンポーネントをメモ化するには、React.memo
でコンポーネント本体をまるっとラップします。
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";
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
のコールバック関数としてコンポーネントを返して元々コンポーネント名であった変数に代入している形になるので実質コンポーネントが名前がない状態になっています。
名前をつけないといけない理由としては、
デバッグのために、全てのコンポーネントに名前を付けることが推奨されるため
らしいです。
ReactDeveloperTools
のComponents
を見るとコンポーネント自体がメモ化されていることがわかりますね。
ただ、実はこの状態では以前の状態と変わりなく、相変わらず全てのコンポーネントがレンダリングされます。
何故メモ化しているのに再レンダリングされるのか
React.memo
は渡されるProps
の等価性(値が等価であること)をチェックして再レンダリングの判断をしていていて、この等価性はどの程度なのかというと
厳密等価演算子(===)で比較しています。
なので、React.memo
内部では
// 前回のProps === 現在のProps
prevProps === currentProps
上記のようなことがレンダリングのたびに行われているということになります。
この比較でReactが「Propsが変わっているからレンダリング対象だな」と判断して再レンダリングが起きているわけですね。
どこが変更になっているか調査
今回の場合はそこまでProps
の値が少ないのでどの値が変更になってると判断されてレンダリングが行われているのがわかりますが、Props
の値が10個くらいになってくると変更されている値の特定が難しくなってきます。
そこでオススメなのが、このReact.memo
の第二引数で比較関数を取ることができるのですが、その比較関数内でProps
の中身を一つずつ厳密等価演算子で比較して真偽値がfalse
になるものが値が変更になっているものになるのでそれで確かめるという手があります。
ただ、このReact.memo
の第二引数はバグにつながる可能性が高いので必要がない限り定義しないことが推薦されています。なのでこの検証が終わり次第削除する方がよさそうです。
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";
これを実際に操作して検証してみると
レンダリングと同時にReact.memo
の第二引数で記載しているconsole.log
が実行されています。
そして、logの内容ですが
user: true
selectPhoto: false
handleSelectPhoto: true
となっており、現在のselectPhoto
と前回のselectPhoto
が変更されていると判断され、レンダリング対象になっているみたいです。(それはそうですが)
ちなみに、handleSelectPhoto
はuseCallback
でラップしているため、レンダリングのたびに関数が再生成されず同じ関数として扱われます。
もし、useCallback
でラップしていなかったらhandleSelectPhoto
も常にfalse
になります。
ただ、現在、このselectPhoto
は、孫であるGrandchild.tsx
にも渡さないといけないため、今の設計を改める必要がありそうですね。
対応策
原因が判明したため、ここからパフォーマンス改善していきたいと思います。
解決策としては、再レンダリングの原因であるselectPhoto
を変更に関係ないChild.tsx
に渡さないというのが一番よさそうです。
なので、親であるApp.tsx
からChild.tsx
にPropsに渡す際にやり方を変えてみようと思います。
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.tsx
にselectPhoto
を渡し、その他のChild.tsx
にはselectPhotoをundefined
で渡しています。
こうすることでselectPhoto: undefined
を渡されたChild.tsx
はProps
の変更がないと判断され再レンダリングを防ぐことができ、クリックされる度にレンダリングされるのは、前回のクリックされていたユーザー分のChild.tsx
と新しくクリックされたユーザー分のChild.tsx
のみになりました。
selectPhoto: undefined
で渡したChild.tsx
のReact.memo
で記載しているconsole.log
は
user: true
selectPhoto: true
handleSelectPhoto: true
と全てtrue
になっており、レンダリングスキップ対象と判断されていますね。
さらに最適化
実はまだ改善の余地がありそうです。
今度はユーザーが持っている全てのGrandchild.tsx
が再レンダリングされていることが分かります。
ここもほとんど同じやり方で、原因調査
を行いそれを踏まえて対策を行います。
今回は変わっているのはselectPhoto
のみなのでChild.tsx
と同じ方法を取ります。
ただ、今回はユーザーのphoto
データを回しているため、photo
が一つ一つ特定出来るのでselectPhoto
を渡すのではなく、isSelect
という真偽値の値で渡したいと思います。
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";
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
も前回の写真と現在の写真のみ再レンダリングされるようになり、かなり最適化されたと思います。
最後に
当時、Reactのことをよくわかっていなかった自分でしたが、メモ化の仕組みをしっかり勉強することでなんとなくですが、理解度が高まった気がしています。
ただ、いつかこういったメモ化を色々考えるのはめんどくさいので考えなくて良い時代が来ると良いですね