Reactで重い計算をブラウザの空き時間に少しずつ進める方法

​​React​requestIdleCallback,TypeScript,useEffect

1万件のデータに重い計算をまとめてかけると、終わるまでのあいだ、ボタンを押しても画面が反応しません。requestIdleCallback を使うと、ブラウザの手が空いたときだけ計算を少しずつ進められるので、計算中のクリックにもすぐ応えられます。React では、useEffect で計算を始め、後片付けで cancelIdleCallback を呼ぶフックにまとめます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153(headless、Playwright 1.63.0 から起動)です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。時間を比べる測定(まとめて計算する書き方との比較、timeout の有無の比較)は StrictMode を外し、後片付け、途中で閉じる場合、関数を作り直す場合の確認は StrictMode を付けて行いました。Firefox と Safari、スマートフォンの実機、本番用ビルド、サーバーでの描画(SSR)は試していません。

まとめて計算すると、終わるまでクリックが効かない

検証では、1件ずつに重い計算をかける heavyScore を1万件のデータに使いました。useMemo でまとめて計算すると、検証環境(Chromium 153)では全件に4,273ミリ秒かかり、そのあいだずっと1つのタスクがメインスレッドを占めていました。計算を始めて約0.2秒後にボタンを押すと、そのクリックが処理されるまでに4,078ミリ秒かかりました。

同じ計算を、この記事のフックで requestIdleCallback に回すと、計算中に50ミリ秒を超えるタスクは1つもありませんでした。同じように約0.2秒後に押したボタンは、5ミリ秒でクリックの処理が済みました。

2本ずつの横棒グラフ。上は計算中に押したボタンのクリックが処理されるまでの時間で、まとめて計算すると4,078ミリ秒、requestIdleCallbackで少しずつ進めると5ミリ秒。下は1万件の計算がすべて終わるまでの時間で、まとめて計算すると4,273ミリ秒、少しずつ進めると4,999ミリ秒
検証環境のChromiumで測った、クリックの待ち時間と、全件が終わるまでの時間

その代わり、全件が終わるまでの時間は4,273ミリ秒から4,999ミリ秒に延びました。クリックへの反応を優先して、全件が終わるのが少し遅くなるのを受け入れる書き方です。

なお、グラフの上側の「クリックが処理されるまで」は、Playwright の mouse.click() が戻るまでの時間です。まとめて計算したときは、ページの中で Event Timing API を使い、押してからイベントの処理が始まるまでの時間(processingStart - startTime、MDN の PerformanceEventTiming の説明)も測りました。こちらも4,073ミリ秒でした。

requestIdleCallback は、残り時間を IdleDeadline で教えてくれる

requestIdleCallback(callback, options) は、ブラウザの手が空いたときに呼んでほしい関数を登録します。戻り値の ID を cancelIdleCallback() に渡すと、まだ呼ばれていない登録を取り消せます(MDN の requestIdleCallback() の説明)。

呼ばれた関数には IdleDeadline が渡されます。timeRemaining() は、あと何ミリ秒くらい手が空いているかの見込みを返し、didTimeout は、後の節で説明する timeout の期限が来たために呼ばれたかどうかを表します(MDN の IdleDeadline の説明)。MDN の Background Tasks API の解説には、timeRemaining() の上限は今のところ50ミリ秒だと書かれています(MDN の Background Tasks API の解説)。

検証環境で、呼ばれた時点の timeRemaining() を記録すると、中央値は15.6ミリ秒、最大は49.5ミリ秒でした。1回の呼び出しで使った時間も最大48.6ミリ秒で、50ミリ秒に収まりました。

型は TypeScript の lib の dom に入っているので、型定義のパッケージを足さなくても tsc の型チェックが通りました。

手が空いたときに少しずつ進めるフック

作ったフックは次のとおりです。items の1件ずつに fn をかけ、何件終わったか(done)と、全件が終わったときの結果(results)を返します。requestIdle と cancelIdle は、requestIdleCallback と cancelIdleCallback を包んだ関数で、中身は Safari の節に載せます。

ts
import { useEffect, useState } from 'react';
import { cancelIdle, requestIdle } from './idle';

type State<T, R> = { source: readonly T[]; done: number; results: R[] | null };

// items の1件ずつに fn をかける処理を、ブラウザの手が空いたときに少しずつ進める
export function useIdleMap<T, R>(items: readonly T[], fn: (item: T) => R, timeout?: number) {
  const [state, setState] = useState<State<T, R>>({ source: items, done: 0, results: null });

  useEffect(() => {
    let index = 0;
    const out: R[] = [];
    let id = 0;

    const step = (deadline: IdleDeadline) => {
      // 時間切れで呼ばれたとき(timeRemaining() が 0)でも、1件は進める
      let first = true;
      while (index < items.length && (first || deadline.timeRemaining() > 1)) {
        out.push(fn(items[index]));
        index++;
        first = false;
      }
      if (index < items.length) {
        setState({ source: items, done: index, results: null });
        id = requestIdle(step, { timeout });
      } else {
        setState({ source: items, done: index, results: out });
      }
    };

    id = requestIdle(step, { timeout });
    return () => cancelIdle(id);
  }, [items, fn, timeout]);

  // items が変わった直後は、前の items の進み具合を見せない
  const current = state.source === items ? state : { done: 0, results: null };
  return { done: current.done, total: items.length, results: current.results };
}

step は、最初の1件は残り時間に関係なく計算し、そのあとは timeRemaining() が1ミリ秒以下になるまで1件ずつ計算します。残りがあれば、自分をもう一度登録します。MDN にも、呼ばれた関数の中で requestIdleCallback() を呼び直すと、次の機会に続きを回せると書かれています。

使う側では、進み具合を <progress> と文で見せ、終わったら結果を出します。

tsx
import { heavyScore } from './heavy';
import { useIdleMap } from './useIdleMap';

type Props = { items: readonly number[] };

export function ScoreReport({ items }: Props) {
  const { done, total, results } = useIdleMap(items, heavyScore, 1000);

  if (results === null) {
    return (
      <div>
        <progress value={done} max={total} />
        <p role="status">{total.toLocaleString()}件中 {done.toLocaleString()}件を計算しました</p>
      </div>
    );
  }
  const max = Math.max(...results);
  return <p role="status">{total.toLocaleString()}件の計算が終わりました(最大のスコアは {max})</p>;
}
「スコアの計算」の見出しの下に、「ほかの操作(1回押しました)」と「閉じる」のボタン、少しだけ進んだプログレスバー、「10,000件中 672件を計算しました」の文が並んでいる
幅375pxの画面で、計算の途中に撮った表示。計算中に押したボタンの回数も増えている

state の更新は、1回の呼び出しにつき1回にする

setState は1件ごとではなく、1回の step の終わりにだけ呼んでいます。検証では、step が294回呼ばれ、React が画面を更新した回数は、最初の表示を含めて295回でした。

MDN の Background Tasks API の解説では、呼ばれた関数の中で DOM を変えるのは避けるよう勧められています。検証では、step の中で setState を呼んでいますが、React が画面を更新した295回のうち、step の中で起きたものは1回もありませんでした。

fn には、レンダーのたびに作り直さない関数を渡す

fn は useEffect の依存配列に入っています。そのため、useIdleMap(items, (n) => heavyScore(n)) のように、レンダーのたびに新しい関数を作って渡すと、進み具合を更新するたびにエフェクトが後片付けされ、計算が最初からやり直しになりました。

検証では、表示は3秒後に74件まで進んだあと、5秒後には34件に戻っていて、5秒たっても100件に届きませんでした。そのあいだに heavyScore は7,719回呼ばれていました。heavyScore のようにコンポーネントの外で定義した関数を渡せば、レンダーのたびに変わることはありません。公式リファレンスにも、エフェクトがレンダー中に作った関数に依存していると、必要以上に実行されてしまうと書かれています(公式リファレンスにある、関数型の不要な依存値を削除する方法)。

後片付けで cancelIdleCallback を呼ぶ

開発用ビルドの StrictMode では、React がエフェクトの準備と後片付けを1回余分に実行します(公式リファレンスの StrictMode の説明)。後片付けで cancelIdle(id) を呼んでいれば、1回目の登録は呼ばれる前に取り消されます。検証でも、heavyScore を呼んだ回数はちょうど1万回でした。

比べるため、後片付けの return () => cancelIdle(id); を消して同じことをすると、1回目と2回目の計算が並んで走りました。

heavyScore を呼んだ回数 最初に「終わりました」が出るまで
後片付けで取り消す 10,000回 5,029ミリ秒
後片付けで取り消さない 20,000回 9,575ミリ秒

取り消さない場合の9,575ミリ秒は、2つの計算のうち先に終わった方の時刻です。先に終わった計算が「10,000件の計算が終わりました」を出したあと、遅れていた方の計算が「10,000件中 9,571件を計算しました」に表示を戻しました。

計算の途中で「閉じる」を押し、コンポーネントを外したときも同じです。取り消す場合は、閉じたときの721回から2秒後も721回のままでした。取り消さない場合は、閉じたときの186回から、2秒後には220回に増えていて、画面にない計算が続いていました。

timeout を付けないと、忙しいあいだは一度も呼ばれない

requestIdleCallback は、ブラウザの手が空かないかぎり呼ばれません。MDN の requestIdleCallback() の説明には、必ず済ませたい処理には timeout を付けることが強く勧められていて、付けないと数秒たっても呼ばれないことがあると書かれています。timeout のミリ秒が過ぎると、手が空いていなくても呼ばれます。

検証では、計算を始めると同時に、20ミリ秒の処理を休みなくつないで3秒間メインスレッドをふさぎました。

最初に呼ばれた時刻 2.5秒の時点で終わった件数
timeout を付けない 3,015ミリ秒(ふさいでいたのが終わったあと) 0件
timeout: 1000 1,023ミリ秒(didTimeout が true) 2件

timeout で呼ばれたときは didTimeout が true で、timeRemaining() は0でした。フックは、このとき1件だけ計算して次を登録し直します。MDN の didTimeout の説明でも、このときは必要な処理か、ごく少しの処理だけをして、残りは次の呼び出しに回すよう勧められています(MDN の IdleDeadline.didTimeout の説明)。

一方で、MDN の Background Tasks API の解説には、timeout で呼ばれると、時間が足りないときにも処理が割り込むので、画面がもたつく原因にもなると書かれています。timeout は、必ず済ませたい処理にだけ付けます。

Safari には requestIdleCallback がないので、setTimeout で代わりに呼ぶ

MDN の互換性の表では、requestIdleCallback は Chrome 47、Firefox 55 から使えます。Safari は、設定で有効にしたプレビュー版(Technology Preview)でしか使えないとされています(MDN の requestIdleCallback() の互換性の表)。

そこで、フックが使う requestIdle と cancelIdle は、requestIdleCallback がないときに setTimeout で代わりに呼ぶようにしました。

ts
// requestIdleCallback がないブラウザ(Safari など)では setTimeout で代わりに呼ぶ
const hasIdle = typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function';

export function requestIdle(callback: IdleRequestCallback, options?: IdleRequestOptions): number {
  if (hasIdle) return window.requestIdleCallback(callback, options);
  return window.setTimeout(() => {
    const start = performance.now();
    // 1回に使ってよい時間を 10 ミリ秒に決めて、IdleDeadline の形をまねる
    callback({ didTimeout: false, timeRemaining: () => Math.max(0, 10 - (performance.now() - start)) });
  }, 1);
}

export function cancelIdle(id: number): void {
  if (hasIdle) window.cancelIdleCallback(id);
  else window.clearTimeout(id);
}

この代わりの書き方は、ブラウザの手が空いているかどうかを知りません。決めた10ミリ秒ずつ計算して、間に setTimeout をはさむだけです。Chromium で requestIdleCallback と cancelIdleCallback を消してから動かすと、全件が終わるまで6,385ミリ秒かかり、50ミリ秒を超えるタスクはありませんでした。計算中のクリックは14ミリ秒で処理されました。

計算が DOM に触らないなら、Web Worker でメインスレッドの外に出す方法もあります。なお、requestIdleCallback 自体は Web Worker の中では使えません(MDN の Background Tasks API の解説)。

​Posted by 86