JavaScript の Promise: 概要

Promise を使用すると、遅延計算や非同期計算を簡素化できます。Promise は、まだ完了していないオペレーションを表します。

ウェブ デベロッパーの皆様、ウェブ開発の歴史における重要な転換期に備えましょう。

[ドラムロールが始まる]

JavaScript に Promise が登場しました。

[花火が打ち上げられ、キラキラした紙吹雪が舞い、観客が熱狂する]

この時点で、次のいずれかのカテゴリに分類されます。

  • 周囲の人々は歓声を上げていますが、あなたは何が起こっているのかわかりません。「約束」が何なのかさえわからないかもしれません。肩をすくめようとしても、きらきらした紙の重みが肩にのしかかっています。もしそうなら、心配しないでください。私がこのことを気にする理由を理解するのに何年もかかりました。最初から始めることをおすすめします。
  • あなたはガッツポーズをします。そろそろですよね。Promise を使用したことがあっても、実装によって API が少しずつ異なることに不満を感じているかもしれません。公式の JavaScript バージョンの API は何ですか?まずは用語から始めることをおすすめします。
  • このことをすでに知っていたあなたは、まるでニュースであるかのように大騒ぎしている人々を嘲笑しています。自分の優位性をしばらく味わってから、API リファレンスに直行してください。

ブラウザのサポートとポリフィル

Browser Support

  • Chrome: 32.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 8.

Source

Promise の実装が不完全なブラウザを仕様に準拠させる場合や、他のブラウザや Node.js に Promise を追加する場合は、ポリフィル(2k gzipped)をご覧ください。

何がそんなに騒がれているのでしょうか?

JavaScript はシングル スレッドです。つまり、2 つのスクリプトを同時に実行することはできず、1 つずつ順番に実行する必要があります。ブラウザでは、JavaScript はブラウザごとに異なる他の多くの処理とスレッドを共有します。しかし、通常、JavaScript は、ペイント、スタイルの更新、ユーザー操作(テキストのハイライト表示やフォーム コントロールの操作など)の処理と同じキューにあります。これらのいずれかのアクティビティが、他のアクティビティを遅らせます。

人間はマルチスレッドです。複数の指で入力したり、運転しながら会話したりできます。対処する必要がある唯一のブロッキング関数は sneezing で、くしゃみの間は現在のアクティビティをすべて一時停止する必要があります。これは、特に運転中に会話をしようとしているときに、非常に不便です。くしゃみをするようなコードは書きたくないでしょう。

この問題を回避するために、イベントとコールバックを使用したことがあるかもしれません。予定は次のとおりです。

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

これはスニージーじゃない。画像を取得し、リスナーをいくつか追加すると、JavaScript は、それらのリスナーのいずれかが呼び出されるまで実行を停止できます。

残念ながら、上記の例では、イベントのリッスンを開始する前にイベントが発生する可能性があるため、画像の「complete」プロパティを使用して回避する必要があります。

var img1 = document.querySelector('.img-1');

function loaded() {
  // woo yey image loaded
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // argh everything's broken
});

この方法では、リスニングする前にエラーが発生した画像はキャッチできません。残念ながら、DOM にはそのような方法はありません。また、これは 1 つの画像を読み込んでいます。一連の画像が読み込まれたタイミングを知りたい場合は、さらに複雑になります。

イベントが常に最適な方法とは限らない

イベントは、同じオブジェクトで複数回発生する可能性があるもの(keyup、touchstart など)に適しています。これらのイベントでは、リスナーをアタッチする前に何が起こったかはあまり重要ではありません。しかし、非同期の成功 / 失敗に関しては、理想的には次のようなものが必要です。

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

これは Promise と同じですが、より適切な名前が付けられています。HTML 画像要素に、Promise を返す「ready」メソッドがあれば、次のように記述できます。

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

最も基本的な点として、Promise はイベント リスナーに似ていますが、次の点が異なります。

  • Promise が成功または失敗するのは 1 回だけです。2 回成功または失敗することはなく、成功から失敗に切り替わることも、その逆もありません。
  • Promise が成功または失敗し、後で成功 / 失敗のコールバックを追加すると、イベントが以前に発生していても、正しいコールバックが呼び出されます。

これは、非同期の成功 / 失敗に非常に役立ちます。何かが利用可能になった正確な時間よりも、結果への対応に関心があるためです。

Promise の用語

Domenic Denicola 氏がこの記事の最初のドラフトを校正し、用語について「F」の評価を付けました。彼は私を居残りにし、States and Fates を 100 回書き写すよう強制し、私の両親に心配の手紙を書きました。それでも、多くの用語が混同されていますが、基本は次のとおりです。

約束の例:

  • fulfilled - Promise に関連するアクションが成功した
  • rejected - Promise に関連するアクションが失敗した
  • pending - まだ処理または拒否されていない
  • settled - 履行済みまたは不承認

仕様では、then メソッドを持つ Promise のようなオブジェクトを説明するために、thenable という用語も使用されています。この用語は、元イングランド代表監督のテリー ヴェナブルズを連想させるため、できるだけ使用しないようにします。

JavaScript に Promise が登場!

Promise は、次のようなライブラリの形式で以前から存在していました。

上記と JavaScript の Promise は、Promises/A+ と呼ばれる共通の標準化された動作を共有しています。jQuery ユーザーであれば、Deferreds と呼ばれる同様の機能があります。ただし、Deferred は Promise/A+ に準拠していないため、微妙に異なり、有用性が低くなります。jQuery にも Promise 型がありますが、これは Deferred のサブセットにすぎず、同じ問題があります。

Promise の実装は標準化された動作に従いますが、全体的な API は異なります。JavaScript の Promise は、API が RSVP.js に似ています。Promise を作成する方法は次のとおりです。

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

Promise コンストラクタは、1 つの引数(resolve と reject の 2 つのパラメータを持つコールバック)を取ります。コールバック内で処理(非同期処理など)を行い、すべてが正常に動作した場合は resolve を呼び出し、それ以外の場合は reject を呼び出します。

従来の JavaScript の throw と同様に、Error オブジェクトで拒否するのが慣例ですが、必須ではありません。Error オブジェクトの利点は、スタック トレースをキャプチャして、デバッグツールをより有用にすることです。

この Promise の使用方法は次のとおりです。

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

then() は、成功ケースのコールバックと失敗ケースのコールバックの 2 つの引数を取ります。どちらも省略可能であるため、成功または失敗のケースのみにコールバックを追加できます。

JavaScript の Promise は、DOM で「Future」として始まり、「Promise」に名前が変更され、最終的に JavaScript に移行されました。DOM ではなく JavaScript にあることで、Node.js などのブラウザ以外の JS コンテキストでも利用できるようになります(コア API で利用するかどうかは別の問題です)。

これらは JavaScript の機能ですが、DOM はこれらを恐れずに使用します。実際、非同期の成功 / 失敗メソッドを持つ新しい DOM API はすべて Promise を使用します。これは、Quota Management、Font Load Events、ServiceWorker、Web MIDI、Streams などですでに実現しています。

他のライブラリとの互換性

JavaScript の Promise API は、then() メソッドを持つものを Promise のように(Promise の用語では thenable (ため息))扱います。そのため、Q Promise を返すライブラリを使用しても、新しい JavaScript の Promise とうまく連携します。

ただし、前述のように、jQuery の Deferred はあまり役に立ちません。幸い、これらは標準の Promise にキャストできます。できるだけ早くキャストすることをおすすめします。

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

ここで、jQuery の $.ajax は Deferred を返します。then() メソッドがあるため、Promise.resolve() はこれを JavaScript Promise に変換できます。ただし、次のように、deferred がコールバックに複数の引数を渡すこともあります。

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

一方、JS Promise は最初の値以外をすべて無視します。

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

幸いなことに、通常はこれで十分です。少なくとも、必要なものにアクセスできます。また、jQuery は、Error オブジェクトを拒否に渡す規則に従っていません。

複雑な非同期コードを簡素化

では、コードを記述してみましょう。たとえば、次のようなことを行うとします。

  1. 読み込み中であることを示すスピナーを開始する
  2. ストーリーの JSON を取得します。これにより、各チャプターのタイトルと URL が提供されます。
  3. ページにタイトルを追加する
  4. 各チャプターを取得する
  5. ページにストーリーを追加する
  6. スピナーを停止する

…だけでなく、途中でエラーが発生した場合はユーザーに通知します。その時点でスピナーを停止する必要があります。そうしないと、スピナーが回転し続け、めまいがして、他の UI にクラッシュします。

もちろん、ストーリーを配信するために JavaScript を使用することはありません。HTML として提供する方が高速ですが、このパターンは API を扱うときに非常に一般的です。複数のデータを取得し、すべて完了したら何かを行います。

まず、ネットワークからデータを取得する処理を行います。

XMLHttpRequest の Promise 化

古い API は、下位互換性のある方法で可能であれば、Promise を使用するように更新されます。XMLHttpRequest は有力な候補ですが、ここでは GET リクエストを行う簡単な関数を作成します。

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

では、これを使用してみましょう。

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

これで、XMLHttpRequest を手動で入力せずに HTTP リクエストを行うことができます。XMLHttpRequest のキャメルケースを入力する手間が省けるため、作業が楽になります。

チェーン

then() はストーリーの終わりではありません。then を連結して値を変換したり、追加の非同期アクションを次々に実行したりできます。

値の変換

新しい値を返すだけで、値を変換できます。

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

実用的な例として、次の例に戻りましょう。

get('story.json').then(function(response) {
  console.log("Success!", response);
})

レスポンスは JSON ですが、現在は書式なしテキストとして受信しています。JSON responseType を使用するように get 関数を変更することもできますが、Promise で解決することもできます。

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("Yey JSON!", response);
})

JSON.parse() は単一の引数を受け取り、変換された値を返すため、次のようにショートカットを作成できます。

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

実際、getJSON() 関数は非常に簡単に作成できます。

function getJSON(url) {
  return get(url).then(JSON.parse);
}

getJSON() は引き続き Promise を返します。この Promise は URL を取得し、レスポンスを JSON として解析します。

非同期アクションのキューイング

then を連結して、非同期アクションを順番に実行することもできます。

then() コールバックから何かを返すのは、少し魔法のようなものです。値を返すと、次の then() がその値で呼び出されます。ただし、Promise のようなものを返すと、次の then() はそれを待機し、その Promise が解決(成功 / 失敗)したときにのみ呼び出されます。次に例を示します。

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

ここでは、story.json に非同期リクエストを送信して、リクエストする URL のセットを取得し、その最初の URL をリクエストします。この点が、Promise が単純なコールバック パターンと大きく異なる点です。

チャプターを取得するショートカット メソッドを作成することもできます。

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

getChapter が呼び出されるまで story.json はダウンロードされませんが、次に getChapter が呼び出されると、ストーリーの Promise が再利用されるため、story.json は 1 回だけ取得されます。Promise のメリット

エラー処理

前述のとおり、then() は 2 つの引数を取ります。1 つは成功用、もう 1 つは失敗用です(Promise の用語では、fulfill と reject)。

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

catch() を使用することもできます。

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

catch() は特別なものではなく、then(undefined, func) の糖衣構文ですが、より読みやすくなっています。上記の 2 つのコード例は同じように動作しません。後者は次のコードと同等です。

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

違いはわずかですが、非常に便利です。Promise の拒否は、拒否コールバック(または同等の catch())を使用して次の then() にスキップされます。then(func1, func2) を使用すると、func1 または func2 が呼び出されます。両方が呼び出されることはありません。しかし、then(func1).catch(func2) を使用すると、func1 が拒否された場合に両方が呼び出されます。これらはチェーン内の個別のステップであるためです。次の手順を行います。

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

上記のフローは、通常の JavaScript の try/catch とよく似ています。try 内で発生したエラーは、すぐに catch() ブロックに移動します。上記をフローチャートで表すと次のようになります(フローチャートが好きなため)。

履行される Promise の場合は青い線、拒否される Promise の場合は赤い線に沿って進みます。

JavaScript の例外と Promise

拒否は、Promise が明示的に拒否された場合に発生しますが、コンストラクタ コールバックでエラーがスローされた場合にも暗黙的に発生します。

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

つまり、Promise 関連のすべての処理を Promise コンストラクタのコールバック内で行うと、エラーが自動的にキャッチされて拒否されるため便利です。

then() コールバックでスローされたエラーについても同様です。

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

実際のエラー処理

ストーリーとチャプターの例では、catch を使用してユーザーにエラーを表示できます。

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

story.chapterUrls[0] の取得に失敗した場合(HTTP 500 やユーザーがオフラインの場合など)、後続の成功コールバックはすべてスキップされます。これには、レスポンスを JSON として解析しようとする getJSON() のコールバックや、chapter1.html をページに追加するコールバックも含まれます。代わりに、catch コールバックに移動します。その結果、前のアクションのいずれかが失敗すると、ページに「Failed to show chapter」が追加されます。

JavaScript の try / catch と同様に、エラーがキャッチされて後続のコードが続行されるため、スピナーは常に非表示になります。これは、想定どおりの動作です。上記は、次の非ブロッキング非同期バージョンになります。

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

エラーから復旧せずに、ロギング目的でのみ catch() を行うこともできます。これを行うには、エラーを再スローします。これは、getJSON() メソッドで行うことができます。

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

1 つのチャプターを取得できましたが、すべてのチャプターを取得する必要があります。それを実現しましょう

並列処理とシーケンス処理: 両方の長所を活かす

非同期で考えるのは簡単ではありません。なかなか始められない場合は、同期処理であるかのようにコードを記述してみてください。この例の場合は、次のようになります。

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

それがいいですね。しかし、これは同期処理であり、ダウンロード中はブラウザがロックされます。この処理を非同期にするには、then() を使用して処理を順番に実行します。

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

しかし、チャプターの URL をループして順番に取得するにはどうすればよいでしょうか。これは機能しません。

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

forEach は非同期に対応していないため、チャプターはダウンロードされた順序で表示されます。これは基本的に、パルプ フィクションの書き方です。これは『パルプ・フィクション』ではないので、修正しましょう。

シーケンスの作成

chapterUrls 配列を Promise のシーケンスに変換します。これには、then() を使用します。

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

Promise.resolve() は、指定した値に解決される Promise を作成します。Promise のインスタンスを渡すと、単にそれが返されます(注: これは仕様の変更であり、一部の実装ではまだ対応していません)。Promise のようなもの(then() メソッドを持つもの)を渡すと、同じように履行 / 拒否する本物の Promise が作成されます。他の値(Promise.resolve('Hello') など)を渡すと、その値で履行される Promise が作成されます。上記のように値を指定せずに呼び出すと、「undefined」で処理されます。

Promise.reject(val) もあります。これは、指定した値(または未定義)で拒否される Promise を作成します。

array.reduce を使用して、上記のコードを整理できます。

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

これは前の例と同じ処理を行いますが、個別の「sequence」変数は必要ありません。reduce コールバックは、配列内の各アイテムに対して呼び出されます。「sequence」は最初は Promise.resolve() ですが、それ以降の呼び出しでは、前の呼び出しから返された値になります。array.reduce は、配列を単一の値(この場合は Promise)に絞り込むのに非常に便利です。

まとめ

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

これで、同期バージョンの完全な非同期バージョンが完成しました。しかし、さらに改善する余地はあります。現時点では、ページは次のようにダウンロードされています。

ブラウザは複数のファイルを同時にダウンロードすることに優れているため、チャプターを 1 つずつダウンロードするとパフォーマンスが低下します。これらを同時にダウンロードし、すべてが到着したら処理します。幸い、このための API があります。

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

Promise.all は、Promise の配列を受け取り、それらがすべて正常に完了したときに履行される Promise を作成します。渡した Promise と同じ順序で、結果の配列(Promise が履行されたもの)が返されます。

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

接続によっては、1 つずつ読み込むよりも数秒速くなることがあり、最初の試行よりもコードが少なくなります。チャプターは任意の順序でダウンロードできますが、画面には正しい順序で表示されます。

ただし、知覚されるパフォーマンスはさらに改善できます。第 1 章が届いたら、ページに追加する必要があります。これにより、ユーザーは残りのチャプターが届く前に読み始めることができます。第 3 章が届いたときに、ユーザーは第 2 章がないことに気づかない可能性があるため、第 3 章をページに追加しません。第 2 章が届いたら、第 2 章と第 3 章を追加できます。

これを行うには、すべてのチャプターの JSON を同時に取得し、それらをドキュメントに追加するシーケンスを作成します。

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

これで、両方の長所を活かすことができます。すべてのコンテンツを配信するのにかかる時間は同じですが、ユーザーは最初のコンテンツをより早く取得できます。

この簡単な例では、すべてのチャプターがほぼ同時に到着しますが、1 つずつ表示するメリットは、チャプターの数が増え、サイズが大きくなるほど大きくなります。

Node.js スタイルのコールバックまたはイベントで上記を行うと、コードが約 2 倍になりますが、さらに重要なのは、それほど簡単ではないということです。しかし、Promise の話はこれで終わりではありません。他の ES6 の機能と組み合わせることで、さらに使いやすくなります。

ボーナス ラウンド: 拡張機能

この記事を最初に書いたときから、Promise を使用する機能は大幅に拡張されました。Chrome 55 以降、非同期関数により、Promise ベースのコードを同期的に記述できるようになりました。ただし、メインスレッドをブロックすることはありません。詳しくは、非同期関数に関する記事をご覧ください。主要なブラウザでは、Promise と非同期関数の両方が広くサポートされています。詳しくは、MDN の Promise と非同期関数のリファレンスをご覧ください。

この記事を校正し、修正や提案をしてくれた Anne van Kesteren、Domenic Denicola、Tom Ashworth、Remy Sharp、Addy Osmani、Arthur Evans、Yutaka Hirano に感謝します。

また、記事のさまざまな部分を更新してくれた Mathias Bynens にも感謝します。