ReactでWeb Workerを使い、重い計算中も入力を止めない方法

​​React​Playwright,TypeScript,useDeferredValue,Vite,Web Worker

重い計算を描画の中で呼ぶと、計算が終わるまで入力欄に文字が出ません。計算を Web Worker に移すと、計算は別のスレッドで進み、入力欄はすぐに更新されます。

検証環境で、入力のたびに約200ミリ秒かかる計算を4通りの書き方で動かしました。描画の中でそのまま呼ぶと、文字が画面に出るまで中央値で632ミリ秒かかりました。Worker に移すと5〜6ミリ秒でした。ただし、Worker を1つだけ作って使い回すと、計算が順番待ちになり、結果が出るまでの時間が延びました。入力のたびに Worker を作り直し、古い計算を止める書き方では、入力欄はすぐに更新され、結果も計算1回分の時間(約0.24秒)で出ました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、CPU は4コアです。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、vite 8.3.5、@vitejs/plugin-react 6.1.2、playwright 1.56.1 です。.tsx を tsc で型チェックしてから Vite でビルドしました。時間の測定は、Playwright で動かした Chromium 141(ヘッドレス)で、本番用のビルドを使って行いました。

わざと重い計算を用意する

検証には、次の関数を使いました。2 から 2,000,000 までの数を1つずつ割り算で調べ、素数のうち、入力した数字の並びを含むものを数えます。

ts
// わざと遅い計算。2 から limit までの数を試し割りで素数か調べ、
// 10進で書いたときに digits を含む素数の個数を返す
export function countPrimesContaining(digits: string, limit = 2_000_000): number {
  let count = 0;
  for (let n = 2; n <= limit; n++) {
    if (isPrime(n) && String(n).includes(digits)) count++;
  }
  return count;
}

function isPrime(n: number): boolean {
  if (n < 4) return n >= 2;
  if (n % 2 === 0) return false;
  for (let d = 3; d * d <= n; d += 2) {
    if (n % d === 0) return false;
  }
  return true;
}

すべての数を素数か調べてから数字の並びを比べるので、どの入力でも、かかる時間はほぼ同じです。Node.js で呼んだところ、1回に約190〜260ミリ秒かかりました。

text
$ node --experimental-strip-types --no-warnings bench.ts
「3」を含む素数:82892個(Node.js で 252ms)
「31」を含む素数:9521個(Node.js で 233ms)
「314」を含む素数:425個(Node.js で 260ms)
「3141」を含む素数:72個(Node.js で 193ms)
「31415」を含む素数:1個(Node.js で 192ms)
「314159」を含む素数:1個(Node.js で 194ms)

この個数は、あとでブラウザに出た結果が正しいかを確かめるのにも使います。

描画の中で計算すると、入力欄が止まる

まず、描画の中で、そのまま計算を呼ぶ書き方です。入力が変わったときだけ計算するように、useMemo で包んでいます。

tsx
import { useMemo, useState } from 'react';
import { countPrimesContaining } from './primes';

// 描画の中で、重い計算をそのまま呼ぶ書き方
export function SyncSearch() {
  const [digits, setDigits] = useState('');
  const count = useMemo(
    () => (digits === '' ? null : countPrimesContaining(digits)),
    [digits],
  );

  return (
    <>
      <input value={digits} onChange={(e) => setDigits(e.target.value)} />
      <p className="result">{count === null ? '' : `「${digits}」を含む素数:${count}個`}</p>
    </>
  );
}

Chromium でこの画面を開き、「314159」の6文字を100ミリ秒おきに打ちました。前のキーの処理が終わるのを待たずに、決まった間隔で押しています。キーを押した時刻(keydown の timeStamp)と、その文字が入った状態で次のフレーム(requestAnimationFrame)が来た時刻の差を、文字が画面に出るまでの時間としました。測定のコードは React の外に置き、各書き方で3回ずつ測りました。

text
$ node measure.mjs
Chromium 141.0.7390.37
sync 1回目: 入力欄に出るまで(ms) [631, 532, 432, 928, 828, 729] / 入力欄が変わったフレーム 2回 / 最後のキーから結果が「314159」になるまで 729ms / console のエラーと警告 0件
  結果の欄に出た表示: 「314」を含む素数:425個 → 「314159」を含む素数:1個
(中略)

中央値(3回×6キーの18個と、3回の結果から)
sync: 入力欄に出るまで 中央値 632ms・最大 928ms / 結果が追いつくまで 中央値 728ms

3回とも、6文字を打つあいだに入力欄の表示が変わったのは2回だけでした。1回目では、1文字目が画面に出たのはキーを押してから約0.63秒後で、そのときには3文字目までがまとめて出ています。useMemo のリファレンスにあるとおり、useMemo が前の結果を使うのは、依存する値が変わっていないときだけです。入力が変わるたびに、計算そのものは描画の中で最後まで走ります。

useDeferredValue でも、計算の途中に来た入力は待たされる

useDeferredValue で、計算する側に渡す値を遅らせる書き方も試しました。計算する側のコンポーネントは memo で包んでいます。

tsx
import { memo, useDeferredValue, useMemo, useState } from 'react';
import { countPrimesContaining } from './primes';

// useDeferredValue で、計算する側に渡す値を遅らせる書き方
export function DeferredSearch() {
  const [digits, setDigits] = useState('');
  const deferredDigits = useDeferredValue(digits);

  return (
    <>
      <input value={digits} onChange={(e) => setDigits(e.target.value)} />
      <Result digits={deferredDigits} />
    </>
  );
}

const Result = memo(function Result({ digits }: { digits: string }) {
  const count = useMemo(
    () => (digits === '' ? null : countPrimesContaining(digits)),
    [digits],
  );
  return <p className="result">{count === null ? '' : `「${digits}」を含む素数:${count}個`}</p>;
});
text
deferred 1回目: 入力欄に出るまで(ms) [6, 117, 17, 105, 5, 90] / 入力欄が変わったフレーム 4回 / 最後のキーから結果が「314159」になるまで 277ms / console のエラーと警告 0件
  結果の欄に出た表示: 「314159」を含む素数:1個
(中略)
deferred: 入力欄に出るまで 中央値 86ms・最大 182ms / 結果が追いつくまで 中央値 277ms

そのまま呼ぶ書き方よりは速くなりましたが、文字が出るまでに70〜180ミリ秒ほど待たされるキーが残りました。最大の182ミリ秒は、計算1回分の時間を超えていません(後の節で測った、ブラウザの Worker での1回分は185〜199ミリ秒でした)。

useDeferredValue の注意事項には、裏で行う描き直しは、入力などのイベントによる更新で中断されると書かれています。ただ、今回の計算は1つのコンポーネントの中の、1回の関数呼び出しです。筆者は、React が中断できるのは関数が終わったあとで、計算の途中に来たキーは、その計算が終わるまで待たされたと考えています。待ち時間が計算1回分を超えなかったことは、この考えと合います。

計算を Web Worker に移す

MDN の Web Workers API の説明によると、Worker はメインスレッドとは別のスレッドでスクリプトを動かします。重い処理を別のスレッドで行うので、画面を受け持つメインスレッドが止まりません。Worker の中からは DOM を直接さわれません。やりとりは postMessage() で送り、onmessage で受け取ります。送ったデータは共有されず、コピーされます。

Worker 側のファイル src/primes.worker.ts は、次のように書きました。最初に用意した関数をそのまま import しています。結果の型 PrimeResult は、画面側と Worker 側の両方から使う src/types.ts に置きました。

ts
import { countPrimesContaining } from './primes';
import type { PrimeResult } from './types';

// メインスレッドから数字の並びを受け取り、数えた結果を送り返す
self.onmessage = (e: MessageEvent<string>) => {
  const digits = e.data;
  const result: PrimeResult = { digits, count: countPrimesContaining(digits) };
  self.postMessage(result);
};

画面側では、Worker を new Worker(new URL('./primes.worker.ts', import.meta.url), { type: 'module' }) で作ります。Vite のドキュメントの Web Workers の節では、この書き方が推奨されています。new URL() を new Worker() の中に直接書かないと、Worker としては扱われず、ただのファイルの URL として扱われます。オプションも、文字列のような決まった値で書く必要があります。ビルドすると、Worker のファイルは primes.worker-(ハッシュ).js という別のファイルになりました。

text
dist/assets/primes.worker-NoVT5Zml.js    0.28 kB
dist/index.html                          0.46 kB │ gzip:  0.33 kB
dist/assets/index-CYKDw1XK.js          221.64 kB │ gzip: 69.29 kB

src/types.ts の中身は次の1行です。

ts
export type PrimeResult = { digits: string; count: number };

Worker を1つだけ使い回すと、結果が遅れる

最初に試したのは、Worker を1つだけ作り、入力のたびに計算を頼む書き方です。

tsx
import { useEffect, useRef, useState } from 'react';
import type { PrimeResult } from './types';

// Worker を1つだけ作り、入力のたびに計算を頼む書き方
export function QueueWorkerSearch() {
  const [digits, setDigits] = useState('');
  const [result, setResult] = useState<PrimeResult | null>(null);
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    const worker = new Worker(new URL('./primes.worker.ts', import.meta.url), { type: 'module' });
    worker.onmessage = (e: MessageEvent<PrimeResult>) => setResult(e.data);
    workerRef.current = worker;
    return () => worker.terminate();
  }, []);

  function handleChange(value: string) {
    setDigits(value);
    if (value !== '') workerRef.current?.postMessage(value);
  }

  return (
    <>
      <input value={digits} onChange={(e) => handleChange(e.target.value)} />
      <p className="result">{result && `「${result.digits}」を含む素数:${result.count}個`}</p>
    </>
  );
}
text
queue 1回目: 入力欄に出るまで(ms) [5, 6, 7, 7, 6, 6] / 入力欄が変わったフレーム 6回 / 最後のキーから結果が「314159」になるまで 690ms / console のエラーと警告 0件
  結果の欄に出た表示: 「3」を含む素数:82892個 → 「31」を含む素数:9521個 → 「314」を含む素数:425個 → 「3141」を含む素数:72個 → 「31415」を含む素数:1個 → 「314159」を含む素数:1個
(中略)
queue: 入力欄に出るまで 中央値 6ms・最大 7ms / 結果が追いつくまで 中央値 690ms

入力欄は、どのキーでも10ミリ秒以内に更新されました。6文字の入力が、6回のフレームでそのまま画面に出ています。

一方、結果の欄には「3」「31」「314」…と、打っている途中の値に対する結果が、順番に6回出ました。最後のキーから「314159」の結果が出るまでは、中央値で690ミリ秒でした。1つの Worker が、頼まれた6回の計算を1つずつ順に片づけていったためと考えられます。打ち終わったときには、もう要らない途中の計算が残っていて、最後の計算はその後ろで待っていました。

入力のたびに Worker を作り直し、古い計算を止める

途中の計算を待たないように、入力のたびに Worker を作り、次の入力が来たら前の Worker を terminate() で止めるようにしました。MDN の terminate() の説明によると、terminate() は Worker をすぐに止めます。Worker の処理が終わるのを待ちません。

tsx
import { useEffect, useState } from 'react';
import type { PrimeResult } from './types';

// 入力のたびに Worker を作り、前の計算は terminate() で止める書き方
export function WorkerSearch() {
  const [digits, setDigits] = useState('');
  const [result, setResult] = useState<PrimeResult | null>(null);

  useEffect(() => {
    if (digits === '') return;
    let ignore = false;
    const worker = new Worker(new URL('./primes.worker.ts', import.meta.url), { type: 'module' });
    worker.onmessage = (e: MessageEvent<PrimeResult>) => {
      if (!ignore) setResult(e.data);
      worker.terminate();
    };
    worker.postMessage(digits);
    // 次の入力が来たら、終わっていない計算ごと Worker を止める
    return () => {
      ignore = true;
      worker.terminate();
    };
  }, [digits]);

  const pending = digits !== '' && result?.digits !== digits;

  return (
    <>
      <input value={digits} onChange={(e) => setDigits(e.target.value)} />
      <p className="result">
        {result && `「${result.digits}」を含む素数:${result.count}個`}
        {pending && '(計算中…)'}
      </p>
    </>
  );
}

エフェクトは digits が変わるたびに、前の値で後片づけを実行してから、新しい値で準備を実行します(useEffect のリファレンス)。後片づけで前の Worker を止めるので、計算は最後に頼んだ1つだけが残ります。ignore は、古い結果を使わないための目印です。useEffect のリファレンスにある、エフェクトでデータを取得する例と同じ考え方です。

text
worker 1回目: 入力欄に出るまで(ms) [4, 5, 4, 5, 4, 5] / 入力欄が変わったフレーム 6回 / 最後のキーから結果が「314159」になるまで 239ms / console のエラーと警告 0件
  結果の欄に出た表示: (計算中…) → 「314159」を含む素数:1個
(中略)
worker: 入力欄に出るまで 中央値 5ms・最大 9ms / 結果が追いつくまで 中央値 238ms

入力欄は、1つだけを使い回す書き方と同じく、10ミリ秒以内に更新されました。結果の欄には途中の値の結果が出ず、「(計算中…)」のあとに「314159」の結果だけが出ました。最後のキーから結果が出るまでは中央値で238ミリ秒で、計算1回分とほぼ同じです。個数も、Node.js で数えた値と同じでした。

Worker を毎回作る手間がどれくらいかも測りました。作った直後の Worker で計算した時間と、同じ Worker でもう1回計算した時間を、3回ずつ比べています。

text
  Worker を作ってから最初の結果まで(ms) [196, 191, 199] / 同じ Worker での2回目(ms) [188, 193, 185]

この検証では、1回目と2回目の差は15ミリ秒以内で、Worker を作る時間は計算の時間に比べて小さいものでした。ただし、この Worker のファイルは 0.28 kB と小さなものです。大きなライブラリを読み込む Worker では、作り直しにもっと時間がかかるかもしれません(この検証では試していません)。

ポイント

Worker に移すと入力欄は止まりませんが、Worker の中でも計算は1つずつ進みます。入力のたびに計算を頼むなら、古い計算を terminate() で止めないと、結果が出るまでの時間が延びます。

描画の中でそのまま計算する書き方と、入力のたびに Worker を作り直す書き方で、「314159」を150ミリ秒おきに打つ様子を GIF にしました。

上はそのまま計算する画面で、6回のキー入力のあいだ入力欄と時計がほとんど止まり、「31」「31415」「314159」と飛び飛びに表示される。下は Web Worker で計算する画面で、入力欄に1文字ずつ出て、時計も止まらず、最後に結果が出る
150ミリ秒おきに6回キーを押したときの、入力欄と時計の動き(上:そのまま計算、下:Web Worker)

上の「そのまま計算」では、キーを押しても入力欄がしばらく空のままで、「31」「31415」「314159」と、何文字かずつまとめて出ます。そのあいだ、requestAnimationFrame で毎フレーム進めている画面の時計も止まっています。下の「Web Worker で計算」では、入力欄に1文字ずつ出て、時計も進み続けました。

見出し、時計、結果が変わったときのピンクの色は、撮影用にページへ書き足したものです。右上の「キー入力」の表示は、動画を撮ったあとで重ねたものです。録画の時刻とページの時刻を、画面の隅に出した目印で合わせています。下の「Web Worker で計算」では、入力欄の文字とキー入力の表示が、ほぼ同じコマで変わります。6回のうち4回は、重ねた表示のほうが1コマ(約0.07秒)遅れて変わります。測定では、入力欄は約5ミリ秒で更新されていました。

開発中の StrictMode でも、Worker は残らない

開発用ビルドの StrictMode では、エフェクトの準備と後片づけが1回余分に実行されます。Vite の開発用サーバーで、Worker を使う2つの書き方を、StrictMode で包んで開き、Worker を作った数と terminate() を呼んだ数を数えました。数えるために、Worker を包んだ測定用のクラスを、ページを開く前に差し込んでいます。

text
$ node dev-check.mjs
queue: 開いた直後 作った 2・止めた 1 / 打ち終わったあと 作った 2・止めた 1 / 結果「「314159」を含む素数:1個」 / console のエラーと警告 0件
worker: 開いた直後 作った 0・止めた 0 / 打ち終わったあと 作った 6・止めた 6 / 結果「「314159」を含む素数:1個」 / console のエラーと警告 0件

1つだけを使い回す書き方では、開いた直後に Worker が2つ作られ、1つが止められていました。余分に実行された準備で作った Worker を、後片づけで止めているので、動いている Worker は1つだけです。後片づけで terminate() を呼んでいなければ、開発中は Worker が2つ動き続けるはずです(この検証では試していません)。

入力のたびに作り直す書き方では、開いた直後は入力が空なので、Worker は作られません。6文字を打ったあとは、作った Worker は6つで、terminate() も6回呼ばれていました。どちらの書き方でも、結果は正しく出ました。

この検証で試していないこと

Worker に送ったのは短い文字列だけです。MDN の、Worker とのデータの受け渡しの説明によると、Worker とのあいだで受け渡すデータはコピーされるので、大きな配列などを送ると、コピーにも時間がかかるはずです。この検証では、大きなデータを送ったときの時間や、コピーせずに渡す方法(transferable objects)は試していません。

Worker のファイルは、lib に webworker を指定した別の tsconfig で型チェックしました。画面側の tsconfig(lib に dom)でこのファイルをチェックしても、エラーは出ませんでした。Worker の中で使う API が増えたときに、どちらの設定が合うかは確かめていません。

動きは Chromium 141 のヘッドレスでだけ確かめました。ほかのブラウザ、実機のスマホ、CPU の遅い端末は試していません。時間は、実際の画面の描画(ペイント)の時刻ではなく、requestAnimationFrame が呼ばれた時刻で測っています。

​Posted by 86