Featured image of post JavaScript Promiseの高度なパターン:非同期制御フローを極める

JavaScript Promiseの高度なパターン:非同期制御フローを極める

Promise.allやallSettledの使い分け、同時実行制限、エラーハンドリング戦略など、本番アプリケーションで役立つ高度なPromiseパターンを徹底解説。

PromiseはモダンなJavaScriptの基礎ですが、非同期制御フローの習得には基本的な.then()チェーンを超えた理解が必要です。本記事では、同時実行制御からエラーハンドリング、キャンセルまで、本番アプリケーションで必要な高度なPromiseパターンを解説します。

Promiseの全体像と基本の復習

JavaScriptは非同期処理を合成するための4つの静的メソッドを提供しており、それぞれ異なるシナリオに適しています。

メソッド解決条件拒否条件ユースケース
Promise.allすべてのPromiseが解決いずれかが拒否並列API呼び出し、全件必須
Promise.allSettledすべてが完了なし混合結果の記録
Promise.race最初のPromiseが完了最初の拒否タイムアウト競合
Promise.any最初の解決すべて拒否最初の成功レスポンス
const results = await Promise.allSettled([
  fetch("/api/users").then(r => r.json()),
  fetch("/api/posts").then(r => r.json()),
  fetch("/api/comments").then(r => r.json()),
]);
const successful = results.filter(r => r.status === "fulfilled").map(r => r.value);

Promise.allSettledは、一部の操作が失敗しても部分的な結果を処理する必要がある場合(個別のレコード失敗がバッチ全体を中断させるべきではないデータ同期など)に特に有用です。


並行実行制御

多数のPromiseを同時に実行すると、APIやデータベース、ブラウザに過剰な負荷がかかる可能性があります。並行実行制御はタスクをキューイングし、指定された数のみが同時に実行されるようにします。

async function pMap(items, fn, concurrency = 5) {
  const results = [];
  const executing = new Set();
  for (const item of items) {
    const p = fn(item).then(result => {
      executing.delete(p);
      return result;
    });
    executing.add(p);
    results.push(p);
    if (executing.size >= concurrency) {
      await Promise.race(executing);
    }
  }
  return Promise.all(results);
}

// 100個のURLを最大5並行で処理
const data = await pMap(urls, url => fetch(url).then(r => r.json()), 5);

このパターンはバックプレッシャーをサポートします。同時実行数制限に達すると、少なくとも1つのタスクが完了するまでループが一時停止します。より高度なニーズには、p-limitp-queuep-throttleなどのライブラリが優先度キューやレート制限などの追加機能を提供します。


Async/Awaitのベストプラクティス

ループ内での逐次awaitは一般的なパフォーマンスの落とし穴です。依存関係がない場合でも、各反復は前のPromiseの解決を待ってから次の処理を開始します。

// 低速: 逐次実行
for (const id of ids) {
  const data = await fetch(`/api/items/${id}`).then(r => r.json());
  process(data);
}

// 高速: 並列実行
const results = await Promise.all(
  ids.map(id => fetch(`/api/items/${id}`).then(r => r.json()))
);
results.forEach(process);

逐次awaitは各ステップが前の結果に依存する場合のみ使用します。独立した操作の場合は、常にPromise.allを優先してスループットを最大化します。async関数のエラーハンドリングは、型付きエラークラスを使用した構造化されたtry-catchが効果的です。

class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

async function fetchData(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new ApiError(response.status, await response.text());
  }
  return response.json();
}

エラーハンドリングと回復力

本番アプリケーションでは、Promiseの拒否を適切に処理する必要があります。グローバルな拒否追跡により、未処理のエラーを捕捉できます。

// ブラウザ
window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled rejection:", event.reason);
  event.preventDefault();
});

// Node.js
process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled rejection at:", promise, "reason:", reason);
});

不安定な外部サービスに対しては、サーキットブレーカーパターンが障害が発生しているエンドポイントへの繰り返し呼び出しを防止します。

class CircuitBreaker {
  constructor(fn, options = {}) {
    this.fn = fn;
    this.failureCount = 0;
    this.threshold = options.threshold || 5;
    this.timeout = options.timeout || 30000;
    this.state = "CLOSED";
  }
  async call(...args) {
    if (this.state === "OPEN") {
      throw new Error("Circuit breaker is open");
    }
    try {
      const result = await this.fn(...args);
      this.failureCount = 0;
      return result;
    } catch (error) {
      this.failureCount++;
      if (this.failureCount >= this.threshold) {
        this.state = "OPEN";
        setTimeout(() => { this.state = "HALF_OPEN"; }, this.timeout);
      }
      throw error;
    }
  }
}

AbortControllerによるキャンセル

Promiseは本質的にはキャンセルできませんが、AbortControllerはfetchリクエストを中断し、非同期フロー全体にキャンセルを伝播する標準メカニズムを提供します。

function createTimeoutSignal(ms) {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), ms);
  return controller.signal;
}

async function fetchWithTimeout(url, ms = 5000) {
  const signal = createTimeoutSignal(ms);
  try {
    const response = await fetch(url, { signal });
    return response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      throw new Error(`Request timed out after ${ms}ms`);
    }
    throw error;
  }
}

このパターンにより、任意の非同期処理のタイムアウト、呼び出し元間で進行中のリクエストを共有する重複排除、コンポーネントのアンマウント時の非同期フローのクリーンアップが可能になります。


結論

高度なPromiseパターンは、非同期JavaScriptをエラーが発生しやすいコードから堅牢で保守性の高いコードに変革します。各シナリオに適したPromiseコンビネーターを選択し、ダウンストリームサービスを保護するために同時実行数を制限し、可読性のためにasync関数を構造化し、回復力のためにサーキットブレーカーを実装し、キャンセル可能なフローにAbortControllerを使用します。これらのパターンは、モダンなJavaScriptアプリケーションにおけるプロダクション品質の非同期コードの基盤です。