はじめに
自分は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では下記のように記載されています。
- レンダリングをトリガーする(客の注文を厨房に届ける)
- コンポーネントのレンダリング(厨房で注文を準備する)
- DOMにコミットする(テーブルに注文を置く)
上記のレンダリングの仕組みを少し噛み砕いて説明していきます。
1. レンダリングをトリガーする
レンダリングのトリガーとなるイベントは下記の2つです
- 初回レンダリング
- 画面更新時の再レンダリング
再レンダリングは、state
の更新関数setState
を利用して状態を更新し、コンポーネントの状態を更新することで、次回レンダリングをスケジューリングします。(差分を取得し更新)
2. Reactがコンポーネントをレンダリングする
レンダリングが開始されると、Reactコンポーネントを呼び出し画面に表示する内容を決定します。この時点ではDOMへの反映処理は行っていません。
- 初回レンダリング: Reactはルートコンポーネントを呼び出す
- それ以降のレンダリング: 1でトリガーとなった状態更新の関数コンポーネントを呼び出す
更新されたコンポーネントが子コンポーネントを持っている場合、Reactはその子コンポーネントを次にレンダリングしていく。この処理は再起的に行われます。
今回の場合は親コンポーネント(Parent)でstateの更新が発生した場合、子コンポーネントA, 子コンポーネントB, 子コンポーネントC, 子コンポーネントDでもレンダリングが起こります。
コードでは下記のようになります。
// 状態更新が発火する親コンポーネント
export const Parent = () => {
const [count, setCount] = useState<number>(0);
const onClick = () => {
setCount(count + 1);
};
return (
<>
<button onClick={onClick}>+1</button>
<ChildA />
<ChildB />
</>
);
};
export const ChildA = () => {
return (
<>
<p>子コンポーネントA</p>
<ChildC />
</>
);
};
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 は子のレンダーや副作用の実行を回避して処理を終了します。
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
を管理および状態更新をするようなコンポーネントを例に確認していきます。
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} />
</>
);
};
type ChildProps = {
count: number;
};
export const Child: React.FC<ChildProps> = ({ count }) => {
return (
<>
<p>子のcount:{count}</p>
</>
);
};
子コンポーネントのカウントを増やすボタンをクリックすると、下記のように親と子の両方レンダリングされていることが確認できます。
同様に親コンポーネントのカウントを増やすボタンをクリックしてみます。すると同じく親と子の両方がレンダリングされることが確認できます。
ここで親コンポーネントのparentCount
が更新された時、子コンポーネントに依存している値は更新されていないにも関わらずレンダリング処理が走ってしまっています。
この不要なレンダリングを回避するために子コンポーネントをReact.memo
でラップします。
export const Child: React.FC<ChildProps> = ({ count }) => {
console.log("子供コンポーネントのレンダリング");
return (
<>
<p>子のカウント:{count}</p>
</>
);
};
export const ChildMemo = React.memo(Child);
再度、親コンポーネントのカウントを増やすボタンをクリックすると親コンポーネントだけがレンダリングされており、値の更新がないChildMemo
コンポーネントはレンダリングされていないことが確認できます。
以上のようにReact.memo
を利用することで、props
で渡ってくる値に変更がない時に不用なレンダリングを回避させパフォーマンスの最適化をおこなうことができます。
useCallback
useCallback
は公式ドキュメントで下記のように解説されています。
インラインのコールバックとそれが依存している値の配列を渡してください。useCallback はそのコールバックをメモ化したものを返し、その関数は依存配列の要素のいずれかが変化した場合にのみ変化します。
これは、不必要なレンダーを避けるために(例えば shouldComponentUpdate などを使って)参照の同一性を見るよう最適化されたコンポーネントにコールバックを渡す場合に便利です。
少し噛み砕くと、useCallback
の第二引数で指定した依存配列の要素のいずれかが変化した場合のみ、メモ化した値を再計算する。
つまり依存配列の要素が変化しなかった場合は、不必要なレンダリングを避けることができる。
【useCallbackの構文】
useCallback(コールバック関数, 依存配列);
具体的にコードを例に見ていきます。
子コンポーネントにcount
に加えて、count
の状態を更新する(+1する)関数(onClickChild
)をprops
で親から受け取れるようにする。
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);
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} />
</>
);
};
この状態で子のカウントを増やすボタンをクリック(onClickChild
)すると、コンソルより親と子コンポーネントの両方がレンダリングされているのが確認できます。
次に子コンポーネントには依存していない親のcount
を更新するボタンをクリックします。
すると、先ほどコンポーネントをmemo化したのにもかかわらず子コンポーネントもレンダリングされてしまっていることが確認できます。
その理由としては、props
で受け取っているonClickChild
が親コンポーネントが再レンダリングされるたびに再計算され、結果としてprops
でonClickChild
が渡されるタイミングに新しい関数が渡ってきたReact側が認識し、子コンポーネントでもレンダリングが走っている。
そのため、コンポーネント本体をReact.memo
でメモ化してもレンダリングが走ってしまう。
この不要なレンダリングを回避するために、props
で受け渡されている関数(onClickChild
)をuseCallback
でラップしメモ化する。
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} />
</>
);
};
同様に親のカウントを増やすボタンを押すと、子のレンダリングが回避されていることが確認できます。
しかし、子コンポーネントのカウントを増やすボタンをクリックしても1から増えないという問題が起こってしまっています。
これは、先ほど定義した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
を入れました。
親コンポーネントのカウント増やしてみます挙動を確認してみます。
すると、メモ化されていない(useMemo
の外側にある)console.log("子供コンポーネントのレンダリング");
が実行されていることを確認できます。
先ほど紹介したReact.memoではコンポーネント全体をメモ化していたので、親のカウントを実行した際は子コンポーネントはレンダリングされていませんでした。
次に子コンポーネントのcount
を増やすボタンをクリックしてみます。
するとuseMemo
でメモ化したJSXの値も実行されていることが確認できます。
最後に
いかがだったでしょうか。今回はReactのパフォーマンスを最適化するための基本的な方法をまとめました。
ぜひ今回紹介した手法を用いて、よりパフォーマンスがよい開発をしていただければと思います。
次回は下記のようなデータ通信におけるパフォーマンスの最適化について紹介していきたいと思っています。
- useQueryやuseSWRを利用したフェッチ処理の最適化
他にもReact周りの記事を出しているので読んでいただけると嬉しいです。