JavaScript の Promise: 概要

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

ウェブ開発の歴史における転換期に備えましょう。

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

JavaScript に Promise が到着しました。

[花火が打ち上げ、キラキラ光る紙の雨が上から降り、観客が熱狂する]

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

  • 周りの人たちは声援を送ってますが、何がそんなに大変なのかわかりません。「約束」が何であるかよくわからないかもしれません。さあ、キラキラした紙の重さが肩にかかっています。もしそうだとしても気にしないで。なぜこのようなことに気を配るべきなのか理解するのに何年もかかりました。最初から始めることをおすすめします。
  • パンチだね!ちょうど時間です。先ほどは Promise を使っていましたが、実装ごとに API が若干異なることに気が付きました。JavaScript の公式バージョンの API を教えてください。用語から始めることをおすすめします。
  • あなたはすでにこのことを知っていて、ニュースのように飛び跳ねる人たちを嘲笑しています。少し時間を取って自分の優位性に浸ったら、API リファレンスに進んでください。

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

対応ブラウザ

  • 32
  • 12
  • 29
  • 8

ソース

完全な Promise の実装がないブラウザを仕様に準拠させる場合や、他のブラウザや Node.js に Promise を追加するには、polyfill(2k gzip 圧縮)を確認してください。

何の用件?

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

人間はマルチスレッドです。複数の指で入力でき 運転しながら会話できます私たちが対処する必要がある唯一のブロック機能は、くしゃみです。くしゃみの間は、現在のすべての活動を停止する必要があります。これはかなりイライラします 特に運転中に会話をしようとしているときはくしゃみをするコードを記述したくありません。

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

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 つの画像を読み込んでいます。一連の画像がいつ読み込まれたかを知りたい場合は、事態はさらに複雑になります。

イベントは必ずしも最適な方法とは限らない

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

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」と評価しました。彼は私を拘留し、状態と運命を 100 回コピーせざるを得ず、両親に心配な手紙を書きました。それにもかかわらず、私はまだ多くの用語を混同していますが、基本は以下のとおりです。

Promise は次のようになります。

  • 完了 - Promise に関連するアクションが成功しました
  • rejected - Promise に関連するアクションが失敗しました
  • 保留中 - 処理も却下もされていない
  • 清算 - 履行または却下された

また、仕様では、thenable という用語を使用して、then メソッドを持つ Promise に似たオブジェクトを記述します。この言葉は元イングランド フットボールのマネージャー、Terry Venables を思い出させるので、できるだけ使わないようにしています。

JavaScript に Promise が渡されます。

次のようなライブラリの形で 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 つの引数として、2 つのパラメータ(resolve と denied)を持つコールバックを受け取ります。非同期など、コールバック内で何かを行い、すべてが機能した場合は呼び出しが解決され、それ以外の場合は呼び出しが拒否されます。

古い 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 を使用します。これは、割り当て管理フォント読み込みイベントServiceWorkerWeb MIDIストリームなどですでに行われています。

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

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 を連結して値を変換したり、追加の非同期アクションを 1 つずつ実行したりできます。

値の変換

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

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 関数を変更できますが、Promises でも解決できます。

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

JSON.parse() は 1 つの引数を取り、変換された値を返すため、次のショートカットを作成できます。

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 を返します。これは、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);
})

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

エラー処理

前述のように、then() は成功と失敗(Promise の用語では解決と拒否)用の 2 つの引数を取ります。

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 の拒否は、拒否のコールバックを持つ次の then()(または同等のものであるため catch())にスキップされます。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() 内のコールバックが含まれます。また、ページに chap1.html を追加するコールバックもスキップされます。代わりに catch コールバックに進みます。その結果、前述の操作のいずれかが失敗した場合は、「Failed to show chap」というタイトルがページに追加されます。

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() を使用して処理を 1 つずつ順に実行します。

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 のインスタンスを渡すと、そのまま返されます(注: これは、一部の実装ではまだ準拠していない仕様の変更です)。then() メソッドを持つ Promise に似たものを渡すと、同じ方法で履行または拒否される真正な Promise が作成されます。他の値(Promise.resolve('Hello') に設定すると、その値を満たす Promise が作成されます。上記のように値を指定せずに呼び出すと、「未定義」のフルフィルメントになります。

また、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 章が欠落していることに気づかないため、ページに追加しません。第 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 機能と組み合わせることで、さらに簡単になります。

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

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

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

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