Web Worker で重い処理を裏で回す

Vite の new Worker(new URL()) で重い計算を裏で動かし、Promise 化・terminate() での中止・transfer を示す。Tauri の API を呼べない点と Rust との使い分けも載せる。

パフォーマンス 対象: Tauri 2.x 更新日: 読了目安: 約9分 perf-008
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. Worker のファイルを書く
  4. 呼び出し側で Promise にまとめ、途中で止める
  5. 大きなデータは transfer で渡す
  6. 2. Rust に任せる場合との比較
  7. 動作確認
  8. よくあるエラーと対処法
  9. 注意点
  10. 関連レシピ

ページの JS は、画面の描画やクリックの処理と同じ 1 本のスレッド(メインスレッド)で動きます。数百ミリ秒以上かかる計算をそのまま書くと、その間ボタンが反応せず、アニメーションも止まります。Web Worker を使うと、計算を WebView の中の別スレッドに移せます。Vite なら追加の設定なしで TypeScript の Worker を書けます。ただし Worker からは invoke などの Tauri の API を呼べないので、Rust 側の別スレッドに任せる方法(rust-013)との使い分けも説明します。

前提条件

プラグインや権限は不要です。Worker は専用のファイルに書き、呼び出し側で作ります。new URL() は new Worker() の引数に直接書きます。Vite はこの形を目印に Worker のファイルを見つけてビルドするので、URL を変数に入れてから渡すと、ビルド後に Worker として正しく出力されません。

書き方特徴
new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })標準の書き方。別ファイルとして出力される
import X from './x.worker?worker' → new X()Vite 独自の書き方。こちらも別ファイルとして出力される
import X from './x.worker?worker&inline' → new X()呼び出し側のファイルに埋め込まれ、blob: の URL から起動する

Vite のテンプレートの tsconfig.json は lib が DOM なので、Worker のファイルでも self は Window の型です。ここで使う self.onmessage と self.postMessage(値, { transfer }) は Window にも同じ形があるので、そのまま型チェックが通ります。

1. フロントエンドから実装する (TypeScript)

Worker のファイルを書く

例として、rust-013 と同じ「300 万までの素数を数える」計算を Worker で行います。1% 進むごとに進み具合を、最後に結果を送ります。メッセージには呼び出しごとの id を付け、どの呼び出しへの返事かを見分けます。

// src/workers/primes.worker.ts
// Worker の中には window も document も無い。使えるのは self と一部の Web API だけ
type PrimeRequest = { id: number; limit: number };
type PrimeMessage =
  | { id: number; type: 'progress'; percent: number }
  | { id: number; type: 'done'; count: number };

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

self.onmessage = (e: MessageEvent<PrimeRequest>) => {
  const { id, limit } = e.data;
  let count = 0;
  let last = -1;
  for (let n = 0; n <= limit; n++) {
    const percent = Math.floor((n * 100) / Math.max(limit, 1));
    if (percent !== last) {
      last = percent; // 送るのは 1% 進むごと(毎回送るとメインスレッドが追いつかない)
      self.postMessage({ id, type: 'progress', percent } satisfies PrimeMessage);
    }
    if (isPrime(n)) count++;
  }
  self.postMessage({ id, type: 'done', count } satisfies PrimeMessage);
};

呼び出し側で Promise にまとめ、途中で止める

postMessage と onmessage のままでは扱いにくいので、id ごとに Promise を覚えておき、返事が来たら解決する関数にまとめます。Worker は 1 つを使い回します。クリックのたびに作ると起動の時間とメモリが無駄になり、止め忘れればそのまま残ります(メモリリークしていないか検査する)。

途中で止めるには terminate() を呼びます。計算の途中でもその場で止まるので、Rust の処理のように中止のフラグを確かめる仕組みは要りません。止めた Worker は再開できないので、次の呼び出しで作り直します。

// src/primes.ts(型は Worker 側と同じものを使う。実際は共通のファイルに置いて import type で共有する)
type PrimeMessage =
  | { id: number; type: 'progress'; percent: number }
  | { id: number; type: 'done'; count: number };
type Job = { resolve: (count: number) => void; reject: (e: Error) => void; onProgress?: (percent: number) => void };

let worker: Worker | null = null;
const jobs = new Map<number, Job>();
let nextId = 1;

function getWorker(): Worker {
  if (worker) return worker;
  // new URL() は new Worker() の中に直接書く
  const w = new Worker(new URL('./workers/primes.worker.ts', import.meta.url), { type: 'module' });
  w.onmessage = (e: MessageEvent<PrimeMessage>) => {
    const job = jobs.get(e.data.id);
    if (!job) return;
    if (e.data.type === 'progress') {
      job.onProgress?.(e.data.percent);
    } else {
      jobs.delete(e.data.id);
      job.resolve(e.data.count);
    }
  };
  w.onerror = (e) => cancelAll(new Error(`worker error: ${e.message}`));
  worker = w;
  return w;
}

/** Worker を止め、待っている呼び出しをすべて失敗させる */
export function cancelAll(reason = new Error('cancelled')) {
  worker?.terminate(); // 計算の途中でもその場で止まる
  worker = null; // 次の呼び出しで作り直す
  for (const job of jobs.values()) job.reject(reason);
  jobs.clear();
}

export function countPrimes(limit: number, onProgress?: (percent: number) => void): Promise<number> {
  const id = nextId++;
  return new Promise((resolve, reject) => {
    jobs.set(id, { resolve, reject, onProgress });
    getWorker().postMessage({ id, limit });
  });
}

const bar = document.querySelector<HTMLProgressElement>('#bar'); // <progress max="100">
document.querySelector('#start')?.addEventListener('click', async () => {
  try {
    const count = await countPrimes(3_000_000, (p) => {
      if (bar) bar.value = p;
    });
    console.log(`素数は ${count} 個`);
  } catch (e) {
    console.log(e instanceof Error ? e.message : String(e));
  }
});
document.querySelector('#cancel')?.addEventListener('click', () => cancelAll());

1 つの Worker は届いた順に処理するので、続けて呼ぶと前の計算を待ちます。並べて走らせるなら、navigator.hardwareConcurrency(論理コア数)を目安に Worker を複数作ります。

大きなデータは transfer で渡す

postMessage で渡した値は、既定ではコピーされます。画像の画素のような大きな ArrayBuffer は、transfer に入れるとコピーせずに移せます。移した側の ArrayBuffer は長さ 0 になり、使えなくなります。

// 画像の画素を Worker でグレースケールにする。画素の ArrayBuffer はコピーせずに移す
const canvas = document.querySelector('canvas');
const ctx = canvas?.getContext('2d');
if (canvas && ctx) {
  const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const w = new Worker(new URL('./workers/gray.worker.ts', import.meta.url), { type: 'module' });
  w.onmessage = (e: MessageEvent<ArrayBuffer>) => {
    ctx.putImageData(new ImageData(new Uint8ClampedArray(e.data), canvas.width, canvas.height), 0, 0);
    w.terminate(); // 1 回きりの Worker は使い終わったら止める
  };
  const buffer = image.data.buffer;
  w.postMessage(buffer, [buffer]); // 第 2 引数に入れたものは移る
  console.log(image.data.byteLength); // 0(こちらではもう使えない)
}
// src/workers/gray.worker.ts
self.onmessage = (e: MessageEvent<ArrayBuffer>) => {
  const px = new Uint8ClampedArray(e.data); // RGBA の順に 4 バイトずつ
  for (let i = 0; i < px.length; i += 4) {
    const y = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
    px[i] = px[i + 1] = px[i + 2] = y;
  }
  self.postMessage(e.data, { transfer: [e.data] }); // 返すときも移す
};

2. Rust に任せる場合との比較

どちらも画面を固めずに重い計算をする手段ですが、得意なことが違います。

Web WorkerRust の別スレッド(rust-013)
向いているデータページの中にあるもの(Canvas の画素、読み込み済みの JSON)ファイル、OS の情報、Rust のクレートで扱うもの
Tauri の API使えない(メインスレッドで呼んで結果を渡す)使える
途中で止めるterminate() ですぐ止まるフラグを確かめて自分で抜ける
データの受け渡しpostMessage(transfer ならコピーなし)invoke の引数と戻り値、Channel
画面を隠している間ページと一緒に止まることがある動き続ける

ファイルを読んで集計するような処理は、Rust に任せればデータが IPC を往復せずに済みます。逆に、ページの中で作ったデータを加工して表示に戻すだけなら、Worker の方が受け渡しが少なく済みます。どちらで動いているかは CPU 使用率を常時監視する で確かめられます。Worker は WebView のプロセスで動くので、アプリ自身の値には出ず、PC 全体の値だけが上がります。

動作確認

npm run tauri dev で起動して開始ボタンを押すと、プログレスバーが進み、少しするとコンソールに次のように出ます。計算中もボタンやスクロールは普段どおり反応し、中止ボタンを押すと cancelled と出て終わります。

素数は 216816 個

比べるために、同じループを Worker を使わずにクリックの処理の中で直接回すと、終わるまでプログレスバーもボタンも止まります。

よくあるエラーと対処法

  • Worker の中で invoke を呼ぶと、window が定義されていない趣旨の ReferenceError になる: Tauri の JS API はページの window にある仕組みを使うので、Worker からは呼べません。document や localStorage も同様です。ファイルの読み込みや Rust の呼び出しはメインスレッドで行い、結果を postMessage で Worker に渡します。
  • tauri dev では動くのに、ビルド後だけ Worker が起動しない: new URL() を変数に入れてから渡すなどして、Vite が Worker のファイルを見つけられていません。前提条件の表の形で直接書きます。
  • 送れない値を渡した趣旨の DataCloneError: 関数、DOM の要素、Promise などは postMessage で送れません。送れるのは数値・文字列・配列・プレーンなオブジェクト・ArrayBuffer などです。クラスのインスタンスは、メソッドの無いただのオブジェクトとして届きます。
  • 送った後の ArrayBuffer が空になる: transfer に入れたためで、正常な動きです。送った後も使うなら transfer に入れずに送ります(コピーになります)。

注意点

  • 画面を隠している間: 最小化や非表示のページは、しばらくするとタイマーの間引きや処理の一時停止の対象になります(設定の backgroundThrottling で変えられるのは macOS 14 以降だけで、Windows・Linux は非対応)。Worker もページに属するので、隠している間も続けたい処理は Rust 側に置きます。
  • CSP: ?worker&inline の Worker は blob: の URL から起動します。tauri.conf.json で CSP を設定しているなら、worker-src に blob: を足すか、別ファイルとして出力される書き方にします。
  • 起動時の計算: 初期化の重い計算を Worker に移すと、最初の画面が早く出ます。効果は アプリの起動時間を計測する で確かめます。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する