1. ホーム
  2. JavaScript

【JavaScript】Web Worker の使い方|重い処理を別スレッドに逃がして画面を固まらせない方法

Share

CSV を数万行読み込んで集計する、画像にフィルターをかける、暗号化の計算をする。こうした重い処理をブラウザーで実行すると、終わるまでページ全体が固まります。ボタンを押しても反応せず、スクロールもできず、アニメーションも止まってしまいます。

この問題を解決するのが Web Worker です。重い処理をメインスレッドとは別のスレッドに任せることで、計算中もページの操作を受け付けられるようになります。

この記事では、Worker の作り方とデータのやり取り、渡せるデータの制限、Worker の中でできること・できないこと、そして動かないときの原因までを解説します。

なぜ画面が固まるのか

ブラウザーで動く JavaScript は、基本的に1本のスレッド(メインスレッド)で実行されます。このスレッドは、JavaScript の実行だけでなく、クリックの処理や画面の描画も担当しています。

つまり、重い for ループが回っている間、ブラウザーは他のことを何もできません。async や Promise を付けても同じです。非同期処理は「待ち時間のあいだ他の処理に順番を譲る」仕組みであって、計算そのものを別の場所で動かしてくれるわけではないからです。

Web Worker は、文字どおり別のスレッドで JavaScript を動かします。メインスレッドとはメモリを共有せず、メッセージのやり取りだけでつながっている、独立した実行環境です。

基本の使い方

Worker は別ファイルとして用意します。メインスレッド側で new Worker() にそのファイルの URL を渡すと、Worker が起動します。

worker.js
// メインスレッドからメッセージを受け取る
self.addEventListener('message', (event) => {
  const max = event.data;

  // 重い処理(指定した数までの素数を数える)
  let count = 0;
  for (let n = 2; n <= max; n++) {
    let isPrime = true;
    for (let i = 2; i * i <= n; i++) {
      if (n % i === 0) {
        isPrime = false;
        break;
      }
    }
    if (isPrime) count++;
  }

  // 結果をメインスレッドへ返す
  self.postMessage(count);
});
main.js
// Worker を起動する
const worker = new Worker('./worker.js');

// Worker からの結果を受け取る
worker.addEventListener('message', (event) => {
  document.querySelector('#result').textContent = `${event.data} 個`;
});

// 計算を依頼する(この行では待たずにすぐ次へ進む)
document.querySelector('#start').addEventListener('click', () => {
  worker.postMessage(5000000);
});

やり取りは postMessage() で送り、message イベントで受け取ります。送られてきたデータは event.data に入っています。この2つを両方向で使うだけなので、覚えることは多くありません。

重要なのは、worker.postMessage() がすぐに戻ってくることです。処理の完了を待たないので、この間もボタンは押せますし、アニメーションも止まりません。同じループをメインスレッドで実行すると、数秒間まったく操作できなくなるのと対照的です。

複数の用件を1つの Worker で扱う

用途が複数あるときは、送るデータに種類を持たせて振り分けます。オブジェクトで送れるので、次のような形が扱いやすいです。

worker.js
self.addEventListener('message', (event) => {
  const { type, payload, id } = event.data;

  switch (type) {
    case 'sum':
      self.postMessage({ id, result: payload.reduce((a, b) => a + b, 0) });
      break;
    case 'sort':
      self.postMessage({ id, result: [...payload].sort((a, b) => a - b) });
      break;
    default:
      self.postMessage({ id, error: `未知の処理: ${type}` });
  }
});

id を持たせているのは、複数の依頼を同時に出したときに「どの依頼の結果か」を見分けるためです。Worker からの返信は依頼した順に返るとは限らないので、この対応付けがないと結果を取り違えます。

渡せるデータと渡せないデータ

postMessage() で渡したデータは、参照が共有されるのではなく複製されて相手に届きます。このときに使われるのが構造化複製アルゴリズムで、コピーできるものには制限があります。

渡せるもの渡せないもの
数値・文字列・真偽値・null関数(アロー関数・メソッドを含む)
配列・プレーンなオブジェクトDOM 要素(document や Element)
Date・RegExp・Map・Setクラスのインスタンス(プロトタイプは失われる)
ArrayBuffer・型付き配列・Blob・FileError 以外の特殊なオブジェクト、プロキシ

関数を渡そうとすると DataCloneError が発生します。「処理そのものを Worker に送る」という使い方はできず、処理は Worker 側のファイルに書いておく必要があります。

また、クラスのインスタンスを渡すと、プロパティはコピーされますがメソッドは失われ、ただのオブジェクトになります。受け取った側でメソッドを呼ぶとエラーになるので、必要な値だけを平らなオブジェクトにして渡すほうが安全です。

大きなデータはコピーせず転送する

数十 MB の ArrayBuffer をコピーすると、その複製自体に時間がかかります。postMessage() の第2引数に転送したいオブジェクトを渡すと、コピーではなく所有権の移動になり、一瞬で渡せます。

main.js
const buffer = new ArrayBuffer(64 * 1024 * 1024);

// 第2引数に渡すと、コピーではなく所有権が移る
worker.postMessage(buffer, [buffer]);

// 渡したあと、送信側では中身が空になる
console.log(buffer.byteLength); // 0

転送したあと、送信側の ArrayBuffer は使えなくなります(byteLength が 0 になります)。渡したデータをこちらでも使い続けたい場合は、転送ではなく通常のコピーを選んでください。

Worker の中でできること・できないこと

Worker は独立した実行環境なので、ページの DOM には一切触れません。document や window は存在せず、参照すると ReferenceError になります。

使えるもの使えないもの
fetch()・XMLHttpRequest・WebSocketdocument(DOM 操作全般)
setTimeout()・setInterval()window・alert()・confirm()
IndexedDB・CachelocalStorage・sessionStorage
importScripts()(他のスクリプトの読み込み)parent など親ページへの直接アクセス

画面を更新したい場合は、Worker から結果を postMessage() で送り返し、DOM の書き換えはメインスレッド側で行います。役割分担としては「計算は Worker、表示はメイン」と考えると分かりやすいはずです。

なお Worker の中では、グローバルを指す this や self が使えます。window の代わりに self を使う、と覚えておけば困りません。

モジュールとして読み込む

type: 'module' を指定すると、Worker のファイルの中で import が使えます。処理を複数のファイルに分けたいときはこちらが便利です。

main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), {
  type: 'module',
  name: 'csv-parser', // 開発者ツールで識別しやすくなる
});
worker.js
// モジュールワーカーなら import が使える
import { parseCsv } from './parse-csv.js';

self.addEventListener('message', (event) => {
  self.postMessage(parseCsv(event.data));
});

new URL('./worker.js', import.meta.url) という書き方は、Vite や webpack といったバンドラーが Worker のファイルを認識して、正しく出力に含めてくれる形でもあります。文字列でパスを直書きすると、ビルド後にファイルが見つからなくなることがあるので、この書き方を覚えておくと安全です。

終了とエラー処理

Worker は明示的に終了しないと動き続けます。使い終わったら terminate() で止めます。Worker 側から自分で終わらせる場合は self.close() です。

main.js
// 実行中でも強制的に止める(キャンセルにも使える)
worker.terminate();

// Worker 内で発生した例外を受け取る
worker.addEventListener('error', (event) => {
  console.error(`${event.filename}:${event.lineno} ${event.message}`);
});

// 複製できないデータが送られてきたときに発生する
worker.addEventListener('messageerror', (event) => {
  console.error('受け取ったデータを復元できませんでした', event);
});

terminate() は処理の途中でも問答無用で止めます。「検索中に次の入力が来たら前の検索を打ち切る」といったキャンセル処理にそのまま使えます。ただし Worker の起動には数ミリ秒のコストがあるので、頻繁に作り直すより、1つを使い回してメッセージで指示するほうが効率的です。

Worker が動かないときの確認ポイント

ローカルのファイルを直接開いている

HTML ファイルをダブルクリックして file:// で開いた場合、Worker の生成はセキュリティ上の制約でブロックされます。開発中でもローカルサーバー(npx serve や Vite の開発サーバーなど)を立てて http://localhost で確認してください。

Worker のファイルが見つからない

new Worker('./worker.js') のパスは、HTML からではなくそのスクリプトの位置を基準に解決されます。ビルドツールを使っている場合は出力先のディレクトリ構成も関係するので、前述の new URL('./worker.js', import.meta.url) を使うのが確実です。ネットワークタブで 404 になっていないか確認するとすぐ分かります。

別ドメインのファイルを指定している

Worker のスクリプトは同一オリジンから読み込む必要があります。CDN に置いたファイルを直接指定すると起動できません。どうしても外部のコードを使いたい場合は、いったん自分のサーバーから配信するか、Worker 内で importScripts() を使う形にします。

DOM を触ろうとしている

document is not defined というエラーが出たら、Worker 側のコードで DOM にアクセスしています。ライブラリを importScripts() で読み込んだ場合も、そのライブラリが内部で window を参照していれば同じエラーになります。Worker 環境に対応しているかどうかは、ライブラリのドキュメントで確認してください。

まとめ

Web Worker は、重い処理をメインスレッドから切り離して実行するための仕組みです。new Worker() で起動し、postMessage() で依頼し、message イベントで結果を受け取る。この3つが分かれば使い始められます。

注意点は、メモリを共有しないという性質から来るものがほとんどです。データはコピーして渡されるため関数や DOM 要素は渡せず、Worker の中から画面を直接書き換えることもできません。大きなバイナリを渡すときは、postMessage() の第2引数による転送を使うとコピーのコストを避けられます。

すべての処理を Worker にする必要はありません。「これを実行すると数百ミリ秒以上ページが止まる」と分かっている処理だけを移すのが、費用対効果の高い使い方です。

参考ページ