2022年5月11日

Promise API

Promise クラスには 6 つの静的メソッドがあります。ここでそれらのユースケースについて簡単に説明します。

Promise.all

並列に複数の promise を実行し、すべてが準備できるまで待ちたいとしましょう。

例えば、平行で複数の URL をダウンロードし、すべてが完了したらコンテンツを処理する場合です。

これが Promise.all の目的です。

構文は次の通りです:

let promise = Promise.all(iterable);

Promise.all は iterable(反復可能, 通常は promise の配列)を取り、新しい promise を返します。

新しい promise は、配列の各 promise がすべて解決され、それらの結果を配列に持つと解決(resolve)されます。

例えば、下の Promise.all は 3 秒後に解決され、その後結果は配列 [1, 2, 3] です。:

Promise.all([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 3000)), // 1
  new Promise((resolve, reject) => setTimeout(() => resolve(2), 2000)), // 2
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 1000))  // 3
]).then(alert); // 1,2,3 promise が準備できた時: 各 promise は配列の中身に寄与します

結果の配列要素の順番は元の promise の順番と同じであることに注意してください。たとえ1つ目の promise が解決まで最も時間がかかったとしても、結果の配列の中では先頭にあります。

一般的なやり方は、処理データの配列を promise の配列にマップし、それを Promise.all にラップすることです。

例えば、URL の配列がある場合、次のようにしてすべてをフェッチできます:

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/remy',
  'https://api.github.com/users/jeresig'
];

// 各 url を promise の fetch(github url) へマップする
let requests = urls.map(url => fetch(url));

// Promise.all はすべてのジョブが解決されるまで待ちます
Promise.all(requests)
  .then(responses => responses.forEach(
    response => alert(`${response.url}: ${response.status}`)
  ));

名前からGithubユーザのユーザ情報を取得するより大きな例です(id で商品の配列をフェッチできますが、ロジックは同じです):

let names = ['iliakan', 'remy', 'jeresig'];

let requests = names.map(name => fetch(`https://api.github.com/users/${name}`));

Promise.all(requests)
  .then(responses => {
    // すべてのレスポンスが用意できたら HTTP ステータスコードが見られます
    for(let response of responses) {
      alert(`${response.url}: ${response.status}`); // 各 url で 200 が表示されます
    }

    return responses;
  })
  // それぞれの中身を読むために、レスポンスの配列を response.json() の配列にマッピングします
  .then(responses => Promise.all(responses.map(r => r.json())))
  // すべての JSON応答が解析され、"user" はそれらの配列です。
  .then(users => users.forEach(user => alert(user.name)));

いずれかの promise が reject された場合、Promise.all により返却された promise は即座にエラーと一緒に reject します。

例:

Promise.all([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).catch(alert); // Error: Whoops!

ここでは、2つ目の promise が2秒後に reject されます。それは即座に Promise.all の reject に繋がるため、catch が実行されます。: reject されたエラーは Promise.all 全体の結果になります。

エラー時の場合, 他の promise は無視されます

ある promise が reject がされると、Promise.all は即座に reject し、配列の他の promise を完全に忘れます。これらの結果は無視されます。

例えば、上の例のように複数の fetch 呼び出しがあり、1つが失敗した場合、他の promise は依然として実行中ですが、Promise.all はこれ以上ウォッチしません。おそらく正常に解決されますが、結果は無視されます。

promise には “キャンセル” の考え方はないので、Promise.all はキャンセルしません。別の章では、これに役立つ AbortController について説明しています。がこれは Promise API の一部ではありません。

Promise.all(iterable)iterable の中で非 promise の項目を許可します

通常、Promise.all(iterable) は promise の iterable (ほとんどの場合は配列)を受け付けます。しかし、もしそれらのオブジェクトが promise ではない場合、Promise.resolve でラップします。

例えば、ここでは結果は [1, 2, 3] になります:

Promise.all([
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(1), 1000)
  }),
  2, // Promise.resolve(2) と扱われる
  3  // Promise.resolve(3) と扱われる
]).then(alert); // 1, 2, 3

したがって、必要に応じて準備済みの値を Promise.all に渡せます。

Promise.allSettled

A recent addition
This is a recent addition to the language. Old browsers may need polyfills.

Promise.all はいずれかの promise が reject されると、全体として reject されます。これは “白か黒か” のケースにはよいです。続行するために すべての 成功した結果が必要な場合です:

Promise.all([
  fetch('/template.html'),
  fetch('/style.css'),
  fetch('/data.json')
]).then(render); // render メソッドはすべての fetch の結果が必要

Promise.allSettled は結果に関わらずすべての promise が解決するまで待ちます。結果の配列は以下を持ちます:

  • 成功したレスポンスの場合: {status:"fulfilled", value:result}
  • エラーの場合: {status:"rejected", reason:error}

例えば、複数のユーザに対する情報をフェッチしたいとします。たとえ1リクエストが失敗しても、他の結果はほしいです。

Promise.allSettled を使いましょう:

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/remy',
  'https://no-such-url'
];

Promise.allSettled(urls.map(url => fetch(url)))
  .then(results => { // (*)
    results.forEach((result, num) => {
      if (result.status == "fulfilled") {
        alert(`${urls[num]}: ${result.value.status}`);
      }
      if (result.status == "rejected") {
        alert(`${urls[num]}: ${result.reason}`);
      }
    });
  });

上の行 (*)results は以下になります:

[
  {status: 'fulfilled', value: ...レスポンス...},
  {status: 'fulfilled', value: ...レスポンス...},
  {status: 'rejected', reason: ...エラーオブジェクト...}
]

そのため、各 promise に対してステータスと 値 or エラー を取得します。

Polyfill

ブラウザでは、Promise.allSettled をサポートしていません。が polyfill するのは簡単です:

if (!Promise.allSettled) {
  const rejectHandler = reason => ({ status: 'rejected', reason });

  const resolveHandler = value => ({ status: 'fulfilled', value });

  Promise.allSettled = function (promises) {
    const convertedPromises = promises.map(p => Promise.resolve(p).then(resolveHandler, rejectHandler));
    return Promise.all(convertedPromises);
  };
}

このコードで、promises.map は入力値を取り、p => Promise.resolve(p) でそれらを promise に変換(非 promise が渡された場合に備えて)し、.then ハンドラに追加します。

ハンドラは成功した結果 value{status:'fulfilled', value} に、エラー reason{status:'rejected', reason} に変換します。これは Promise.allSettled のフォーマットです。

これで、指定された すべて の promise の結果を得る(たとえいくつかが reject されても) Promise.allSettled が利用できます。

Promise.race

これは Promise.all と同様ですが、すべてが完了するのを待つのではなく、最初の結果(またはエラー)のみを待ちます。

構文です:

let promise = Promise.race(iterable);

例えば、ここでは結果は 1 になります:

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).then(alert); // 1

なので、最初の結果/エラーが Promise.race 全体の結果になります。最初の確定した promise が “レースに勝った” 後、それ以外の結果/エラーは無視されます。

Promise.any

Promise.race と同様ですが、最初の履行した(fulfilled) promise のみを待ち、その結果を得ます。指定された promise がすべて reject された場合、返却された promise は AggregateError で reject されます(errors プロパティにすべての promise エラーを格納する特別なエラーオブジェクトです)。

構文は次の通りです:

let promise = Promise.any(iterable);

例えば、ここでは結果は 1 になります:

Promise.any([
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 1000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).then(alert); // 1

最初の promise が一番はやいですが、reject されたため、2つ目の promise が結果になっています。最初の履行した(fulfilled)promise が “レースに勝ち”、他の結果はすべて無視されます。

これは promise がすべて失敗した例です:

Promise.any([
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("Ouch!")), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("Error!")), 2000))
]).catch(error => {
  console.log(error.constructor.name); // AggregateError
  console.log(error.errors[0]); // Error: Ouch!
  console.log(error.errors[1]); // Error: Error!
});

ご覧の通り、失敗した promise のエラーオブジェクトは AggregateError オブジェクトの errors プロパティで利用可能です。

Promise.resolve/reject

最近のコードでは、Promise.resolvePromise.reject メソッドはめったに必要とされません。async/await 構文(少し後で説明します) によって、これらがやや時代遅れになるためです。

ここでは完全性のためと、何らかの理由で async/await が使用できない場合のために説明します。

Promise.resolve

Promise.resolve(value) は結果 value をもつ解決された promise を作成します。

以下と同様です:

let promise = new Promise(resolve => resolve(value));

このメソッドは、関数が promise を返すことが期待される場合の互換性のために使用されます。

例えば、以下の loadCached 関数は、URL をフェッチし、コンテンツを記憶(キャッシュ)します。同じURLに対する将来の呼び出し時には、キャッシュから即座にコンテンツが返されますが、promise にするために Promise.resolve を使用します。これで戻り値は常に promise になります:

let cache = new Map();

function loadCached(url) {
  if (cache.has(url)) {
    return Promise.resolve(cache.get(url)); // (*)
  }

  return fetch(url)
    .then(response => response.text())
    .then(text => {
      cache.set(url,text);
      return text;
    });
}

関数は promise を返すことが保証されているため、loadCached(url).then(…) と書くことができます。loadCached の後に常に .then が使用でき、これが行 (*)Promise.resolve の目的です。

Promise.reject

Promise.reject(error)error を持つ reject された promise を作成します。

以下と同じです:

let promise = new Promise((resolve, reject) => reject(error));

実際、このメソッドはほとんど使われません。

サマリ

Promise クラスには 6 つの静的なメソッドがあります。:

  1. Promise.all(promises) – すべての promise が解決するのを待って、結果の配列を返します。与えられた promise のいずれかが拒否されると、 Promise.all のエラーとなり、他のすべての結果は無視されます。
  2. Promise.allSettled(promises) (最近追加されたメソッド) – すべての promise が完了するのを待って、以下のオブジェクト配列として結果を返します:
    • status: "fulfilled" or "rejected"
    • value (fulfilled の場合) or reason (rejected の場合).
  3. Promise.race(promises) – 最初の promise の解決を待ち、その結果/エラーを返します。
  4. Promise.any(promises) (最近追加されたメソッド) – 最初に履行された(fulfilled) promise を待ち、その結果を返します。指定したすべての promise が reject された場合、AggregateErrorPromise.any のエラーになります。
  5. Promise.resolve(value) – 与えられた値で promise を解決(resolve)します
  6. Promise.reject(error) – 与えられたエラーで promise を拒否(reject)します

これらのうち、Promise.all が実践では最も一般的です。

タスク

並列に複数の URL を取得したいです。

これはそのためのコードです:

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/remy',
  'https://api.github.com/users/jeresig'
];

Promise.all(urls.map(url => fetch(url)))
  // 各レスポンスに対し、そのステータスを表示
  .then(responses => { // (*)
    for(let response of responses) {
      alert(`${response.url}: ${response.status}`);
    }
  ));

問題は、任意のリクエストが失敗した場合、Promise.all はエラーで reject し、他のすべてのリクエストの結果を失うことです。

これは良くありません。

(*) で配列 responses がフェッチに成功したレスポンスオブジェクトと、失敗したエラーオブジェクトを含むようにコードを修正してください。

例えば、URL の1つが悪い場合、次のようになるべきです:

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/remy',
  'http://no-such-url'
];

Promise.all(...) // URL を取得するあなたのコード...
  // ...そして結果の配列のメンバとして取得エラーを渡します...
  .then(responses => {
    // 3 つの url => 3 つの配列要素
    alert(responses[0].status); // 200
    alert(responses[1].status); // 200
    alert(responses[2]); // TypeError: failed to fetch (内容は異なるかもしれません)
  });

P.S. このタスクでは、response.text()response.json() を使った完全なレスポンスをロードする必要はありません。適切な方法で取得エラーを処理してください。

タスクのためのサンドボックスを開く

解法は実際には非常に単純です。

これを見てください:

Promise.all(
  fetch('https://api.github.com/users/iliakan'),
  fetch('https://api.github.com/users/remy'),
  fetch('http://no-such-url')
)

ここには Promise.all に行く fetch(...) promise の配列があります。

Promise.all が動作する方法を変えることはできません: もしエラーを検出した場合、それを reject します。したがって、エラーが発生しないようにする必要があります。代わりに、 fetch エラーが発生した場合、それを “通常の” 結果として扱う必要があります。

これはその方法です:

Promise.all(
  fetch('https://api.github.com/users/iliakan').catch(err => err),
  fetch('https://api.github.com/users/remy').catch(err => err),
  fetch('http://no-such-url').catch(err => err)
)

つまり、.catch はすべての promise のエラーを取り、それを正常に返します。promise の動作ルールにより、.then/catch ハンドラが値(エラーオブジェクトか他のなにかなのかは関係ありません)を返した場合、実行は “正常の” フローを続けます。

したがって、.catch は、エラーを “正常の” 結果として外側の Promise.all に返します。

このコード:

Promise.all(
  urls.map(url => fetch(url))
)

は次のように書き直すことができます:

Promise.all(
  urls.map(url => fetch(url).catch(err => err))
)

サンドボックスで解答を開く

前のタスク フォールトトレラント Promise.all の解答を改良しましょう。今 fetch を呼び出すだけでなく、指定されたURLからJSONオブジェクトを読み込む必要があります。

ここにそれを行うサンプルコードがあります:

let urls = [
  'https://api.github.com/users/iliakan',
  'https://api.github.com/users/remy',
  'https://api.github.com/users/jeresig'
];

// フェッチリクエストを作成
Promise.all(urls.map(url => fetch(url)))
  // 各レスポンスを response.json() にマップする
  .then(responses => Promise.all(
    responses.map(r => r.json())
  ))
  // 各ユーザ名を表示
  .then(users => {  // (*)
    for(let user of users) {
      alert(user.name);
    }
  });

問題は、任意のリクエストが失敗した場合、Promise.all はエラーで reject し、他のすべてのリクエストの結果を失うことです。したがって、前のタスクのように上のコードはフォールトトレラントではありません。

(*) で、配列には成功したリクエストに対してはパースされた JSONを、エラーとなったものに対してはエラーを含むようにコードを修正してください。

エラーは fetch (ネットワークリクエストが失敗する場合)と response.json() (レスポンスが有効なJSONでない場合)の両方で発生する可能性があることに注意してください。どちらの場合も、エラーは結果オブジェクトのメンバになります。

サンドボックスには、これらの両方のケースがあります。

タスクのためのサンドボックスを開く

チュートリアルマップ