ReactのProfilerで描画にかかる時間を測る方法

​​React​esbuild,memo,TypeScript,useLayoutEffect

React の <Profiler> で画面の一部を囲むと、その部分の描画が確定するたびに onRender が呼ばれ、描画にかかった時間を受け取れます。数字としてログに残せるので、memo が効いているかを確かめたり、どの部分が重いかを比べたりできます。

筆者は検証環境で、1,000 行のリストを描画するコンポーネントを <Profiler> で囲み、Chromium で動かしました。受け取った値の読み方、memo を使ったときの値の変わり方、複数の <Profiler> を置いたときの呼ばれ方、本番用のビルドでの動きを確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。ブラウザは Chromium 141 を、Playwright 1.56.1 で操作しました。.tsx を tsc で型チェックしてから、esbuild で開発用・本番用・プロファイリング用の3種類に変換しています。

Profiler で囲んで onRender を受け取る

<Profiler> には、測る部分の名前を表す id と、コールバックの onRender の2つを渡します(公式リファレンスにある Props の説明)。

検証では、onRender が受け取った値を配列にためておき、あとで Playwright から読み出しました。型は @types/react の ProfilerOnRenderCallback を使います。

ts
// log.ts
import type { ProfilerOnRenderCallback } from 'react';

export type Entry = {
  id: string;
  phase: string;
  actualDuration: number;
  baseDuration: number;
  startTime: number;
  commitTime: number;
};

declare global {
  interface Window { __entries: Entry[] }
}
window.__entries = [];

export const onRender: ProfilerOnRenderCallback = (
  id, phase, actualDuration, baseDuration, startTime, commitTime,
) => {
  window.__entries.push({ id, phase, actualDuration, baseDuration, startTime, commitTime });
};

測る側のコンポーネントは次のとおりです。ボタンを押すと数字が1つ増え、そのたびに SlowList も描画し直されます。SlowList は、1行ごとに少し計算をする Row を 1,000 行並べたものです。

tsx
function Basic() {
  const [count, setCount] = useState(0);
  return (
    <Profiler id="App" onRender={onRender}>
      <button id="inc" onClick={() => setCount((c) => c + 1)}>+1</button>
      <p>{count}</p>
      <SlowList count={ROWS} />
    </Profiler>
  );
}

onRender が受け取る6つの値

onRender は、次の6つの値を順に受け取ります。説明は公式リファレンスにある onRender の引数の説明をもとにしています。時間の単位はミリ秒です。

引数 内容
id 描画が確定した <Profiler> の id
phase 初めての表示なら "mount"、props や state などの変化による描画なら "update"。ほかに "nested-update" がある
actualDuration 今回の更新で、<Profiler> とその中のコンポーネントの描画にかかった時間
baseDuration 最適化なしで中身をすべて描画し直したら、どれくらいかかるかの見積もり。各コンポーネントが直近に描画にかかった時間を足したもの
startTime 今回の更新の描画を始めた時刻
commitTime 今回の更新を確定した時刻。同じ確定では、すべての <Profiler> で同じ値になる

@types/react 19.3.0 の型でも、phase は "mount" | "update" | "nested-update" の3つで、ほかの5つの型は、id が string、残りの4つが number です。

プロファイリング用のビルドの中を見ると、時間は Scheduler の unstable_now で測っていました。Scheduler の本番用のコードでは、これが performance.now() を呼びます。検証環境の Chromium では、値はどれも 0.1 ミリ秒きざみでした。検証のページは、クロスオリジン分離されていません(crossOriginIsolated が false)。MDN の performance.now() のページには、クロスオリジン分離されていないページでは、精度が 100 マイクロ秒に落とされると書かれています。そのため、ログで 0.00 と出ても、かかった時間がゼロだったとは限りません。

memo が効いているかを actualDuration と baseDuration で見る

公式リファレンスにある baseDuration の説明では、actualDuration を baseDuration と比べると、メモ化が効いているかが分かるとしています。

プロファイリング用のビルドで、上の Basic のボタンを3回押した結果です。mount の行が最初の表示、update の3行がボタンを押したあとの更新です。

text
--- build=profiling case=basic 呼ばれた回数=4
App      mount         actual=  29.30 base=  14.20 start=   95.00 commit=  127.10
App      update        actual=  12.60 base=  12.20 start=  273.10 commit=  288.90
App      update        actual=   8.90 base=   7.80 start=  415.80 commit=  427.30
App      update        actual=   8.20 base=   8.50 start=  566.60 commit=  576.20

数字を1つ増やすだけで、毎回 1,000 行のリストも描画し直しています。そのため、actualDuration は baseDuration とほぼ同じ値になりました。

次に、SlowList を memo で包み、同じようにボタンを3回押しました。SlowList に渡す count は変わらないので、描画をとばせるはずです。

tsx
export const MemoSlowList = memo(SlowList);
text
--- build=profiling case=memo 呼ばれた回数=4
App      mount         actual=  20.20 base=  13.90 start=   78.00 commit=  100.20
App      update        actual=   0.20 base=  13.90 start=  229.30 commit=  229.70
App      update        actual=   0.00 base=  13.70 start=  357.60 commit=  357.70
App      update        actual=   0.10 base=  13.80 start=  490.70 commit=  490.80

更新のときの actualDuration は 0.2 ミリ秒以下に下がりました。一方で baseDuration は約 14 ミリ秒のままです。baseDuration は直近に描画したときの時間を足した見積もりなので、描画をとばした SlowList の分も入っています。

memoで包んだリストの描画時間のグラフ。最初の表示ではactualDurationが20.2ミリ秒、baseDurationが13.9ミリ秒。更新1〜3回目ではactualDurationが0.2、0.0、0.1ミリ秒で、baseDurationは13.7〜13.9ミリ秒のまま
memoで包んだリストの、actualDurationとbaseDuration(プロファイリング用のビルド、Chromium 141)
ポイント

更新のたびに actualDuration が baseDuration とほぼ同じなら、中身を毎回すべて描画し直しています。actualDuration だけが大きく下がっていれば、memo などで描画をとばせています。

なお、検証では最初の表示(mount)の actualDuration が、baseDuration より大きく出ました。memo のありなしにかかわらず、2回目の実行でも、開発用のビルドでも同じでした。理由は確かめていません。

複数の Profiler を置いたときの onRender の呼ばれ方

<Profiler> は、画面の部分ごとに複数置くことも、入れ子にすることもできます(公式リファレンスにある、アプリの部分ごとに測る例)。検証では、次のように置きました。

tsx
function Multi() {
  return (
    <>
      <Profiler id="Sidebar" onRender={onRender}>
        <SlowList count={100} />
      </Profiler>
      <Profiler id="Content" onRender={onRender}>
        <Profiler id="Editor" onRender={onRender}>
          <Editor />
        </Profiler>
        <SlowList count={100} />
      </Profiler>
    </>
  );
}

Editor は、ボタンを押すと文字が1つ増えるコンポーネントです。このボタンを2回押しました。

text
--- build=profiling case=multi 呼ばれた回数=7
Sidebar  mount         actual=   4.90 base=   2.40 start=   75.50 commit=   83.70
Editor   mount         actual=   0.40 base=   0.10 start=   81.10 commit=   83.70
Content  mount         actual=   2.00 base=   1.40 start=   81.10 commit=   83.70
Editor   update        actual=   0.30 base=   0.30 start=  219.30 commit=  219.70
Content  update        actual=   0.30 base=   1.60 start=  219.30 commit=  219.70
Editor   update        actual=   0.10 base=   0.10 start=  349.40 commit=  349.50
Content  update        actual=   0.10 base=   1.40 start=  349.40 commit=  349.50

最初の表示では、3つの <Profiler> すべてで onRender が呼ばれました。ボタンを押したあとは、Editor と、その外側の Content だけが呼ばれました。中で何も描画し直していない Sidebar の onRender は、呼ばれませんでした。

ボタンを押したあとの Content の actualDuration は、Editor と同じ値でした。Content の中の SlowList は、描画し直していないためと考えられます。一方で Content の baseDuration は 1.4〜1.6 ミリ秒あり、その SlowList の分も入っています。また、同じ確定で呼ばれたものは、commitTime がそろっていました。ログを集めるときは、commitTime で同じ確定のものをまとめられます。

phase が nested-update になったとき

phase の3つめの値、"nested-update" が出る場合も1つ確かめました。useLayoutEffect の中で state を更新するコンポーネントです。

tsx
function Nested() {
  const [count, setCount] = useState(0);
  const [width, setWidth] = useState(0);
  useLayoutEffect(() => {
    setWidth(count * 10);
  }, [count]);
  return (
    <Profiler id="App" onRender={onRender}>
      <button id="inc" onClick={() => setCount((c) => c + 1)}>+1</button>
      <p>{count} / {width}</p>
    </Profiler>
  );
}

ボタンを1回押すと、onRender が2回呼ばれました。1回目は "update"、2回目はエフェクトの中の更新による描画で、"nested-update" でした。

text
--- build=profiling case=nested 呼ばれた回数=3
App      mount         actual=   1.60 base=   0.40 start=   71.30 commit=   73.20
App      update        actual=   0.10 base=   0.10 start=  208.20 commit=  208.40
App      nested-update actual=   0.00 base=   0.00 start=  209.10 commit=  209.10

1回の操作で描画が2回確定しています。1回の操作にかかった時間を見たいときは、2回分を足して見ます。どんなときに "nested-update" になるかは、公式リファレンスに詳しい説明がありません。ここで確かめたのは、useLayoutEffect の中で state を更新した場合だけです。

本番用のビルドでは onRender が呼ばれない

公式リファレンスには、測ること自体に少し負荷がかかるので、本番用のビルドでは標準で無効になっていると書かれています。

本番用のビルド(NODE_ENV を production にして変換したもの)で同じ4つのケースを動かすと、onRender は一度も呼ばれませんでした。

text
--- build=prod case=basic 呼ばれた回数=0
--- build=prod case=memo 呼ばれた回数=0
--- build=prod case=multi 呼ばれた回数=0
--- build=prod case=nested 呼ばれた回数=0

本番に近い条件で測りたいときは、プロファイリング用のビルドを使います。公式ドキュメントにある、プロファイリング用のビルドの使い方では、react-dom/client の代わりに react-dom/profiling を使うこと、そのためにはバンドラーの別名(エイリアス)で置き換えるのがよいことが書かれています。

検証では、esbuild の --alias で置き換えました。

sh
npx esbuild src/app.tsx --bundle --format=iife --jsx=automatic \
  --define:process.env.NODE_ENV='"production"' --minify \
  --alias:react-dom/client=react-dom/profiling --outfile=dist/profiling.js

react-dom/profiling は、NODE_ENV が production のときにプロファイリング用のビルドを読み込みます。こうして変換したものでは、上の結果のとおり onRender が呼ばれました。

Vite や webpack などのほかのバンドラーでは試していません。それぞれの別名の設定で、同じように置き換えられるはずですが、確かめてはいません。

ポイント

開発用のビルドで測った時間は、プロファイリング用のビルドより大きく出ました。検証環境では、memo なしのリストを更新したときの actualDuration が、開発用のビルドでは 41.9〜51.8 ミリ秒、プロファイリング用のビルドでは 8.2〜12.6 ミリ秒でした(どちらも1回目の実行での、更新3回分)。開発用のビルドの数字を、そのまま本番の速さとして読まないようにします。

開発用のビルドの結果は次のとおりです。

text
--- build=dev case=basic 呼ばれた回数=4
App      mount         actual=  58.30 base=  44.90 start=  183.10 commit=  245.90
App      update        actual=  51.80 base=  50.70 start=  384.30 commit=  440.00
App      update        actual=  42.70 base=  42.70 start=  578.10 commit=  624.00
App      update        actual=  41.90 base=  42.00 start=  761.20 commit=  805.10

同じ検証をもう一度実行すると、値が変わりました。たとえば、プロファイリング用のビルドで memo ありの更新の baseDuration は、1回目が 13.7〜13.9 ミリ秒、2回目が 10.6〜10.8 ミリ秒でした。1回の数字だけで判断せず、何回か測って比べるのが安全です。

React Developer Tools との使い分けと負荷

<Profiler> は、コードの中で時間を受け取る仕組みです。画面を操作しながら見たいときは、React Developer Tools の Profiler タブが使えます。公式リファレンスにある測り方の説明には、同じような機能をブラウザの拡張機能として使えると書かれています。筆者は、ブラウザの拡張機能の React Developer Tools は試していません。

公式リファレンスには、<Profiler> は軽いものの、置くたびに CPU とメモリを少し使うので、必要なときだけ使うようにとも書かれています。

​Posted by 86