LINEヤフー プロダクトストラテジ部の江口です。普段はバックエンドエンジニアとして業務に関わっていますが、今回はReact.jsについての記事を執筆します。この記事では私たち が開発する「UTS Portal」という社内向けプロダクトで発生していたReact.jsのパフォーマンス悪化問題について原因と改善内容をまとめたものです。
UTS Portalについて
まず私たちが開発するUTS Portalについて説明します。
UTS PortalとはUTS(Universal Tracking System)という内製ログトラッキングシステムを利用する際に「どのようなトラッキングログを取得したいか?」を事前に定義・管理しておくためのポータルサイトです。
ログ定義を管理しておくことで、取得するログがユーザーのプライバシー侵害とならないかを事前にレビューする際や実際にログの取得を開始した後のモニタリング業務の際に役立てることができます。
Reactコンポーネント構成
UTS PortalのReactコンポーネント構成について解説します。UTSには「Pageview」「Click」「Impression」といったユーザーの行動イベントの種類があります。画面上ではそれぞれのEventTypeごとにタブが分かれており、各タブの中で複数のセクションを定義する形です。
(1つのSection = 対象のアプリにおける1つのScreen のようなイメージです)

それぞれのEventTabの中には「Add Section」というボタンがあり、これをクリックすると空のSectionが1つ追加されます。またそれぞれのSectionはCopyとRemoveのボタンを保持しています。
Add Sectionの機能は各EventTabで共通の処理になっており、SectionのCopyとRemoveについては各EventTypeごとに独自の処理になるため 、コンポーネント構成としては以下の図のように、onClick系の関数を親コンポーネントから子コンポーネントへpropsとして受け渡す構造になっています。


ソースコード(抜粋)
EventTabContent.tsx
export const EventTabContent = ({
name,
children,
onClickAppend // ← propsとしてAppend処理の関数を受け取る
}) => {
......
return (
<>
{children?.({})}
<Button
onClick={onClickAppend}
>
Add Section
</Button>
</>
);
};
PageViewTabContent.tsx
export const PageViewTabContent = () => {
const { fields, append, insert, remove } = useFieldArray({ control, name: 'pageViewList' });
......
const handleClickAppend = () => {
......
};
const handleClickCopy = (sectionIndex: number) => {
......
};
const handleClickRemove = (sectionIndex: number) => {
......
};
return (
<>
<EventTabContent name="pageViewList" onClickAppend={handleClickAppend}>
{() => {
return
<>
{fields.map((field, sectionIndex) => (
<PageViewSection
key={field.id}
sectionIndex={sectionIndex}
onClickCopy={handleClickCopy}
onClickRemove={handleClickRemove}
/>
))}
</>
}}
</EventTabContent>
......
</>
);
};
PageViewSection.tsx
export const PageViewSection = ({
sectionIndex,
onClickCopy,
onClickRemove
}) => {......}
発生していた問題
上述のログデザイン編集画面において「入力時や保存時の画面の動作が重く、画面が固まることがある」というユーザーからの問い合わせを受けました。対象のログデザインを確認してみると、Sectionの数が他ユーザーが作成するログデザインに比べてかなり多く、フロントエンド処理に原因があると見て調査を開始しました。
パフォーマンス測定・レンダリングコストの調査
まずブラウザ負荷が大きそうな画像の描画について調査しましたが、ChromeのPerformanceタブの機能で確認したところ画像表示についてはそこまで時間はかかっておらず、Scriptの評価/実行に時間がかかっているようでした。

そこでReactコンポーネントのレンダリングコストについての調査をすることにしました。
React Developer Tools
コンポーネントのレンダリングについて調査するにはReact Developer ToolsというChrome拡張を使うと便利です。
インストールが完了すると、Chrome Developer Toolsを開いたときにSourceタブ、Networkタブなどと同列に「Profiler」というタブが追加されます。このProfile機能を使う とコンポーネントレンダリング時のProfileを取得できます。
Reactコンポーネントの再レンダリングについて
Reactコンポーネントが再レンダリングされる理由は主に以下の3つです。
- コンポーネントに渡すPropsの変更
- 親コンポーネントのレンダリング
- コンポーネントのStateの更新
(結論として今回のパフォーマンス悪化は1番目と2番目の項目について無駄に多くトリガーされてしまっていたことが原因でした。)
不必要な再レンダリングの調査
React Developer ToolsのProfiler機能を使って、Section編集時のProfileを取得します。以下の画像は既に1つSectionが存在する状態で、新規Sectionを追加したときのProfileを取得したものです。

これをみると、新規Sectionを追加しただけで既存のSectionについては何も編集を加えていないにもかかわらず、既存のSectionに対しても再レンダリングが走っていることが分かります。(上記画像で緑色ハイライトされているのは再レンダリングされたコンポーネントを表しています)
既存Sectionをクリックすると「Why did this render?」項目に再レンダリングの理由が表示されています。PageViewSectionコンポーネントに渡している「onClickCopy, onClickRemove」というpropsが変更されたとみなされ、再レンダリングが走っていることが分かります。

パフォーマンス改善
これらの測定結果を踏まえ、親Componentの内部処理から見直していきます。
useCallback実装
Reactのprops変更検知は以下のように行われています
- プリミティブ型(数値、文字列、真偽値など)のprops:値を直接比較して変更を検知
- オブジェクト、関数などのprops:メモリ上の参照(アドレス)を比較して変更を検知
このため、親コンポーネントで定義したonClickCopyなどの関数は処理の内容が変わらずとも親コンポーネントが再計算された時点で関数が保存されるメモリ上のアドレスが変わり、子コンポーネントではpropsの変更があったと見なされてしまいます。
Reactフックではこれを防ぐためのラッパー関数としてuseCallbackが用意されています。
useCallbackはuseCallback(関数,[依存配列])の形で使用し、依存配列に指定した変数リストに変更がない限り子コンポーネント側ではキャッシュされた関数を使用します。
前述したソースコードをuseCallbackを使って書き換えると以下の通りです。
PageViewTabContent.tsx
export const PageViewTabContent = () => {
const { fields, append, insert, remove } = useFieldArray({ control, name: 'pageViewList' });
......
const handleClickAppend = useCallback(() =>
},[append, ...]);
const handleClickCopy = useCallback((sectionIndex: number) => {
},[insert, ...]);
const handleClickRemove = useCallback((sectionIndex: number) => {
},[remove, ...]);
...
};
useCallbackを実装し、再びProfileを計測してみます。

コード変更前はレンダリング理由が「Props changed: (onClickCopy, onClickRemove)」だったのが「The parent component rendered.」に変わっています。
前述したコンポーネントが再レンダリングされる理由
- コンポーネントに渡すPropsの変更
- 親コンポーネントのレンダリング
- コンポーネントのStateの更新
のうちuseCallbackによって1つ目は解消されたものの、2つ目の理由でまだ無駄な再レンダリングが走っています。
memo実装
親コンポーネントのレンダリングが子コンポーネントに影響しないようにするには、memoを使います。memoで子コンポーネントをwrapすると、親コンポーネントが再レンダリングされたとしても子コンポーネントのpropsに変更がなければ子コンポーネントの再レンダリングがスキップされます。
ソースコードに適用すると以下の通りです。
PageViewSection.tsx
export const PageViewSection = memo(({
sectionIndex,
onClickCopy,
onClickRemove
}) => {......});
memoを実装した後、再びProfileを計測します。

新規追加したSectionのみ がレンダリングされており(緑色ハイライト表示)、既存のPageviewSectionに対しては再レンダリングは発生していない(グレー表示)ことが分かります。
これで無事にコンポーネントの再レンダリングコストを最適化することができました!
改善結果
ユーザーから問い合わせのあった「入力時や保存時の画面の動作が重い」という現象は上記で説明した通りに、編集中のSectionとは関係のない全てのSectionまで再レンダリングが発生していたために、Sectionの数が多くなるとブラウザ負荷が高くなってしまうことが原因でした。
今回のレンダリングコスト最適化を実装した後該当ユーザーに再確認してもらい、無事に事象が改善されたことが確認できました!
まとめ
今回私はリファクタリング作業から担当しましたが、本来はコンポーネント設計の際にpropsの更新頻度まで考えてこれらの最適化手法を実装できるのがベストかと思います。
またむやみにmemo化を実装したとしても、propsの更新頻度が高いコンポーネントについてはその分memoの比較処理が頻繁に実行されることで逆にパフォーマンスが悪化してしまうことになります。適切なコンポーネントを選定し、最適化していくことが重要になります。


