React, Vue.js
投稿日:

Web Worker/Comlinkで”重い処理をUIから隔離”

Web WorkerComlinkで重い処理をUIから隔離

こんにちは、株式会社ファストコーディングのフルスタックエンジニア MrFireです。

先日、趣味のバイクツーリングでログデータを分析するWebアプリを使っていた時、大量のGPSデータを読み込むたびに画面が完全にフリーズしてしまい、イライラした経験がありました。入力も効かない、スクロールもできない。この「重い処理でUIが固まる」問題は、Webアプリケーション開発における典型的な課題です。バイクのスピードと同じで、反応が遅いと不快ですよね。

別に走り屋ではありませんが。。。

メインスレッドの占有がもたらすUX崩壊

JavaScriptはシングルスレッドで動作するため、重い処理が走るとメインスレッドが占有され、UI操作がすべてブロックされます。よくある「重い」処理なのが検索結果のフィルタリング処理。一度検索で得たデータのフィルタリングや並べかえを、できるだけスムーズにやりたいからフロントで処理してしまうというのは、気持ちわかるんですが、データ量が多いことでメインスレッドが専有されてしまい、ユーザーは数秒間、何もできなくなります。つまり画面が固まるという状態になるので、INP(Interaction to Next Paint)が悪化し、ユーザー体験が著しく損なわれます。

Web Workerで処理を別スレッド化する

Web Workerを使えば、重い処理を別スレッドで実行し、メインスレッドを解放できます。しかし、従来のWorker実装には課題がありました。

従来のWorker実装の問題点

昔からあるpostMessage方式

// main.js
const worker = new Worker('worker.js');

worker.postMessage({ type: 'PROCESS_CSV', data: csvData });

worker.onmessage = (event) => {
  if (event.data.type === 'RESULT') {
    console.log(event.data.result);
  }
};

// worker.js
self.onmessage = (event) => {
  if (event.data.type === 'PROCESS_CSV') {
    const result = processCSV(event.data.data);
    self.postMessage({ type: 'RESULT', result });
  }
};

いちばん知られているのはpostMessageではないかと思いますが、実装には一苦労です。

例えばPromiseを使いたい場合、自分でPromiseラッパーを実装することになりますが、Worker側で処理が完了したら、メインスレッド側のPromiseをresolveする仕組みを、requestId管理とコールバックマップを使って自前で実装する必要があります。これは非常に煩雑で、コードが肥大化し、バグの温床になります。

また、postMessageとonmessageのペアを何度も書く必要があり、同じような処理が羅列したソースコード、、、、という見たくない結果になりがちです。メッセージ送信→受信のたびに、typeチェック、データ取り出し、レスポンス構築という定型コードを書き続けることになっていきます。複数の処理を連鎖させたい場合は、コールバック地獄に陥りがちです。

さらに、エラーハンドリングも複雑です。Worker内でエラーが発生した場合、それをメインスレッドに適切に伝える仕組みを自分で実装する必要があります。エラーの種類ごとにメッセージタイプを定義し、それぞれに対応するハンドラーを書いていくという修行のような作業。。。TypeScriptを使っていても、メッセージタイプが文字列ベースのため型安全性が活かせず、例えば’PROCESS_CSV’というタイプ名をタイプミスしても、コンパイル時には検出されません。私たちの開発現場でも、この方式を使っていた時期がありましたが、書くのもつらいしバグも多いしと、、、なっていました。

Comlinkで型安全なRPC通信を実現

そんな中現れた救世主はComlink。Comlinkは、Web Workerとのやり取りをRPC(Remote Procedure Call)スタイルで実現するライブラリです。型安全性を保ちながら、まるでローカル関数を呼ぶように Worker の関数を実行できます。

Good Case: Comlinkを使った実装

// worker.ts
import { expose } from 'comlink';

const api = {
  async processCSV(data: string): Promise<ProcessedData> {
    // 重い処理
    const rows = data.split('\n');
    const processed = rows.map(row => {
      // 複雑な集計処理
      return analyzeRow(row);
    });
    return { items: processed, total: processed.length };
  },
  
  async processImage(imageData: ImageData): Promise<Blob> {
    // 画像処理
    const canvas = new OffscreenCanvas(imageData.width, imageData.height);
    const ctx = canvas.getContext('2d')!;
    ctx.putImageData(imageData, 0, 0);
    // リサイズ・圧縮処理
    return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 });
  }
};

expose(api);

// main.ts
import { wrap, Remote } from 'comlink';

const worker = new Worker(new URL('./worker.ts', import.meta.url), {
  type: 'module'
});

const api: Remote<typeof workerApi> = wrap(worker);

// 型安全に呼び出せる
const result = await api.processCSV(csvData);
console.log(result.total); // TypeScriptの型推論が効く

Comlinkを使う一番大きなメリットは、TypeScriptの型定義がそのまま使えることです。Worker側で定義した関数の型が、メインスレッド側でも完全に保持されます。これにより、IDEの補完も効きますし、型エラーもコンパイル時に検出できます。

また、async/awaitで自然に書けるのも大きな利点です。従来のpostMessage方式では、非同期処理を扱うためにコールバック地獄に陥りがちでしたが、Comlinkを使えば普通の非同期関数と同じように書けます。Promiseが自動的に処理されるため、Worker側でPromiseを返せば、メインスレッド側でawaitするだけで結果が得られます。エラーも適切に伝播するため、try-catchで通常のエラーハンドリングが可能です。実際のプロジェクトで導入したところ、コード量が30%削減され、バグも激減しました(心理的にもとっても楽に)。

ワーカー化の採用判断基準

ではすべての処理をWorker化すればいいのかというと、これはまたそういうわけでもないんです。適切な判断基準を持つことが重要です。

判断基準Worker化推奨メインスレッドでOK
処理時間>50ms<50ms
データ量>1MB<1MB
DOM操作不要必要
実行頻度頻繁まれ
ユーザー入力への影響INP悪化が顕著影響軽微

弊社だと、だいたいChrome DevToolsのPerformanceタブで「Long Tasks(50ms以上)」が頻発する場合に、Worker化を検討します。処理時間が50msを超えると、体感的にも明らかな遅延を感じます。また、データ量が1MBを超える場合も、シリアライズのコストを考慮してもWorker化のほうがいい、となるパターンも多いです。

実装ステップと実践パターン

ここからは実際にComlinkを使ったWorker化の、基本的にな流れを書いてみたいと思います。

ステップ1: 重い関数をWorkerへ分離

まず、メインスレッドで実行されている重い関数を特定し、Workerファイルに移動します。

// 元のコード(main.ts)
function analyzeLogData(logs: LogEntry[]): AnalysisResult {
  // 重い集計処理
  const grouped = groupBy(logs, 'userId');
  const stats = calculateStatistics(grouped);
  return { grouped, stats };
}

// Worker化後(worker.ts)
import { expose } from 'comlink';

const api = {
  analyzeLogData(logs: LogEntry[]): AnalysisResult {
    const grouped = groupBy(logs, 'userId');
    const stats = calculateStatistics(grouped);
    return { grouped, stats };
  }
};

expose(api);

ステップ2: 転送可能オブジェクトで高速化

大きなデータをWorkerに渡す際、通常はデータがコピーされます(構造化複製)。これはオーバーヘッドが大きいため、転送可能オブジェクト(Transferable Objects)を使います。いわゆるBuffer系ですね。ArrayBufferやImageDataなどのオブジェクトは、コピーではなくポインタ(ではないと思いますが他に言い方が分からないので・・・)で移動が可能です。これにより、データのコピーコストがゼロになり、大幅な高速化が実現できます。

// main.ts
import { transfer } from 'comlink';

// ArrayBufferやImageDataなどは転送可能
const buffer = new ArrayBuffer(1024 * 1024 * 10); // 10MB
const result = await api.processBuffer(transfer(buffer, [buffer]));

// bufferはもう使えない(ポインタがWorkerに移動)
console.log(buffer.byteLength); // 0

転送可能オブジェクトを使うと、10MBのデータでも数ミリ秒で転送できます。通常のコピーだと数百ミリ秒かかるため、数十倍の高速化です。ただし、転送後は元のオブジェクトが使えなくなるため、あとから使うようなデータの管理には注意してください。

ステップ3: キャンセル・タイムアウトの実装

長時間実行される処理には、キャンセル機能とタイムアウトも実装しましょう。ユーザーが処理を中断したい場合や、処理が想定以上に時間がかかる場合に対応する必要があります。

// worker.ts
const api = {
  async processWithCancel(
    data: string,
    signal: AbortSignal
  ): Promise<Result> {
    const chunks = splitIntoChunks(data);
    const results = [];
    
    for (const chunk of chunks) {
      // キャンセルチェック
      if (signal.aborted) {
        throw new Error('処理がキャンセルされました');
      }
      results.push(processChunk(chunk));
    }
    
    return { results };
  }
};

// main.ts
const controller = new AbortController();

// 10秒でタイムアウト
const timeoutId = setTimeout(() => controller.abort(), 10000);

try {
  const result = await api.processWithCancel(data, controller.signal);
  clearTimeout(timeoutId);
} catch (error) {
  console.error('処理失敗:', error);
}

共通Workerアダプタの実装

複数のWorkerを管理するための共通アダプタも作りましょう。つまりWorkerの生成、終了、エラーハンドリングを一箇所にまとめておくことで、Worker自体の処理は今後考えなくてもよくなります。

// workerAdapter.ts
import { wrap, Remote } from 'comlink';

export class WorkerAdapter<T> {
  private worker: Worker;
  private api: Remote<T>;
  private isTerminated = false;

  constructor(workerUrl: URL) {
    this.worker = new Worker(workerUrl, { type: 'module' });
    this.api = wrap<T>(this.worker);
  }

  getAPI(): Remote<T> {
    if (this.isTerminated) {
      throw new Error('Worker is already terminated');
    }
    return this.api;
  }

  terminate(): void {
    this.worker.terminate();
    this.isTerminated = true;
  }

  // エラーハンドリング
  onError(handler: (error: ErrorEvent) => void): void {
    this.worker.addEventListener('error', handler);
  }
}

// 使用例
const csvWorker = new WorkerAdapter<typeof csvWorkerApi>(
  new URL('./csvWorker.ts', import.meta.url)
);

csvWorker.onError((error) => {
  console.error('Worker error:', error);
  // エラーログ送信、再起動など
});

const result = await csvWorker.getAPI().processCSV(data);

よくある落とし穴と対策

落とし穴1: シリアライズコストの過多

小さなデータを頻繁にWorkerに送ると、シリアライズの計算量が処理に影響してくることがあります。ブラウザはデータをWorkerに送るときにシリアライズしていますが、小さなデータでも、この処理には数ミリ秒かかります。1件ずつ送ると、毎回シリアライズが発生し、トータルで大きなオーバーヘッドになります。

悪い例

// 1件ずつWorkerに送る(非効率)
for (const item of items) {
  await api.processItem(item); // シリアライズが毎回発生
}

良い例

// バッチで送る
const result = await api.processBatch(items); // シリアライズは1回だけ

よく使うのはバッチ処理かなと思います。送信自体を別関数化(バッチ化)しておいて、バッチ関数内で定期的に溜まったら送るように実装します。送るときの基準をパラメータにしておけば、実際のプロジェクトで試しながら調整できますし便利です。実際このバッチ化だけで体感速度あがったお客様もいらっしゃいました。

落とし穴2: 例外の握りつぶし

Worker内のエラーは適切にハンドリングしないと、デバッグが困難になります。Worker内でエラーが発生しても、それをメインスレッドに伝えなければ、処理が失敗したことすら分かりません。特に、try-catchでエラーを握りつぶしてしまうと、何が起きたのか全く分からなくなります。

// worker.ts
const api = {
  async processData(data: string): Promise<Result> {
    try {
      return heavyProcess(data);
    } catch (error) {
      // エラーを適切に伝える
      console.error('Worker error:', error);
      throw new Error(`処理失敗: ${error.message}`);
    }
  }
};

// main.ts
try {
  const result = await api.processData(data);
} catch (error) {
  // エラーログ送信
  sendErrorLog({
    message: error.message,
    stack: error.stack,
    context: 'CSV processing'
  });
  // ユーザーへのフィードバック
  showErrorNotification('データ処理に失敗しました');
}

DOM処理だったりブラウザ内で完結させている計算に比べれば、Workerはネットワークが絡むことも多いので、デバッグが結構面倒になります。転ばぬ先の杖じゃないですが、最初の段階からエラー処理や例外処理はきちんと組んでおきましょう。

落とし穴3: メモリリーク

使ったWorkerは最後、適切に終了しておかないとメモリリークが発生しがちです。特にSPAでページ遷移を繰り返す場合、Workerが蓄積してメモリを圧迫します。

// メモリリーク検出
class WorkerPool {
  private workers: Worker[] = [];

  createWorker(): Worker {
    const worker = new Worker('./worker.js');
    this.workers.push(worker);
    return worker;
  }

  terminateAll(): void {
    this.workers.forEach(w => w.terminate());
    this.workers = [];
  }
}

// Reactでの使用例
useEffect(() => {
  const pool = new WorkerPool();
  const worker = pool.createWorker();
  
  return () => {
    pool.terminateAll(); // クリーンアップ必須
  };
}, []);

Reactのようなフレームワークであれば、コンポーネントのアンマウント時にWorkerを確実に終了させましょう。たとえばuseEffectのクリーンアップ関数で必ずterminateを呼ぶイメージですね。WorkerPoolパターンを使うことで、複数のWorkerを一括管理でき、メモリリークを防げます。

まとめ

Worker化をする、ということ自体、だいたい既存プロジェクトに対してやることが多いと思います。だんだん利用者が増えてきたり、データ量が増えてきて(お客様からなんか重いよね、、、って言われ出して)考える作戦の1つだと思います。

でもなかなか既存コードを変えられない・変えるのが怖い、、という現場もあると思います。株式会社ファストコーディングでは、こういったフロントエンドのパフォーマンス最適化の経験が豊富にありますので、そういうときは、ぜひお気軽にご相談ください。なんか重いよねぐらいで、まずはファストに相談するぐらいに思ってもらえればいいと思います(激重・UIフリーズ状態になると、もう手に負えなくなるかもしれませんので)。