T-CREATOR

JavaScript Web Workers 実践入門:重い処理を別スレッドへ逃がす最短手順

JavaScript Web Workers 実践入門:重い処理を別スレッドへ逃がす最短手順

Web アプリケーションを開発していると、大量のデータ処理や複雑な計算によって画面が固まってしまった経験はありませんか。そんな時に活躍するのが Web Workers です。この技術を使えば、重い処理を別スレッドに移して、メインの UI を快適に保つことができます。

本記事では、Web Workers の基本的な仕組みから実際の実装手順まで、初心者の方にもわかりやすく解説いたします。記事を読み終える頃には、あなたも自信を持って Web Workers を活用できるようになるでしょう。

背景

JavaScript のシングルスレッド問題

JavaScript は基本的に シングルスレッド で動作します。これは、一度に一つの処理しか実行できないことを意味しています。通常の Web ページでは問題になりませんが、複雑な処理が必要になると大きな制約となってしまいます。

以下の図は、JavaScript のシングルスレッド処理の流れを示しています。

mermaidflowchart TD
    start["処理開始"] --> task1["軽い処理1"]
    task1 --> heavy["重い処理<br/>(数秒かかる)"]
    heavy --> task2["軽い処理2"]
    task2 --> ui_update["UI更新"]
    ui_update --> done["処理完了"]

    heavy -.->|ブロック| ui_block["UI操作不可<br/>画面固着"]
    ui_block -.-> heavy

図で理解できる要点:

  • 重い処理の実行中は他の処理が一切実行されない
  • UI 操作やアニメーションも完全に停止してしまう
  • 処理が完了するまでユーザーは何もできない状態になる

UI ブロッキングの課題

シングルスレッドの性質により、以下のような問題が発生します。

問題具体的な影響ユーザー体験への影響
画面の固着クリックやスクロールが効かない操作不能でストレス
アニメーション停止ローディング表示も止まるアプリが壊れたと誤解
応答性の低下数秒間の完全停止使いにくさを感じる

パフォーマンス改善の必要性

現代の Web アプリケーションでは、以下のような重い処理が求められることが多くなっています。

  • 大量データの解析:数万件の CSV ファイル処理
  • 複雑な計算:グラフィック処理や統計計算
  • リアルタイム処理:チャットアプリでの暗号化処理
  • 画像・動画処理:フィルター適用や圧縮処理

これらの処理をメインスレッドで実行すると、必ずユーザビリティが低下してしまいます。だからこそ、Web Workers という解決策が重要になってくるのです。

課題

メインスレッドでの重い処理による画面フリーズ

従来の JavaScript 開発では、すべての処理をメインスレッドで実行していました。これにより、以下のような深刻な問題が発生していました。

javascript// 問題のあるコード例:メインスレッドでの重い処理
function heavyCalculation() {
  console.log('計算開始...');

  // 1億回のループ処理(意図的に重い処理)
  let result = 0;
  for (let i = 0; i < 100000000; i++) {
    result += Math.sqrt(i);
  }

  console.log('計算完了:', result);
  return result;
}

// この関数を呼び出すと画面が数秒間固まる
document
  .getElementById('calc-button')
  .addEventListener('click', () => {
    heavyCalculation(); // UI完全停止
    alert('処理が完了しました'); // 計算完了まで表示されない
  });

上記のコードの問題点:

  • 計算中はすべての UI 操作が無効になる
  • ローディング表示すら更新されない
  • ユーザーはアプリがクラッシュしたと誤解する可能性がある

ユーザビリティの低下

重い処理による UI ブロッキングは、以下のような具体的なユーザビリティ問題を引き起こします。

問題の種類発生場面ユーザーの反応
無応答状態データ処理中のクリック「壊れた?」と不安
進捗不明長時間処理の実行中「いつ終わる?」とイライラ
操作不能処理中の他機能利用「使いにくい」と評価低下

従来の解決策の限界

これまで、開発者は以下のような方法で問題を解決しようとしてきました。

javascript// 従来の解決策1: setTimeoutによる分割処理
function heavyCalculationWithTimeout(data, callback) {
  let index = 0;
  const batchSize = 1000;

  function processBatch() {
    const endIndex = Math.min(
      index + batchSize,
      data.length
    );

    // バッチ処理
    for (let i = index; i < endIndex; i++) {
      // 何らかの処理
      processItem(data[i]);
    }

    index = endIndex;

    if (index < data.length) {
      // 次のバッチを非同期で実行
      setTimeout(processBatch, 0);
    } else {
      callback();
    }
  }

  processBatch();
}

しかし、この方法にも以下の限界がありました:

  • 処理が複雑になる:コードの可読性が大幅に低下
  • デバッグが困難:非同期処理の追跡が難しい
  • パフォーマンス限界:根本的な解決にならない
  • メモリ使用量の増加:分割処理による overhead

これらの限界を克服するために、Web Workers という新しいアプローチが生まれました。

解決策

Web Workers の仕組み

Web Workers は、JavaScript の処理を 別スレッド で実行する仕組みです。これにより、重い処理をバックグラウンドで実行しながら、メインスレッドの UI を快適に保つことができます。

以下の図は、Web Workers を使った処理の流れを示しています。

mermaidflowchart LR
    subgraph main["メインスレッド"]
        ui["UI処理"] --> message_send["メッセージ送信"]
        message_receive["メッセージ受信"] --> ui_update["UI更新"]
    end

    subgraph worker["Workerスレッド"]
        receive["メッセージ受信"] --> heavy_calc["重い計算処理"]
        heavy_calc --> send["結果送信"]
    end

    message_send -->|postMessage| receive
    send -->|postMessage| message_receive

    style main fill:#e1f5fe
    style worker fill:#f3e5f5

図で理解できる要点:

  • メインスレッドと Worker スレッドが並行して動作
  • メッセージパッシングによる安全な通信
  • UI は常にレスポンシブな状態を維持

基本的な使い方

Web Workers の実装は、以下の 3 つのステップで完了します。

ステップ 1: Worker ファイルの作成

まず、別スレッドで実行したい処理を記述した JavaScript ファイルを作成します。

javascript// worker.js - Worker側のコード
self.addEventListener('message', function (event) {
  const { type, data } = event.data;

  if (type === 'HEAVY_CALCULATION') {
    // 重い計算処理
    const result = performHeavyCalculation(data);

    // 結果をメインスレッドに送信
    self.postMessage({
      type: 'CALCULATION_COMPLETE',
      result: result,
    });
  }
});

function performHeavyCalculation(data) {
  let result = 0;
  for (let i = 0; i < data.iterations; i++) {
    result += Math.sqrt(i) * Math.cos(i);
  }
  return result;
}

ステップ 2: メインスレッドで Worker を起動

次に、メインスレッドから Worker を作成し、処理を依頼します。

javascript// main.js - メインスレッドのコード
class WorkerManager {
  constructor(workerPath) {
    this.worker = new Worker(workerPath);
    this.setupEventListeners();
  }

  setupEventListeners() {
    this.worker.addEventListener('message', (event) => {
      const { type, result } = event.data;

      if (type === 'CALCULATION_COMPLETE') {
        this.handleCalculationComplete(result);
      }
    });

    this.worker.addEventListener('error', (error) => {
      console.error('Worker error:', error);
    });
  }

  startCalculation(iterations) {
    this.worker.postMessage({
      type: 'HEAVY_CALCULATION',
      data: { iterations },
    });
  }

  handleCalculationComplete(result) {
    document.getElementById(
      'result'
    ).textContent = `結果: ${result}`;
    document.getElementById('loading').style.display =
      'none';
  }
}

ステップ 3: UI との連携

最後に、ユーザーの操作と連携させます。

javascript// UI連携のコード
document.addEventListener('DOMContentLoaded', () => {
  const workerManager = new WorkerManager('./worker.js');
  const calcButton = document.getElementById('calc-button');
  const loadingElement = document.getElementById('loading');

  calcButton.addEventListener('click', () => {
    // UIの状態を更新
    loadingElement.style.display = 'block';
    calcButton.disabled = true;

    // 重い処理を開始(非ブロッキング)
    workerManager.startCalculation(100000000);

    // この時点でもUIは操作可能
    console.log('処理開始 - UIは引き続き操作可能です');
  });
});

メッセージパッシングの概念

Web Workers では、メインスレッドと Worker スレッド間で直接変数を共有することはできません。代わりに メッセージパッシング という仕組みを使って、安全にデータをやり取りします。

javascript// メッセージパッシングの基本パターン
// メインスレッド → Worker
worker.postMessage({
  command: 'PROCESS_DATA',
  payload: { items: [1, 2, 3, 4, 5] },
});

// Worker → メインスレッド
self.postMessage({
  status: 'SUCCESS',
  result: processedData,
});

この仕組みの利点:

  • データの安全性:意図しない変更を防止
  • スレッドセーフ:競合状態の回避
  • 明確な責任分離:処理の流れが理解しやすい

具体例

シンプルな計算処理の例

最初に、基本的な数値計算を Web Workers で実装してみましょう。素数を見つける処理を例に、具体的な実装を見ていきます。

HTML 構造

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>Web Workers 素数計算デモ</title>
  </head>
  <body>
    <h1>素数計算デモ</h1>
    <div>
      <label for="max-number">最大値:</label>
      <input
        type="number"
        id="max-number"
        value="100000"
        min="1000"
      />
    </div>
    <button id="calc-button">素数を計算</button>
    <button id="cancel-button" disabled>キャンセル</button>

    <div id="progress-container" style="display: none;">
      <div>進捗: <span id="progress-text">0%</span></div>
      <progress
        id="progress-bar"
        value="0"
        max="100"
      ></progress>
    </div>

    <div id="result-container" style="display: none;">
      <h3>結果</h3>
      <p>
        発見した素数の数: <span id="prime-count"></span>
      </p>
      <p>処理時間: <span id="execution-time"></span>ms</p>
    </div>
  </body>
</html>

Worker ファイル(prime-worker.js)

素数計算を行う Worker 側のコードです。進捗報告機能も含めています。

javascript// prime-worker.js
self.addEventListener('message', function (event) {
  const { type, data } = event.data;

  switch (type) {
    case 'FIND_PRIMES':
      findPrimes(data.maxNumber);
      break;
    case 'CANCEL':
      self.close(); // Workerを終了
      break;
  }
});

function findPrimes(maxNumber) {
  const primes = [];
  const startTime = Date.now();
  let shouldCancel = false;

  // キャンセル要求の監視
  self.addEventListener('message', function (event) {
    if (event.data.type === 'CANCEL') {
      shouldCancel = true;
    }
  });

  for (
    let num = 2;
    num <= maxNumber && !shouldCancel;
    num++
  ) {
    if (isPrime(num)) {
      primes.push(num);
    }

    // 進捗報告(1000件ごと)
    if (num % 1000 === 0) {
      const progress = (num / maxNumber) * 100;
      self.postMessage({
        type: 'PROGRESS',
        data: {
          progress: progress,
          currentNumber: num,
          primesFound: primes.length,
        },
      });
    }
  }

  const endTime = Date.now();

  if (!shouldCancel) {
    // 完了通知
    self.postMessage({
      type: 'COMPLETE',
      data: {
        primes: primes,
        count: primes.length,
        executionTime: endTime - startTime,
      },
    });
  }
}

function isPrime(n) {
  if (n < 2) return false;
  if (n === 2) return true;
  if (n % 2 === 0) return false;

  for (let i = 3; i <= Math.sqrt(n); i += 2) {
    if (n % i === 0) return false;
  }
  return true;
}

メインスレッド(main.js)

UI の制御と Worker との通信を管理するコードです。

javascript// main.js
class PrimeCalculator {
  constructor() {
    this.worker = null;
    this.isCalculating = false;
    this.initializeElements();
    this.setupEventListeners();
  }

  initializeElements() {
    this.elements = {
      maxNumberInput: document.getElementById('max-number'),
      calcButton: document.getElementById('calc-button'),
      cancelButton:
        document.getElementById('cancel-button'),
      progressContainer: document.getElementById(
        'progress-container'
      ),
      progressText:
        document.getElementById('progress-text'),
      progressBar: document.getElementById('progress-bar'),
      resultContainer: document.getElementById(
        'result-container'
      ),
      primeCount: document.getElementById('prime-count'),
      executionTime: document.getElementById(
        'execution-time'
      ),
    };
  }

  setupEventListeners() {
    this.elements.calcButton.addEventListener(
      'click',
      () => {
        this.startCalculation();
      }
    );

    this.elements.cancelButton.addEventListener(
      'click',
      () => {
        this.cancelCalculation();
      }
    );
  }

  startCalculation() {
    const maxNumber = parseInt(
      this.elements.maxNumberInput.value
    );

    if (maxNumber < 1000) {
      alert('1000以上の数値を入力してください');
      return;
    }

    this.isCalculating = true;
    this.updateUI('calculating');

    // 新しいWorkerを作成
    this.worker = new Worker('./prime-worker.js');
    this.setupWorkerListeners();

    // 計算開始
    this.worker.postMessage({
      type: 'FIND_PRIMES',
      data: { maxNumber },
    });
  }

  setupWorkerListeners() {
    this.worker.addEventListener('message', (event) => {
      const { type, data } = event.data;

      switch (type) {
        case 'PROGRESS':
          this.updateProgress(data);
          break;
        case 'COMPLETE':
          this.handleComplete(data);
          break;
      }
    });

    this.worker.addEventListener('error', (error) => {
      console.error('Worker error:', error);
      this.handleError(error);
    });
  }

  updateProgress(data) {
    this.elements.progressText.textContent = `${data.progress.toFixed(
      1
    )}% (素数: ${data.primesFound}個)`;
    this.elements.progressBar.value = data.progress;
  }

  handleComplete(data) {
    this.elements.primeCount.textContent =
      data.count.toLocaleString();
    this.elements.executionTime.textContent =
      data.executionTime.toLocaleString();

    this.isCalculating = false;
    this.updateUI('complete');
    this.cleanupWorker();
  }

  cancelCalculation() {
    if (this.worker && this.isCalculating) {
      this.worker.postMessage({ type: 'CANCEL' });
      this.cleanupWorker();
      this.isCalculating = false;
      this.updateUI('idle');
    }
  }

  updateUI(state) {
    switch (state) {
      case 'calculating':
        this.elements.calcButton.disabled = true;
        this.elements.cancelButton.disabled = false;
        this.elements.progressContainer.style.display =
          'block';
        this.elements.resultContainer.style.display =
          'none';
        break;
      case 'complete':
        this.elements.calcButton.disabled = false;
        this.elements.cancelButton.disabled = true;
        this.elements.progressContainer.style.display =
          'none';
        this.elements.resultContainer.style.display =
          'block';
        break;
      case 'idle':
        this.elements.calcButton.disabled = false;
        this.elements.cancelButton.disabled = true;
        this.elements.progressContainer.style.display =
          'none';
        break;
    }
  }

  cleanupWorker() {
    if (this.worker) {
      this.worker.terminate();
      this.worker = null;
    }
  }

  handleError(error) {
    alert(`エラーが発生しました: ${error.message}`);
    this.isCalculating = false;
    this.updateUI('idle');
    this.cleanupWorker();
  }
}

// アプリケーションの初期化
document.addEventListener('DOMContentLoaded', () => {
  new PrimeCalculator();
});

ファイル処理の実装

次に、CSV ファイルの解析処理を Web Workers で実装してみましょう。大量のデータを扱う場合に威力を発揮します。

CSV パーサー Worker(csv-worker.js)

javascript// csv-worker.js
self.addEventListener('message', function (event) {
  const { type, data } = event.data;

  switch (type) {
    case 'PARSE_CSV':
      parseCSV(data.csvText, data.options);
      break;
  }
});

function parseCSV(csvText, options = {}) {
  const {
    delimiter = ',',
    hasHeader = true,
    chunkSize = 1000,
  } = options;

  const lines = csvText.split('\n');
  const totalLines = lines.length;
  let processedLines = 0;

  const result = {
    headers: [],
    data: [],
    summary: {
      totalRows: 0,
      totalColumns: 0,
      errors: [],
    },
  };

  try {
    // ヘッダー処理
    if (hasHeader && lines.length > 0) {
      result.headers = parseCSVLine(lines[0], delimiter);
      processedLines = 1;
    }

    // データ行の処理
    for (let i = processedLines; i < lines.length; i++) {
      const line = lines[i].trim();
      if (line === '') continue; // 空行をスキップ

      try {
        const rowData = parseCSVLine(line, delimiter);
        result.data.push(rowData);

        // 進捗報告
        if (i % chunkSize === 0) {
          const progress = (i / totalLines) * 100;
          self.postMessage({
            type: 'PROGRESS',
            data: {
              progress: progress,
              processedRows: result.data.length,
              currentLine: i,
            },
          });
        }
      } catch (error) {
        result.summary.errors.push({
          line: i + 1,
          error: error.message,
          content: line,
        });
      }
    }

    // サマリー情報の設定
    result.summary.totalRows = result.data.length;
    result.summary.totalColumns =
      result.headers.length ||
      (result.data.length > 0 ? result.data[0].length : 0);

    // 完了通知
    self.postMessage({
      type: 'PARSE_COMPLETE',
      data: result,
    });
  } catch (error) {
    self.postMessage({
      type: 'PARSE_ERROR',
      data: { error: error.message },
    });
  }
}

function parseCSVLine(line, delimiter) {
  const result = [];
  let current = '';
  let inQuotes = false;

  for (let i = 0; i < line.length; i++) {
    const char = line[i];
    const nextChar = line[i + 1];

    if (char === '"') {
      if (inQuotes && nextChar === '"') {
        // エスケープされた引用符
        current += '"';
        i++; // 次の文字をスキップ
      } else {
        // 引用符の開始または終了
        inQuotes = !inQuotes;
      }
    } else if (char === delimiter && !inQuotes) {
      // 区切り文字(引用符外)
      result.push(current.trim());
      current = '';
    } else {
      current += char;
    }
  }

  // 最後のフィールドを追加
  result.push(current.trim());
  return result;
}

CSV パーサーのメインロジック(csv-parser.js)

javascript// csv-parser.js
class CSVParser {
  constructor(containerId) {
    this.container = document.getElementById(containerId);
    this.worker = null;
    this.setupUI();
  }

  setupUI() {
    this.container.innerHTML = `
      <div class="csv-parser">
        <h3>CSV Parser Demo</h3>
        <div class="upload-area">
          <input type="file" id="csv-file" accept=".csv,.txt">
          <label for="csv-file">CSVファイルを選択</label>
        </div>
        
        <div class="options">
          <label>
            <input type="checkbox" id="has-header" checked>
            ヘッダー行あり
          </label>
          <label>
            区切り文字:
            <select id="delimiter">
              <option value=",">カンマ (,)</option>
              <option value="\t">タブ</option>
              <option value=";">セミコロン (;)</option>
            </select>
          </label>
        </div>
        
        <button id="parse-button" disabled>解析開始</button>
        
        <div id="progress-area" style="display: none;">
          <div>進捗: <span id="progress-text">0%</span></div>
          <progress id="progress-bar" value="0" max="100"></progress>
        </div>
        
        <div id="result-area" style="display: none;">
          <h4>解析結果</h4>
          <div id="summary"></div>
          <div id="data-preview"></div>
          <div id="errors" style="display: none;"></div>
        </div>
      </div>
    `;

    this.setupEventListeners();
  }

  setupEventListeners() {
    const fileInput =
      this.container.querySelector('#csv-file');
    const parseButton =
      this.container.querySelector('#parse-button');

    fileInput.addEventListener('change', (event) => {
      parseButton.disabled = !event.target.files[0];
    });

    parseButton.addEventListener('click', () => {
      this.parseFile();
    });
  }

  async parseFile() {
    const fileInput =
      this.container.querySelector('#csv-file');
    const hasHeaderCheckbox =
      this.container.querySelector('#has-header');
    const delimiterSelect =
      this.container.querySelector('#delimiter');

    const file = fileInput.files[0];
    if (!file) return;

    try {
      const csvText = await this.readFile(file);

      this.showProgress();

      // Workerを作成して処理開始
      this.worker = new Worker('./csv-worker.js');
      this.setupWorkerListeners();

      this.worker.postMessage({
        type: 'PARSE_CSV',
        data: {
          csvText: csvText,
          options: {
            hasHeader: hasHeaderCheckbox.checked,
            delimiter: delimiterSelect.value,
          },
        },
      });
    } catch (error) {
      alert(`ファイル読み込みエラー: ${error.message}`);
    }
  }

  readFile(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.onerror = () => reject(reader.error);
      reader.readAsText(file);
    });
  }

  setupWorkerListeners() {
    this.worker.addEventListener('message', (event) => {
      const { type, data } = event.data;

      switch (type) {
        case 'PROGRESS':
          this.updateProgress(data);
          break;
        case 'PARSE_COMPLETE':
          this.handleParseComplete(data);
          break;
        case 'PARSE_ERROR':
          this.handleParseError(data);
          break;
      }
    });
  }

  updateProgress(data) {
    const progressText = this.container.querySelector(
      '#progress-text'
    );
    const progressBar =
      this.container.querySelector('#progress-bar');

    progressText.textContent = `${data.progress.toFixed(
      1
    )}% (${data.processedRows}行処理済み)`;
    progressBar.value = data.progress;
  }

  handleParseComplete(data) {
    this.hideProgress();
    this.showResults(data);
    this.cleanupWorker();
  }

  handleParseError(data) {
    alert(`解析エラー: ${data.error}`);
    this.hideProgress();
    this.cleanupWorker();
  }

  showProgress() {
    this.container.querySelector(
      '#progress-area'
    ).style.display = 'block';
    this.container.querySelector(
      '#result-area'
    ).style.display = 'none';
  }

  hideProgress() {
    this.container.querySelector(
      '#progress-area'
    ).style.display = 'none';
  }

  showResults(data) {
    const resultArea =
      this.container.querySelector('#result-area');
    const summaryDiv =
      this.container.querySelector('#summary');
    const previewDiv =
      this.container.querySelector('#data-preview');
    const errorsDiv =
      this.container.querySelector('#errors');

    // サマリー表示
    summaryDiv.innerHTML = `
      <p><strong>総行数:</strong> ${data.summary.totalRows.toLocaleString()}</p>
      <p><strong>カラム数:</strong> ${
        data.summary.totalColumns
      }</p>
      <p><strong>エラー数:</strong> ${
        data.summary.errors.length
      }</p>
    `;

    // データプレビュー表示(最初の10行)
    if (data.data.length > 0) {
      const previewData = data.data.slice(0, 10);
      previewDiv.innerHTML = this.createTable(
        data.headers,
        previewData
      );
    }

    // エラー表示
    if (data.summary.errors.length > 0) {
      errorsDiv.style.display = 'block';
      errorsDiv.innerHTML = `
        <h5>エラー詳細</h5>
        <ul>
          ${data.summary.errors
            .map(
              (error) =>
                `<li>行 ${error.line}: ${error.error}</li>`
            )
            .join('')}
        </ul>
      `;
    }

    resultArea.style.display = 'block';
  }

  createTable(headers, data) {
    let html = '<table border="1"><thead><tr>';

    if (headers.length > 0) {
      headers.forEach((header) => {
        html += `<th>${this.escapeHtml(header)}</th>`;
      });
    } else if (data.length > 0) {
      data[0].forEach((_, index) => {
        html += `<th>Column ${index + 1}</th>`;
      });
    }

    html += '</tr></thead><tbody>';

    data.forEach((row) => {
      html += '<tr>';
      row.forEach((cell) => {
        html += `<td>${this.escapeHtml(cell)}</td>`;
      });
      html += '</tr>';
    });

    html += '</tbody></table>';
    return html;
  }

  escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
  }

  cleanupWorker() {
    if (this.worker) {
      this.worker.terminate();
      this.worker = null;
    }
  }
}

// 使用例
document.addEventListener('DOMContentLoaded', () => {
  new CSVParser('csv-container');
});

データ変換処理

最後に、JSON 形式のデータを効率的に変換する例を見てみましょう。

データ変換 Worker(transform-worker.js)

javascript// transform-worker.js
self.addEventListener('message', function (event) {
  const { type, data } = event.data;

  switch (type) {
    case 'TRANSFORM_DATA':
      transformData(
        data.items,
        data.transformType,
        data.options
      );
      break;
  }
});

function transformData(items, transformType, options = {}) {
  const batchSize = options.batchSize || 1000;
  const result = [];

  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    let transformedItem;

    try {
      switch (transformType) {
        case 'NORMALIZE':
          transformedItem = normalizeItem(item, options);
          break;
        case 'AGGREGATE':
          transformedItem = aggregateItem(item, options);
          break;
        case 'FILTER':
          if (filterItem(item, options)) {
            transformedItem = item;
          } else {
            continue; // フィルターで除外
          }
          break;
        default:
          transformedItem = item;
      }

      result.push(transformedItem);

      // 進捗報告
      if (i % batchSize === 0) {
        const progress = (i / items.length) * 100;
        self.postMessage({
          type: 'TRANSFORM_PROGRESS',
          data: {
            progress: progress,
            processed: i,
            total: items.length,
            resultCount: result.length,
          },
        });
      }
    } catch (error) {
      self.postMessage({
        type: 'TRANSFORM_ERROR',
        data: {
          index: i,
          item: item,
          error: error.message,
        },
      });
    }
  }

  // 完了通知
  self.postMessage({
    type: 'TRANSFORM_COMPLETE',
    data: {
      result: result,
      originalCount: items.length,
      transformedCount: result.length,
    },
  });
}

function normalizeItem(item, options) {
  const normalized = { ...item };

  // 数値の正規化
  if (
    options.normalizeNumbers &&
    typeof item.value === 'number'
  ) {
    normalized.value =
      (item.value - options.min) /
      (options.max - options.min);
  }

  // 文字列の正規化
  if (
    options.normalizeStrings &&
    typeof item.name === 'string'
  ) {
    normalized.name = item.name.toLowerCase().trim();
  }

  // 日付の正規化
  if (options.normalizeDates && item.date) {
    normalized.date = new Date(item.date).toISOString();
  }

  return normalized;
}

function aggregateItem(item, options) {
  // グループ化のキーを生成
  const groupKey = options.groupBy
    .map((field) => item[field])
    .join('|');

  return {
    groupKey: groupKey,
    ...item,
    aggregatedAt: new Date().toISOString(),
  };
}

function filterItem(item, options) {
  for (const condition of options.conditions) {
    const { field, operator, value } = condition;
    const itemValue = item[field];

    switch (operator) {
      case 'equals':
        if (itemValue !== value) return false;
        break;
      case 'greater':
        if (itemValue <= value) return false;
        break;
      case 'less':
        if (itemValue >= value) return false;
        break;
      case 'contains':
        if (!String(itemValue).includes(value))
          return false;
        break;
      default:
        return false;
    }
  }

  return true;
}

エラーハンドリング

Web Workers を使用する際の適切なエラーハンドリングパターンを実装しましょう。

javascript// error-handling.js
class RobustWorkerManager {
  constructor(workerPath) {
    this.workerPath = workerPath;
    this.worker = null;
    this.retryCount = 0;
    this.maxRetries = 3;
    this.timeout = 30000; // 30秒のタイムアウト
    this.timeoutId = null;
  }

  async executeTask(taskData) {
    return new Promise((resolve, reject) => {
      this.createWorker();

      // タイムアウト設定
      this.timeoutId = setTimeout(() => {
        this.handleTimeout();
        reject(new Error('Worker task timeout'));
      }, this.timeout);

      // Worker完了時の処理
      const handleComplete = (result) => {
        this.clearTimeout();
        this.cleanup();
        resolve(result);
      };

      // Workerエラー時の処理
      const handleError = (error) => {
        this.clearTimeout();
        this.cleanup();

        if (this.retryCount < this.maxRetries) {
          this.retryCount++;
          console.warn(
            `Worker error, retrying (${this.retryCount}/${this.maxRetries}):`,
            error
          );
          // 少し待ってからリトライ
          setTimeout(() => {
            this.executeTask(taskData)
              .then(resolve)
              .catch(reject);
          }, 1000 * this.retryCount);
        } else {
          reject(error);
        }
      };

      // イベントリスナー設定
      this.worker.addEventListener('message', (event) => {
        const { type, data } = event.data;

        if (type === 'TASK_COMPLETE') {
          handleComplete(data);
        } else if (type === 'TASK_ERROR') {
          handleError(new Error(data.message));
        }
      });

      this.worker.addEventListener('error', (error) => {
        handleError(error);
      });

      // タスク開始
      this.worker.postMessage({
        type: 'EXECUTE_TASK',
        data: taskData,
      });
    });
  }

  createWorker() {
    if (this.worker) {
      this.worker.terminate();
    }

    try {
      this.worker = new Worker(this.workerPath);
    } catch (error) {
      throw new Error(
        `Failed to create worker: ${error.message}`
      );
    }
  }

  handleTimeout() {
    console.error('Worker task timeout');
    this.cleanup();
  }

  clearTimeout() {
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
      this.timeoutId = null;
    }
  }

  cleanup() {
    if (this.worker) {
      this.worker.terminate();
      this.worker = null;
    }
    this.clearTimeout();
  }

  // リトライカウントをリセット
  resetRetryCount() {
    this.retryCount = 0;
  }
}

// 使用例
async function demonstrateErrorHandling() {
  const workerManager = new RobustWorkerManager(
    './task-worker.js'
  );

  try {
    const result = await workerManager.executeTask({
      operation: 'HEAVY_CALCULATION',
      parameters: { iterations: 1000000 },
    });

    console.log('Task completed successfully:', result);
    workerManager.resetRetryCount();
  } catch (error) {
    console.error('Task failed after retries:', error);
    // フォールバック処理
    performFallbackOperation();
  }
}

function performFallbackOperation() {
  console.log(
    'Performing fallback operation on main thread...'
  );
  // メインスレッドでの代替処理
}

これらの具体例を通して、Web Workers の実践的な活用方法を学んでいただけたでしょうか。次のセクションでは、これまでの内容をまとめていきます。

まとめ

Web Workers 活用のメリット

本記事を通じて、Web Workers の導入により以下のような大きなメリットが得られることをご理解いただけたと思います。

メリット具体的な効果ユーザー体験への影響
UI 応答性の向上重い処理中も UI が操作可能ストレスフリーな操作感
処理性能の最適化並列処理による高速化待ち時間の短縮
ユーザビリティ改善進捗表示とキャンセル機能安心感と操作性向上
スケーラビリティ大量データ処理が現実的機能の拡張可能性

使用上の注意点

Web Workers を効果的に活用するために、以下の点にご注意ください。

パフォーマンスに関する注意

  • Worker 作成コスト:頻繁な作成は避け、可能な限り再利用しましょう
  • メッセージ通信オーバーヘッド:大量の小さなメッセージより、少数の大きなメッセージが効率的です
  • メモリ使用量:Worker は独立したコンテキストを持つため、メモリ使用量が増加します

設計上の考慮点

javascript// 良い例:効率的なWorker利用
class EfficientWorkerPool {
  constructor(workerPath, poolSize = 4) {
    this.workers = [];
    this.taskQueue = [];
    this.activeWorkers = new Set();

    // Workerプールを事前に作成
    for (let i = 0; i < poolSize; i++) {
      this.workers.push(new Worker(workerPath));
    }
  }

  async executeTask(taskData) {
    return new Promise((resolve, reject) => {
      const availableWorker = this.getAvailableWorker();

      if (availableWorker) {
        this.runTask(
          availableWorker,
          taskData,
          resolve,
          reject
        );
      } else {
        // キューに追加
        this.taskQueue.push({ taskData, resolve, reject });
      }
    });
  }

  getAvailableWorker() {
    return this.workers.find(
      (worker) => !this.activeWorkers.has(worker)
    );
  }

  runTask(worker, taskData, resolve, reject) {
    this.activeWorkers.add(worker);

    const cleanup = () => {
      this.activeWorkers.delete(worker);
      this.processQueue(); // キューの次のタスクを実行
    };

    worker.addEventListener(
      'message',
      (event) => {
        if (event.data.type === 'TASK_COMPLETE') {
          resolve(event.data.result);
          cleanup();
        }
      },
      { once: true }
    );

    worker.addEventListener(
      'error',
      (error) => {
        reject(error);
        cleanup();
      },
      { once: true }
    );

    worker.postMessage(taskData);
  }

  processQueue() {
    if (this.taskQueue.length > 0) {
      const availableWorker = this.getAvailableWorker();
      if (availableWorker) {
        const { taskData, resolve, reject } =
          this.taskQueue.shift();
        this.runTask(
          availableWorker,
          taskData,
          resolve,
          reject
        );
      }
    }
  }
}

ブラウザ対応とフォールバック

javascript// ブラウザサポートチェックとフォールバック
function createWorkerWithFallback(
  workerPath,
  fallbackFunction
) {
  if (typeof Worker !== 'undefined') {
    try {
      return new Worker(workerPath);
    } catch (error) {
      console.warn(
        'Worker creation failed, using fallback:',
        error
      );
      return createFallbackWorker(fallbackFunction);
    }
  } else {
    console.warn(
      'Web Workers not supported, using fallback'
    );
    return createFallbackWorker(fallbackFunction);
  }
}

function createFallbackWorker(fallbackFunction) {
  return {
    postMessage: (data) => {
      // メインスレッドで非同期実行
      setTimeout(() => {
        try {
          const result = fallbackFunction(data);
          this.onmessage({
            data: { type: 'COMPLETE', result },
          });
        } catch (error) {
          this.onerror(error);
        }
      }, 0);
    },
    terminate: () => {
      // フォールバック用のクリーンアップ
    },
    onmessage: null,
    onerror: null,
  };
}

次のステップ

Web Workers の基本を習得した後は、以下のような発展的なトピックに取り組まれることをお勧めします。

1. SharedArrayBuffer の活用

より高度なデータ共有が必要な場合は、SharedArrayBuffer を検討してみてください。

2. OffscreenCanvas との組み合わせ

グラフィック処理を Worker で行う場合は、OffscreenCanvas が強力な選択肢となります。

3. WebAssembly (WASM) との統合

計算集約的な処理では、WebAssembly と Web Workers を組み合わせることで、さらなる性能向上が期待できます。

4. Service Workers との使い分け

Service Workers と Web Workers の使い分けを理解し、適切な場面で活用しましょう。

Web Workers は、モダン Web アプリケーションにとって欠かせない技術です。ユーザー体験を大幅に改善できる強力なツールですので、ぜひ積極的に活用してみてください。実際のプロジェクトで使用する際は、小さな機能から始めて、徐々に適用範囲を広げていくことをお勧めいたします。

関連リンク