ページの 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 Worker | Rust の別スレッド(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 に移すと、最初の画面が早く出ます。効果は アプリの起動時間を計測する で確かめます。
