LoginSignup
86
72

More than 1 year has passed since last update.

【簡単】React Developer Toolsとwhy did you renderを使ったレンダリング最適化方法をいまさらだけど整理してみた

Last updated at Posted at 2022-05-28

React.memo/useCallback/useMemo...知ってはいるけどいつ使えば良いかわからない

Reactを始めてまもない方やバックエンドとフロントを両方兼務している方にとって、レンダリング最適化やパフォーマンスチューニングは苦手意識を持つ方が多いと思います。

かくいう私も普段はPHPとTypeScriptをいったりきたりしつつ、
フロント業務ではNext/Reactを触り始めてはいるもののメモ化周りとチューニング方法は全く理解できていませんでした。

原因を振り返ってみると以下のようなものでした
「各フックそれぞれの説明において、その場では分かった気がしていた」
「console.logが埋め込まれた前提で再レンダリングの説明をされるだけでは応用が効かない
「問題を検証 → 分析 → 改善(ここでメモ化周りのhooksを使う)フローで進められていない」
「そもそもどこがネックなのか検証できるツールと利用方法を理解していない」

そんな反省を踏まえて自分用の備忘録的に今回の記事を書こうと思いました。

対象者は以下の方々を想定しています。
・React Developer Toolsをの基本的な使い方を知りたい方
・why-did-you-renderライブラリを利用した基本的な使い方を知りたい方
・Reactを触り始めたけれどReact.memo/useCallback/useMemoの使い所がわからない方

※私自身、React歴はかなり浅いのでご指摘あれば是非、具体例(コード)と編集リクエストを頂けると大変嬉しく思います。

進め方

本記事では以下の手順で解説していきます。

  1. 再レンダリングされる仕組みを再整理
  2. 検証ツールの使い方
  3. 簡単なケース問題

また利用しているコードは、
1.に関しては全てcodesandboxというブラウザ統合開発環境で確認できるようにしております。

2.以降に関してはVercel_Next.js ×Why did you renderをベースに構築しています。
動作確認されたい方は、コピペミス防止のため私のリポジトリをクローン頂ければ早いかと思います。各章毎にブランチを切っております。

1.再レンダリングされる仕組みを再整理

Reactにおいて再レンダリングが行われるタイミングは以下の2つです。

  • stateに変化が起きた時
  • 親コンポーネントが再レンダリングされた時

今後のベースとなる知識になるため、しっかり整理しておきましょう。

stateに変化が起きた時

App.tsx
import { useState } from "react";

export default function App() {
  console.log("stateが更新されたため再レンダリング!");
  const [text, setText] = useState("");

  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  return (
    <>
      <p>ここに文字を入力</p>
      <input type="text" onChange={changeText} />
      <p>入力された文字が反映されます</p>
      <input type="text" value={text} />
    </>
  );
}

画面収録 2022-05-25 6 53 00

実際にコードを動かして確認してみる

input内に文字を入力するとonChangesetTextによってstateが変更されます。
consoleには、stateが更新される度に出力されていることがわかります。
つまり、stateが更新される度に再レンダリングが行われています。

親コンポーネントが再レンダリングされた時

App.tsx

import { useState } from "react";

const Child = () => {
  console.log("子が再レンダリング");

  return (
    <>
      <p>子コンポーネントが表示されています</p>
    </>
  );
};

export default function Parent() {
  console.log("親が再レンダリング!");
  const [text, setText] = useState("");

  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  return (
    <>
      <p>親コンポーネントで文字を入力</p>
      <input type="text" onChange={changeText} />
      <Child />
    </>
  );
}

画面収録 2022-05-24 7 04 47

実際にコードを動かして確認してみる

上記と同様ですが、input内に文字を入力するとonChangesetTextによってstateが変更されます。stateを定義しているのは親コンポーネントであるため親側では入力ごとにレンダリング=console.logが出力されています。

一方で、子コンポーネント内ではstateは利用されていないため、再レンダリングの対象にはならないのではと思われたかもしれません。

通常のレンダリングでは、Reactは親がレンダリングされると、子コンポーネントを無条件にレンダリングします。

2.検証ツールの使い方

本記事では二つのツールとライブラリを用いて再レンダリングの測定を行います。

よくある説明ではconsole.logをあらかじめ仕込んで再レンダリングの発生を確認するものが多いですが、実際は、検証→課題を見つける→デバッグを行うというフローが定石です。

ここでは無駄な再レンダリングが発生しているコード例をもとに、
以下を使って検証と改善を行なっていきます。

  • React Developer Tools
  • why-did-you-render

検証ツール確認用コード

ブランチ名:chapter_2

import { useState } from "react";

const Child = () => {
  console.log("子が再レンダリング");

  return (
    <>
      <p>子コンポーネントが表示されています</p>
    </>
  );
};

export default function Parent() {
  console.log("親が再レンダリング!");
  const [text, setText] = useState("");

  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  return (
    <>
      <p>親コンポーネントで文字を入力</p>
      <input type="text" onChange={changeText} />
      <Child />
    </>
  );
}

コード例は前章「②親コンポーネントが再レンダリングされた時」で使用したものになります。
※実際の画面や動作は既出なので割愛
console.logも残してあります。

React Developer Toolsの概要

このReactDeveloperToolsはGoogleChromeの拡張機能です。

DeveloperToolに2つcomponentタブとProfilerタブが追加されて、
以下のようなReactのコンポーネントやレンダリングの状況を検証することができます。

  • コンポーネント階層構造
  • Props・Stateの値
  • レンダリングの可視化・回数

初期設定

Profilerタブ→歯車(View Settings)→Generalと進み、
Highlight updates when components renderをクリックします。

初期設定

検証

画面収録 2022-05-28 9 26 22

以下の流れで操作を行なっています。

①Reload and start profilingを押す
②右上のコミットバーの数を確認
③Ranked chartを押してChildコンポーネントを確認
④再度Reload and start profilingを押す
⑤画面操作(文字入力)
⑥ハイライトされている(再レンダリングされいている)箇所を確認
 ・Highlight updates when components renderの機能がこちらです
 ・再レンダリング(文字入力)が行われる度に該当箇所が青く表示されます
⑦右上のコミットバーの数の増加を確認
 ・コミット(ReactがDOMに変更を適用する)ごとにパフォーマンス情報をグループ化
 ・現在選択しているコミットが青色
 ・各バーの色と高さは、そのコミットのレンダリングにかかった時間を示す
⑧③を再度試しChildコンポーネントがレンダリングされていることを確認

前述のフローから以下の点から無駄な再レンダリングが発生していることがわかります。

  • ⑤ ~ ⑥で文字入力が行われる度にChildコンポーネントもハイライトが起きている
  • ⑦ ~ ⑧でも文字入力が行われている度にChildコンポーネントがレンダリングされている

上記を鑑みて以下のことがわかります。

・親コンポーネントのstateの更新が発生するた度に子コンポーネントで再レンダリングが発生している。
・子コンポーネントではPropsに値が渡されていないため初期レンダリング以外は親コンポーネントのレンダリングの影響を受けて欲しくない。
・子コンポーネント側で何かしらの対策(memo化)を行なえそう。

Why Did You Renderの概要

このライブラリを導入することで回避可能な再レンダリングが発生した場合、consoleにログを出力します。

具体的には以下のような内容です。

  • 再レンダリングが発生しているComponent名
  • 再レンダリングが発生している原因
  • 再レンダリングのトリガーになったpropsやstateの中身

使い方としては、
①React Developer Tools等でパフォーマンスが悪い箇所を発見して当該コンポーネントに埋め込む
②新規にコンポーネント作成の際に埋め込んだ状態で開発を進めて確認する

といった使い方が想定されます。

初期設定

お手元ですぐに動作検確認するために
冒頭でも説明した通り、このライブラリーがインストール済みのプロジェクトをVercelが公開しております。また、本記事でもこのSampleをベースに例題を作成しています。こちらをクローンしてコピペしてください。

それでは、さきほどのコード「検証ツール確認用コード」の最終行のコメントアウトを解除してみてください。

// 最終行をコメントアウトする
Child.whyDidYouRender = true

検証

先ほどと同様にいinput欄に文字を入力してみるとconsole画面に何やら文字がたくさん表示されています。

画面収録 2022-05-27 7 37 07

Re-rendered because the props object itself changed but its values are all equal.' 
Rendered by Parentfather from re-rendering.' 
[hook useState result]
{"prev ": ''} '!==' {"next ": 'a'}

このアラートから以下のことが読み取れます。

  • 値は等しいけれどpropsオブジェクト自体が更新されたため、Childコンポーネントは再レンダリングしている
  • 親が再レンダリングされたことによって再レンダリングしている
  • useStateの値が からaに変わったことが要因

改善(React.memoでメモ化を行う)

ブランチ名:chapter_2_refactoring

React.memoとはコンポーネント(コンポーネントのレンダリング結果)をメモ化するReactのAPI(メソッド)になります。

親から受け取るPropsの等価性=変化があるかをモニタリングしており、propsに変化がなければコンポーネントの再レンダリングをスキップすることができます。

先に示したサンプル例を以下のようにReact.memoラップしてあげます。

import React from "react";
import { useState } from "react";

const Child = React.memo(() => {
  console.log("子が再レンダリング");

  return (
      <>
        <p>子コンポーネントが表示されています</p>
      </>
  );
});

// ①esLintエラー対策
Child.displayName = 'Child';

export default function Parent() {
  console.log("親が再レンダリング!");
  const [text, setText] = useState("");

  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  return (
// ②Reactフラグメントを削除してdivタグを追加
    <div>
      <p>親コンポーネントで文字を入力</p>
      <input type="text" onChange={changeText} />
      <Child />
    </div>
  );
}

// whyDidYouRenderで利用します
Child.whyDidYouRender = true

補足

①React.memoを利用するとdisplayNameを検知できなくなるため改めて明示的に定義する必要があります。未定義だとeslintによるエラーが表示されます。

②Reactフラグメントがあると子コンポーネントをメモ化しているのにも関わらず、
React Developer ToolsのHighlight updates when components render機能で確認しても再レンダリングされているように表示されてしまいます。

対応としてはFragment箇所を削除、<div>タグ等に変換することで対応できます。

再検証

それでは修正した結果でそれぞれの検証ツールでどのような変化があったのか確認します。

React Developer Toolsの再検証

画面収録 2022-05-28 9 38 53

修正前は文字入力(再レンダリング)が行われる度に左側のハイライトがされていた箇所が少なくなっていることがわかります。

また、コミットごとに表示されていたChildコンポーネントの表示数も無くなりました。

Why Did You Renderの再検証

画面収録 2022-05-28 10 05 04

こちらは非常にわかりやすくmemo化したことによりconsoleに表示されていたエラーが消えています。

3.簡単なケース問題

こちらではReact.memo useCallback useMemo3つを利用してレンダリング最適化を行います。

これまでと同様以下の流れで進めます。

  1. 無駄なレンダリング・パフォーマンスが悪いコード例
  2. 検証ツールを用いて問題箇所を計測する
    2-1. React Developer Tools
    2-2. why-did-you-render
  3. コード修正
  4. 再検証

無駄なレンダリング・パフォーマンスが悪いコード例

ブランチ名:chapter_3

import React from "react";
import { useState } from "react";

// ①Propsの受け取りはない子コンポーネント
const Child_1 = () => {
  return (
    <p>Child_1コンポーネント</p>
  );
};

// ②callback関数をPropsで受け取っている子コンポーネント
const Child_2 = (props: { handleClick: () => void }) => {
  return (
    <p>Child_2コンポーネント</p>
// <button onClick={props.handleClick}>Child_2コンポーネント</button>
  );
};

export default function Parent() {
  const [text, setText] = useState("");
  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const handleClick = () => {
    console.log("click");
  };


  const [count, setCount] = useState(0);
  const double = (count: number) => {
    let i = 0;
    while (i < 1000000000) i++;
    return count * 2;
  };

// ③計算に時間が掛かる重い処理結果を格納している値
  const doubledCount = double(count);

  return (
    <div>
      <p>親コンポーネントで文字を入力</p>
      <input type="text" onChange={changeText} />
      <Child_1 />
      <Child_2 handleClick={handleClick} />
      <p>親コンポーネント側での重い処理</p>
      <p>
        Counter: {count}, {doubledCount}
      </p>
      <button onClick={() => setCount(count + 1)}>Increment count2</button>
    </div>
  );
}

Child_1.whyDidYouRender = true;
Child_2.whyDidYouRender = true;

Child_1はPropsの受け渡しはないシンプルなコンポーネントです。
Child_2は親コンポーネントからcallback関数を受け取り、子コンポーネント内で利用しています。
③は親コンポーネントで利用している値になります。計算するの非常に時間が掛かる重い処理になります。

検証ツールを用いて問題箇所を計測する

React Developer Tools

画面収録 2022-05-28 14 41 04

実際にお手元で動かしてみれば分かりますがinput項目に文字を一文字入力するだけでも非常に重い動きになっています。

Ranked chartをでもParentコンポーネントが圧倒的にパフォーマンスのボトルネックになっていることがわかります。

さらにParentコンポーネントが再レンダリングされる度にChild_1 Child_2も合わせてレンダリングされており無駄が発生していそうです。

まとめると以下の仮説が立ちました。

  • 親コンポーネント側での処理がパフォーマンスのボトルネックになっている。
  • 親コンポーネントの再レンダリングに関係のない子コンポーネントも影響されてしまっている

why-did-you-render

React Developer Toolsで立てた仮説を同ライブラリを用いてさらに検証してみます。

画面収録 2022-05-28 14 50 39

consoleに出力されている内容を整理すると以下の通りです。

Child_1

  • 親コンポーネントでのuseStateの値が からaに変わったことが要因変更されたことによって再レンダリングした
  • propsの値自体は変わっていない

Child_2
再レンダリング理由は以下の二つ

  • 親コンポーネントでのuseStateの値が からaに変わった
  • propsの値であるhandleClickが変化した

コード修正

ブランチ名:chapter_3_refactoring

import React from "react";
import { useState, useCallback, useMemo } from "react";

// ①コンポーネントをメモ化する
const Child_1 = React.memo(() => {
  return (
    <p>Child_1コンポーネント</p>
  );
});
const Child_2 = React.memo((props: { handleClick: () => void }) => {
  return (
    <p>Child_2コンポーネント</p>
    // <button onClick={props.handleClick}>Child_2コンポーネント</button>
  );
});

export default function Parent() {
  const [text, setText] = useState("");
  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

// ②コールバック関数のメモ化
  const handleClick = useCallback(() => {
    console.log("click");
  },[]);

// ③重い処理をメモ化
  const [count, setCount] = useState(0);
  const double = (count: number) => {
    let i = 0;
    while (i < 1000000000) i++;
    return count * 2;
  };
  const doubledCount = useMemo(() => double(count),[count]);

  return (
    <div>
      <p>親コンポーネントで文字を入力</p>
      <input type="text" onChange={changeText} />
      <Child_1 />
      <Child_2 handleClick={handleClick} />
      <p>重い処理</p>
      <p>
        Counter: {count}, {doubledCount}
      </p>
      <button onClick={() => setCount(count + 1)}>Increment count2</button>
    </div>
  );
}

Child_1.displayName = 'Child_1';
Child_2.displayName = 'Child_2';

Child_1.whyDidYouRender = true;
Child_2.whyDidYouRender = true;

親子間の不要な再レンダリングを防ぐ
①検証時で明らかになったようにChild_1 Child_2コンポーネントは親に影響されて無駄な再レンダリングが発生していました。

React.memoを使いPropsの値が更新される時以外のレンダリングをスキップするようにします。

②関数はコンポーネントが再レンダリングされる度に再生成されてしまいます。

関数の処理が同じでも、新しいhandleClickと前回のhandleClickは異なるオブジェクトなので等価ではありません。そのため、コンポーネントが再レンダリングされてしまいます。

そこでuseCallbackを利用して関数をメモ化します。

親側の処理を軽くする
doubledCountは一定時間要するループ後にcountを2倍した結果が格納されます。本来であれば以下のボタンがクリックされてcountが更新される時のみ処理が実行してほしいはずです。

<button onClick={() => setCount(count + 1)}>Increment count2</button>

useMemoを利用することで、引数の依存配列で指定したcountに変化が起きる時以外はメモ化された値を利用するようにします。

再検証

React Developer Tools

画面収録 2022-05-28 15 06 40

親子間の不要な再レンダリングを防げた!!
Child_1 Child_2 コンポーネントのレンダリングが減っていることがわかります。

親側の処理を軽くできた!!
初回のParentコンポーネントのレンダリング以降はメモ化された値を利用しているためレンダリングに掛かる時間が大幅に削減されていることがわかります。

why-did-you-render

画面収録 2022-05-28 15 11 22

親側でinput入力を行なっても(stateを更新)、consoleエラーが表示されなくなりました。

参考記事

86
72
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
86
72