React dev toolsのprofilerを利用してパフォーマンス改善していく流れ
はじめに
この記事では、サンプルでReactのオモオモコードを作ってみたので、そのコードを使って順々にパフォーマンスを改善していく流れをお伝えしつつ、Reactで重要なパフォーマンスの考え方や観点の基礎を自分なりにまとめてみます!
Reactのパフォーマンス改善に有用なprofilerを利用した改善方法について知りたい方や、基本のパフォーマンス改善ポイントを知りたい方はぜひ読んでもらえたら嬉しいです。技術的には、メモ化やrefの話が出てきます。
- Reactのデバッグに優れたchrome拡張機能であるreact dev toolsのprofilerを利用した調査方法
- 再レンダーに関するパフォーマンス観点やポイント
今回取り扱うサンプルコードはこちらのリポジトリに置いてみましたので、本記事を読んでもし気になれば、ぜひご覧ください。
また、本記事はタイトルの通り、パフォーマンス改善の流れをprofilerを利用してざっくり説明することを意図しています。各セクションにおける詳細な説明は省かせていただく場合がありますが、その際は自分も参考にさせていただいた経験のある、とてもわかりやすい他記事をリンクにさせていただきました!
前提: 知っておきたいこと
まずは、自分なりに日頃からReactのパフォーマンスについて調べていて考えた、パフォーマンス改善において知っておきたい2つのポイントについてお伝えします。
ボトルネックの計測から入ること
そんなのわかってる!って感じかもしれませんが。
以下はバックエンド向けのパフォーマンスチューニングに関する書籍です。
「達人が教える Web パフォーマンスチューニング 〜ISUCON から学ぶ高速化の実践」
私はこの本に以前大変お世話になっており、何度色んな人に宣伝したかわからないのですが(笑)、この本は技術的な手法はもちろんですが、パフォーマンスの根本的な計測・調査→改善→計測の流れについて、とてもわかりやすく教えてくれます。
もしパフォーマンスについて興味がある方で、まだ読んだことがない方は、ぜひ読んでみてもらえたらと思います!
基本的にはReactのパフォーマンスチューニングも流れは全く同じで、ツールを利用してボトルネック(どこが最も遅いのか)を計測し、改善したものを再計測し、改善します。1つの改善が終わると必ず次のボトルネックが見つかるので、そのボトルネックを再度つぶしにかかる…その繰り返しだなと思っています。
Reactが画面を表示する仕組みを理解すること
profiler自体には、Reactのレンダリングの仕組みを理解していないとイマイチよくわからない文脈があるなと思っています。そのため、パフォーマンスチューニングを行うことになった際には、ぜひReactはどのようにして画面を表示するのかを確認・おさらいしてみることをおすすめします。
おすすめは、「Adding Interactivity」あたりの公式ドキュメントです。Reactがどのような流れで画面へ表示されるのか、その仕組みについてとってもわかりやすく説明してくれます。もしもまだ読んだことがない方は、ぜひ読んでみてください。
以上を踏まえた上で、実際のサンプルコードをもとにprofilerを動かしながらボトルネックを計測し、パフォーマンス改善していきます。
profilerの利用方法
profilerの利用方法についてはたくさんの記事があるため、本記事を読んでprofilerについてより知りたいと思った方は、ぜひ詳細に利用方法を説明されている以下のおすすめ記事を確認してみてください。本記事では流れを説明することに重きを置いているため、詳細なprofilerの説明や利用方法は割愛させてもらいます。
おすすめ記事
今はもう更新がされていないので、ちょっと古いですが。
以下のあたりを参考にしつつ、使い方を学ばせてもらいました。
最初のコード
それではまずは簡単に最初のコードの大枠をざっと紹介します。
import { FC, useState } from "react";
import { Form } from "./Form";
import { Box } from "./Box";
export const Page: FC = () => {
const [selected, setSelected] = useState<number>(0);
const [inputText, setInputText] = useState<string>("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setInputText(e.target.value);
const handleSubmit = () => {
alert(`inputText is ${inputText}`);
};
const processedBox = Array.from(new Array(10_000), (_, i) => `box${i}`).map(
(_, index) => {
const added = Array.from(new Array(index), (_, i) => i).reduce(
(prev, current) => prev + current,
0
);
return `p_box_${index}_${added}`;
}
);
return (
<>
<h2>page1 component only</h2>
<Form
inputText={inputText}
onChange={handleChange}
onSubmit={handleSubmit}
/>
<Box
processedBox={processedBox}
changeSelected={setSelected}
selected={selected}
/>
</>
);
};
import { FC } from "react";
type FormProps = {
inputText: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSubmit: () => void;
};
export const Form: FC<FormProps> = ({ inputText, onChange, onSubmit }) => {
return (
<div
style={{
padding: "20px 0",
}}>
<p>input!</p>
<input type="text" value={inputText} onChange={onChange} />
<button onClick={onSubmit}>submit</button>
</div>
);
};
import { FC } from "react";
type BoxProps = {
processedBox: string[];
changeSelected: (index: number) => void;
selected: number;
};
export const Box: FC<BoxProps> = ({
processedBox,
changeSelected,
selected,
}) => {
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
maxWidth: "600px",
flexDirection: "row",
}}>
{processedBox.map((item, index) => {
const added = Array.from(new Array(index), (_, i) => i).reduce(
(prev, current) => prev + current,
0
);
return (
<div
onClick={() => changeSelected(index)}
style={{
width: "calc(20% - 30px)",
margin: "5px",
padding: "20px 5px",
border: "1px solid black",
backgroundColor: index === selected ? "red" : "white",
}}
key={index}>
{item}_{added}
</div>
);
})}
</div>
);
};
単純なコードですが、初回の表示まで4秒かかるというCoreWebVitalsも真っ青のオモオモコードになりました。また、input要素への入力やボックスのクリックもっさい感じですね。初期レンダー時に5秒ほどかかってますが、フォーム画面に入力したりする時やボックスをクリックする時などもかなり重い感じになっています。
パフォーマンス改善
STEP1
実際にprofilerを利用して計測してみた結果がこちらです。profilerを起動して、1:ページを表示、2:フォームに「あ」1文字を入力、3:ボックスをクリックの3つの動作をしてみました。
結果が表示されたので、まずはprofilerツールの右上部分を見てみます。
これはコミット(レンダー回数とほぼ同義)が、初回レンダーを含めて3回行われていること、黄色い色の棒グラフがほぼ横ばいで3つ並んでいることから、初回レンダーにかかるのと同じ時間が再レンダーにかかっているのがわかります。また、各レンダリングにはおよそ4秒がかかっていることがわかりました。
さらに、黄色い棒グラフ部分の2つめや3つめをクリックして、再レンダー時の様子を見てみます。
-
フォーム入力時
-
ボックスクリック時
(ちょっと原因不明でForm
コンポーネントの方が表示されていないので少々わかりにくいのですが…たぶんForm
のレンダリングにあんまり時間がかからないせいかも)、フォーム入力時もボックスクリック時も、Page1コンポーネントが再レンダーされているのがわかります(赤線で囲んでいる横向きバーが黄色の場合、そのコンポーネントが再レンダリングされていることを示しています)。
今3つのコンポーネントのレンダリング秒数がすべて同じになっていますが、フォーム入力時はForm
コンポーネントだけ、ボックスクリック時はBox
コンポーネントだけ、再レンダリングして欲しいですよね。
Page1
コンポーネントは、現状Page
は今はForm
とBox
を表示しているのみですが、もしPage
でも多くの計算処理やAPI取得処理が入っていた場合、フォームやボックスを操作した際に親コンポーネントも再レンダリングされたら…今よりさらにパフォーマンスが落ちます。
そこでもう一度コンポーネントをよくみてみると、フォーム入力やボタンクリック時に、親コンポーネントであるPage
で利用しているselected,inputTextなどに更新があるため、親コンポーネントも再レンダーがかかっていることがわかっています。そしてこれらは、それぞれの子要素で定義・利用すれば良いことに気づきます。
Reactでは、基本的に無駄なPropsの受け渡しが奨励されていません。理由は今回のように、Propsに変更があった際は親コンポーネントも再レンダーが走るため、場合によっては無駄な再レンダーにつながるためです。
それでは、思い切ってPropsの受け渡しをやめてみます。
import { FC } from "react";
import { Form } from "./Form";
import { Box } from "./Box";
export const Page2: FC = () => {
return (
<>
<h2>Page2 optimize props</h2>
<Form />
<Box />
</>
);
};
import { FC, useState } from "react";
export const Form: FC = () => {
const [inputText, setInputText] = useState<string>("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setInputText(e.target.value);
const handleSubmit = () => {
alert(`inputText is ${inputText}`);
};
return (
<div
style={{
padding: "20px 0",
}}>
<p>input!</p>
<input type="text" value={inputText} onChange={handleChange} />
<button onClick={handleSubmit}>submit</button>
</div>
);
};
import { FC, useState } from "react";
export const Box: FC = () => {
const [selected, setSelected] = useState<number>(0);
const processedBox = Array.from(new Array(10_000), (_, i) => `box${i}`).map(
(_, index) => {
const added = Array.from(new Array(index), (_, i) => i).reduce(
(prev, current) => prev + current,
0
);
return `p_box_${index}_${added}`;
}
);
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
maxWidth: "600px",
flexDirection: "row",
}}>
{processedBox.map((item, index) => {
const added = Array.from(new Array(index), (_, i) => i).reduce(
(prev, current) => prev + current,
0
);
return (
<div
onClick={() => setSelected(index)}
style={{
width: "calc(20% - 30px)",
margin: "5px",
padding: "20px 5px",
border: "1px solid black",
backgroundColor: index === selected ? "red" : "white",
}}
key={index}>
{item}_{added}
</div>
);
})}
</div>
);
};
シンプルですが、Propsはそれぞれの子要素で入れるよう変更してみました。画面を動かしてみてみます。以下は先ほどと同じように、1:ページを表示、2:フォームに「あ」1文字を入力、3:ボックスをクリックの3つの動作をしてみました。
ボックスクリックはまだ遅いままですが、明らかにフォーム入力が早くなっているのがわかります。それでは、profilerも録画してみましょう。
体感速度と同じく、2つめのコミット(フォーム入力)が明らかに早くなり、3コミット分の棒グラフに凸凹ができたのがわかります。理由は、無駄なPropsをやめたことにより、再レンダーが各コンポーネントに閉じたためです。
-
フォーム入力時
-
ボックスクリック時
右側のCommit Informationにて、再レンダーのトリガとなっているのがどのコンポーネントなのかを教えてくれる場所があります。そこを見てみると、それぞれのコンポーネント自体に再レンダーの影響が閉じたことが確認できました。そのため、元々レンダリングに時間がかからないForm
の方が高速になったと言えそうです。
余談ですが、profilerにはボトルネックを調べるために有用なRanked chartという機能があります。今回はコンポーネント数が少ないため、あまり詳細に調べずともボトルネックになっているコンポーネントがどこか見つけることができます。しかし、コンポーネントが何十、何百と増えていった際には、Ranked chartを見ることによって、遅いコンポーネントをより早く特定することができそうです。
また、レンダーの仕組みとpropsの影響との関係を知ってコンポーネントを適切に切ることによって、パフォーマンス改善時にボトルネックが見つけやすくなるのも良いですよね!
STEP2
(もはや最初から怪しさ満点ではありましたが…)Propsを分けたことで、重めの処理がどうやらBox
の方にあるらしいということがわかりました。それでは、改めてコードを見てみます。
export const Box: FC = () => {
const [selected, setSelected] = useState<number>(0);
const processedBox = Array.from(new Array(10_000), (_, i) => `box${i}`).map(
(_, index) => {
const added = Array.from(new Array(index), (_, i) => i).reduce(
(prev, current) => prev + current,
0
);
return `p_box_${index}_${added}`;
}
);
// ~ ~ ~
// ~ 省略 ~
// ~ ~ ~
{processedBox.map((item, index) => {
const added = Array.from(new Array(index), (_, i) => i).reduce(
(prev, current) => prev + current,
0
);
このへんの計算処理、かなり色々ループしながら計算しているので、なんとも怪しそうですよね。
ここでなぜこのコードが重い再レンダーを引き起こすのかというと、Reactのレンダリングの仕組みが関わっています。Reactはコンポーネントが再レンダリングされると、Reactコンポーネント内に定義されているすべての処理を再計算することになります。
しかし今回のprocessedBox
は、どの 再レンダー時に計算されても結果は全く同じですよね? なので、再レンダー時に計算し直すのは全く無駄な処理です。そのような時にはメモ化という技術が役に立ちます。メモ化することで、再レンダー時に同じ計算をスキップしてくれるようになります。
今回はhooksの1つであるuseMemo
においてコンポーネント内の特定の関数をメモ化し、またReact.memo
関数を利用して特定のコンポーネントをまるっとメモ化してみようと思います。
コードは以下のような感じになりました。今回はBox
のみメモ化の改修を行いました。
import { FC, useMemo, useState } from "react";
import { BoxChild } from "./BoxChild";
export const Box: FC = () => {
const [selected, setSelected] = useState<number>(0);
const processedBox = useMemo(() => {
return Array.from(new Array(10_000), (_, i) => `box${i}`).map(
(_, index) => {
const added = Array.from(new Array(index), (_, i) => i).reduce(
(prev, current) => prev + current,
0
);
return `p_box_${index}_${added}`;
}
);
}, []);
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
maxWidth: "600px",
flexDirection: "row",
}}>
{processedBox.map((item, index) => (
<BoxChild
index={index}
item={item}
isSelected={index === selected}
onChangeSelected={setSelected}
key={index}
/>
))}
</div>
);
};
import { FC, memo } from "react";
type BoxChildProps = {
item: string;
index: number;
isSelected: boolean;
onChangeSelected: (index: number) => void;
};
export const BoxChild: FC<BoxChildProps> = memo(
({ index, isSelected, onChangeSelected, item }) => {
const added = Array.from(new Array(index), (_, i) => i).reduce(
(prev, current) => prev + current,
0
);
return (
<div
onClick={() => onChangeSelected(index)}
style={{
width: "calc(20% - 30px)",
margin: "5px",
padding: "20px 5px",
border: "1px solid black",
backgroundColor: isSelected ? "red" : "white",
}}>
{item}_{added}
</div>
);
}
);
Box
コンポーネントにおけるprocessedBox
をuseMemo
を利用してメモ化し、子要素に切り出したBoxChild
はmemo
関数でラップすることによりコンポーネント全体をメモ化しました。結果を見てみます。
初回レンダーは相変わらずもっさい感じですが、再レンダーは非常に早くなりました!
以下がprofilerの結果です。こちらは、例によって1:ページを表示、2:フォームに「あ」1文字を入力、3:ボックスをクリックのうち、3つめのコミットを示しています。メモ化により再レンダー時の計算が激減しました。実際にはこんなに単純には行かないかもしれませんが、このように、メモ化はパフォーマンスにおいてうまく使えば大きな威力を発揮します。
STEP3
再レンダーはだいぶ高速になってきました。そこで、改めてprofilerを見てみます。以下はフォームにおいて、あいうえおと打った結果です。
フォームの中身が変わるたびにすべてのForm
コンポーネントが再レンダーされていることがわかります(合計5回!)。useState
で状態を持っておりスナップショットが更新されているため(ここについて詳しく知りたい方は、この先の注意書きをご覧ください)、当たり前といえば当たり前なのですが、たとえばForm
コンポーネントの中に何か重い処理が入っていたとしたら、再レンダー時に再計算されたくないですよね? フォームの中身をレンダリングという世界から避難させたくなってきます。
そこで、useRef
を利用してフォーム内容の変更についての処理をReactの世界から取り除いてみましょう。
import { FC, useRef } from "react";
export const Form: FC = () => {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
alert(`inputText is ${inputRef.current}`);
};
return (
<div
style={{
padding: "20px 0",
}}>
<p>input!</p>
<input type="text" ref={inputRef} />
<button onClick={handleSubmit}>submit</button>
</div>
);
};
再度profilerで録画をすると、どれだけフォーム内容を更新しても、レンダリングがされなくなりました! これにより、少々無駄になっていた再レンダーを抑制することに成功しました。
これで、今回のパフォーマンス改善の流れはおしまいです。
おわりに
再レンダーは大変早くなりましたが、では初回レンダーはどうするの? かなりもっさくない? ということについて、(本筋とは逸れてしまいますが)少しだけお伝えしておきます。方法は、たぶんですが…色々とあります。
まず物理的に早くする工夫です。今回の処理ですが、10000個のboxコンテンツを最初からロードするような流れになっています。この処理がとてつもなく重いため、「最初のロードはレンダリングの範囲内だけにする」ことで、レンダリングが早くなりそうです。実際のコンポーネントにおいてこういった処理はAPIを利用してバックエンドから取得するという形になると思いますので、そういった技術を可能にするreact-windowやreact-visualizedの導入を検討しても良いかもしれません。
物理的に早くするのが既に難しい場合があると思います。そんな時は、待機中をユーザーに知らせるローディング画面を作って、処理中はそちらを表示させるのが良いかもしれません。そういった待機ローディングがあるだけで、ユーザー体験はいくぶんか良くなると思っています。
初回レンダーのお話はこれくらいにしまして、改めて、今回実施した3つのパフォーマンス改善施策は以下の通りになりました。
- STEP1: Propsの最適化
- STEP2: メモ化
- STEP3: refの利用
profilerで確認しながら、パフォーマンス観点におけるボトルネックを特定し、改善してきました。今回はコンポーネント自体もシンプルでしたし、施策内容もReact界隈ではよく聞くものだったかなと思います。実際のコンポーネントはもっと複雑なので、Reactのパフォーマンス改善は本当に大変だな〜と思っています。
私は今回profilerを使いこなしたくて色々と調べているうちに、この記事をまとめとして書くようになりました。profilerのことをもっと利用し尽くすことにより、パフォーマンス改善のコツをもっとたくさん勉強して、私自身より良いコードが書けるようになりたいなと思っています。
長くなりましたが、ここまで読んでいただきありがとうございました!
Discussion