🌮

React dev toolsのprofilerを利用してパフォーマンス改善していく流れ

2024/01/14に公開

はじめに

この記事では、サンプルで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の説明や利用方法は割愛させてもらいます。

おすすめ記事

今はもう更新がされていないので、ちょっと古いですが。
https://ja.legacy.reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html

以下のあたりを参考にしつつ、使い方を学ばせてもらいました。
https://zenn.dev/maktub_bros/articles/814a14312d2393
https://zenn.dev/virginia_blog/articles/8d202045d5e60f

最初のコード

それではまずは簡単に最初のコードの大枠をざっと紹介します。

Page.tsx
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}
      />
    </>
  );
};
Form.tsx
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>
  );
};
Box.tsx
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秒ほどかかってますが、フォーム画面に入力したりする時やボックスをクリックする時などもかなり重い感じになっています。

page1

パフォーマンス改善

STEP1

実際にprofilerを利用して計測してみた結果がこちらです。profilerを起動して、1:ページを表示、2:フォームに「あ」1文字を入力、3:ボックスをクリックの3つの動作をしてみました。

結果が表示されたので、まずはprofilerツールの右上部分を見てみます。

Page1 profiler

これはコミット(レンダー回数とほぼ同義)が、初回レンダーを含めて3回行われていること、黄色い色の棒グラフがほぼ横ばいで3つ並んでいることから、初回レンダーにかかるのと同じ時間が再レンダーにかかっているのがわかります。また、各レンダリングにはおよそ4秒がかかっていることがわかりました。

さらに、黄色い棒グラフ部分の2つめや3つめをクリックして、再レンダー時の様子を見てみます。

  • フォーム入力時
    Page1フォーム入力時

  • ボックスクリック時
    Page1ボックスクリック時

(ちょっと原因不明でFormコンポーネントの方が表示されていないので少々わかりにくいのですが…たぶんFormのレンダリングにあんまり時間がかからないせいかも)、フォーム入力時もボックスクリック時も、Page1コンポーネントが再レンダーされているのがわかります(赤線で囲んでいる横向きバーが黄色の場合、そのコンポーネントが再レンダリングされていることを示しています)。

今3つのコンポーネントのレンダリング秒数がすべて同じになっていますが、フォーム入力時はFormコンポーネントだけ、ボックスクリック時はBoxコンポーネントだけ、再レンダリングして欲しいですよね。

Page1コンポーネントは、現状Pageは今はFormBoxを表示しているのみですが、もしPageでも多くの計算処理やAPI取得処理が入っていた場合、フォームやボックスを操作した際に親コンポーネントも再レンダリングされたら…今よりさらにパフォーマンスが落ちます。

そこでもう一度コンポーネントをよくみてみると、フォーム入力やボタンクリック時に、親コンポーネントであるPageで利用しているselected,inputTextなどに更新があるため、親コンポーネントも再レンダーがかかっていることがわかっています。そしてこれらは、それぞれの子要素で定義・利用すれば良いことに気づきます。

Reactでは、基本的に無駄なPropsの受け渡しが奨励されていません。理由は今回のように、Propsに変更があった際は親コンポーネントも再レンダーが走るため、場合によっては無駄な再レンダーにつながるためです。

それでは、思い切ってPropsの受け渡しをやめてみます。

Page.tsx
import { FC } from "react";
import { Form } from "./Form";
import { Box } from "./Box";

export const Page2: FC = () => {
  return (
    <>
      <h2>Page2 optimize props</h2>
      <Form />
      <Box />
    </>
  );
};
Form.tsx
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>
  );
};
Box.tsx
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つの動作をしてみました。

Page2

ボックスクリックはまだ遅いままですが、明らかにフォーム入力が早くなっているのがわかります。それでは、profilerも録画してみましょう。

Page2 profiler

体感速度と同じく、2つめのコミット(フォーム入力)が明らかに早くなり、3コミット分の棒グラフに凸凹ができたのがわかります。理由は、無駄なPropsをやめたことにより、再レンダーが各コンポーネントに閉じたためです。

  • フォーム入力時
    Page2フォーム入力時

  • ボックスクリック時
    Page2ボックスクリック時

右側のCommit Informationにて、再レンダーのトリガとなっているのがどのコンポーネントなのかを教えてくれる場所があります。そこを見てみると、それぞれのコンポーネント自体に再レンダーの影響が閉じたことが確認できました。そのため、元々レンダリングに時間がかからないFormの方が高速になったと言えそうです。

余談ですが、profilerにはボトルネックを調べるために有用なRanked chartという機能があります。今回はコンポーネント数が少ないため、あまり詳細に調べずともボトルネックになっているコンポーネントがどこか見つけることができます。しかし、コンポーネントが何十、何百と増えていった際には、Ranked chartを見ることによって、遅いコンポーネントをより早く特定することができそうです。

Ranked chart

また、レンダーの仕組みとpropsの影響との関係を知ってコンポーネントを適切に切ることによって、パフォーマンス改善時にボトルネックが見つけやすくなるのも良いですよね!

STEP2

(もはや最初から怪しさ満点ではありましたが…)Propsを分けたことで、重めの処理がどうやらBoxの方にあるらしいということがわかりました。それでは、改めてコードを見てみます。

Box.tsx
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のみメモ化の改修を行いました。

Box.tsx
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>
  );
};
BoxChild.tsx
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コンポーネントにおけるprocessedBoxuseMemoを利用してメモ化し、子要素に切り出したBoxChildmemo関数でラップすることによりコンポーネント全体をメモ化しました。結果を見てみます。

初回レンダーは相変わらずもっさい感じですが、再レンダーは非常に早くなりました!

Page3

以下がprofilerの結果です。こちらは、例によって1:ページを表示、2:フォームに「あ」1文字を入力、3:ボックスをクリックのうち、3つめのコミットを示しています。メモ化により再レンダー時の計算が激減しました。実際にはこんなに単純には行かないかもしれませんが、このように、メモ化はパフォーマンスにおいてうまく使えば大きな威力を発揮します。

STEP3

再レンダーはだいぶ高速になってきました。そこで、改めてprofilerを見てみます。以下はフォームにおいて、あいうえおと打った結果です。

Page3

フォームの中身が変わるたびにすべてのFormコンポーネントが再レンダーされていることがわかります(合計5回!)。useStateで状態を持っておりスナップショットが更新されているため(ここについて詳しく知りたい方は、この先の注意書きをご覧ください)、当たり前といえば当たり前なのですが、たとえばFormコンポーネントの中に何か重い処理が入っていたとしたら、再レンダー時に再計算されたくないですよね? フォームの中身をレンダリングという世界から避難させたくなってきます。

そこで、useRefを利用してフォーム内容の変更についての処理をReactの世界から取り除いてみましょう。

From.tsx
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-windowreact-visualizedの導入を検討しても良いかもしれません。

物理的に早くするのが既に難しい場合があると思います。そんな時は、待機中をユーザーに知らせるローディング画面を作って、処理中はそちらを表示させるのが良いかもしれません。そういった待機ローディングがあるだけで、ユーザー体験はいくぶんか良くなると思っています。

初回レンダーのお話はこれくらいにしまして、改めて、今回実施した3つのパフォーマンス改善施策は以下の通りになりました。

  • STEP1: Propsの最適化
  • STEP2: メモ化
  • STEP3: refの利用
    profilerで確認しながら、パフォーマンス観点におけるボトルネックを特定し、改善してきました。今回はコンポーネント自体もシンプルでしたし、施策内容もReact界隈ではよく聞くものだったかなと思います。実際のコンポーネントはもっと複雑なので、Reactのパフォーマンス改善は本当に大変だな〜と思っています。

私は今回profilerを使いこなしたくて色々と調べているうちに、この記事をまとめとして書くようになりました。profilerのことをもっと利用し尽くすことにより、パフォーマンス改善のコツをもっとたくさん勉強して、私自身より良いコードが書けるようになりたいなと思っています。

長くなりましたが、ここまで読んでいただきありがとうございました!

Discussion