JavaScript Promises: eine Einführung

Promise-Objekte vereinfachen verzögerte und asynchrone Berechnungen. Ein Promise stellt einen noch nicht abgeschlossenen Vorgang dar.

Jake Archibald
Jake Archibald

Bereiten Sie sich auf einen entscheidenden Moment in der Geschichte der Webentwicklung vor.

[Trommelwirbel beginnt]

Promise-Objekte sind in JavaScript eingegangen.

[Feuerwerk explodieren, glitzerndes Papier regnet von oben, die Menge ist wild]

An dieser Stelle fallen Sie in eine der folgenden Kategorien:

  • Die Leute jubeln um Sie herum, aber Sie sind sich nicht sicher, worum es hier geht. Vielleicht sind Sie sich nicht einmal sicher, was ein "Versprechen" ist. Sie würden die Achseln zucken, aber das Gewicht des glitzernden Papiers liegt auf Ihren Schultern. Wenn ja, machen Sie sich keine Sorgen. Es hat eine Weile gedauert, bis ich herausgefunden habe, warum ich mich um diese Dinge kümmern sollte. Vielleicht möchten Sie von vorn beginnen.
  • Du schlagst die Luft! Zeit, richtig? Sie haben diese Promise-Funktionen schon einmal verwendet, aber es stört Sie, dass alle Implementierungen eine etwas andere API haben. Wie lautet die API für die offizielle JavaScript-Version? Beginnen Sie am besten mit der Terminologie.
  • Das wussten Sie schon und Sie verspotten diejenigen, die auf und ab springen, als wäre es Nachrichten für sie. Nehmen Sie sich einen Moment Zeit, um Ihre eigene Überlegenheit zu prüfen, und rufen Sie dann direkt die API-Referenz auf.

Browserunterstützung und Polyfill

Unterstützte Browser

  • 32
  • 12
  • 29
  • 8

Quelle

Wenn du Browser ohne vollständige Versprechen an die Spezifikationskonformität anpassen oder Promise für andere Browser und Node.js hinzufügen möchtest, solltest du Polyfill (2k gzipped) ausprobieren.

Was soll das Ganze?

JavaScript ist ein Single-Thread-Element. Das bedeutet, dass zwei Skriptbits nicht gleichzeitig ausgeführt werden können. Sie müssen nacheinander ausgeführt werden. In Browsern teilt JavaScript einen Thread mit einer Menge anderer Inhalte, die von Browser zu Browser unterschiedlich sind. Normalerweise befindet sich JavaScript jedoch in derselben Warteschlange wie das Zeichnen, das Aktualisieren von Stilen und die Verarbeitung von Nutzeraktionen (z. B. Text hervorheben und mit Formularsteuerelementen interagieren). Aktivitäten in einem dieser Dinge verzögern die anderen.

Mensch ist ein Multithread. Sie können Text mit mehreren Fingern tippen und eine Unterhaltung gleichzeitig führen. Die einzige Blockierfunktion, mit der wir uns befassen müssen, ist das Niesen, bei dem alle aktuellen Aktivitäten für die Dauer des Niesens ausgesetzt werden müssen. Das ist ziemlich ärgerlich, vor allem, wenn Sie Auto fahren und versuchen, Sie möchten keinen interessanten Code schreiben.

Um dies zu umgehen, haben Sie wahrscheinlich Ereignisse und Callbacks verwendet. Hier sind Ereignisse:

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

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

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

Das ist gar nicht lustig. Wir rufen das Image ab und fügen einige Listener hinzu. Dann kann JavaScript die Ausführung stoppen, bis einer dieser Listener aufgerufen wird.

Leider ist es im obigen Beispiel möglich, dass die Ereignisse eingetreten sind, bevor wir sie überwachen konnten. Deshalb müssen wir dies mithilfe der "complete"-Eigenschaft von Bildern umgehen:

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
});

Bilder mit Fehlern werden nicht erkannt, bevor wir sie überwachen konnten. Leider bietet uns das DOM dafür keine Möglichkeit. Dabei wird ein Bild geladen. Wenn wir wissen möchten, wann eine Reihe von Bildern geladen ist, wird es noch komplexer.

Veranstaltungen sind nicht immer optimal

Ereignisse eignen sich hervorragend für Dinge, die mehrmals am selben Objekt auftreten können, z. B. keyup oder touchstart. Bei diesen Ereignissen ist es für Sie unwichtig, was vor dem Anhängen des Listeners passiert ist. Aber wenn es um asynchronen Erfolg/Misserfolg geht, sollten Sie im Idealfall etwa Folgendes benötigen:

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

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

So etwas ist durch Versprechen möglich, allerdings mit besserer Benennung. Hätten HTML-Bildelemente eine "ready"-Methode, die ein Promise zurückgegeben hat, könnten wir wie folgt vorgehen:

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

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

Im Grunde ähneln Versprechen wie Event-Listener, mit folgenden Ausnahmen:

  • Ein Promise kann nur einmal erfolgreich sein oder fehlschlagen. Sie kann nicht zweimal erfolgreich sein oder fehlschlagen. Es ist auch nicht möglich, von Erfolg zu Misserfolg zu wechseln oder umgekehrt.
  • Wenn ein Promise erfolgreich war oder fehlgeschlagen ist und Sie später einen Erfolgs-/Fehler-Callback hinzufügen, wird der richtige Callback aufgerufen, obwohl das Ereignis früher stattgefunden hat.

Dies ist bei asynchronen Erfolgen/Fehlern äußerst nützlich, da Sie weniger daran interessiert sind, wann genau etwas verfügbar wurde, und mehr daran interessiert sind, auf das Ergebnis zu reagieren.

Promise-Terminologie

Der Proof von Domenic Denicola war der erste Entwurf dieses Artikels. Ich wurde mit "F" für die Terminologie bewertet. Er ließ mich inhaftieren, zwang mich, States and Fates 100-mal zu kopieren, und schrieb einen besorgten Brief an meine Eltern. Trotzdem verwechseln meine Begriffe immer noch häufig, aber hier sind die Grundlagen:

Ein Promise kann Folgendes sein:

  • fulfilled: Aktion in Bezug auf das Versprechen erfolgreich
  • rejected – Die Aktion in Bezug auf das Versprechen ist fehlgeschlagen.
  • ausstehend – Wurde noch nicht ausgeführt oder abgelehnt
  • settled – erfüllt oder abgelehnt

In der Spezifikation wird außerdem der Begriff thenable verwendet, um ein Promise-ähnliches Objekt zu beschreiben, in dem es die Methode then enthält. Dieser Begriff erinnert mich an den ehemaligen englischen Fußballmanager Terry Venables, deshalb werde ich ihn so wenig wie möglich verwenden.

Promise-Objekte werden in JavaScript empfangen.

Promise-Objekte gibt es schon seit einiger Zeit in Form von Bibliotheken, z. B.:

Die oben und die JavaScript-Versprechen haben ein gemeinsames, standardisiertes Verhalten namens Promises/A+. Wenn Sie jQuery verwenden, gibt es eine ähnliche Funktion namens Deferreds. Zurückgestellte Elemente sind jedoch nicht Promise/A+-konform, wodurch sie geringfügig anders und weniger nützlich sind. jQuery hat auch einen Promise-Typ, aber dies ist nur ein Teil von „Zurückgestellt“ und hat die gleichen Probleme.

Obwohl Promise-Implementierungen einem standardisierten Verhalten folgen, unterscheiden sich die APIs insgesamt. JavaScript-Promis sind in der API ähnlich wie RSVP.js. So erstellst du ein 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"));
  }
});

Der Promise-Konstruktor verwendet ein Argument, einen Callback mit zwei Parametern, „resolve“ und „ablehnen“. Führen Sie eine Aktion innerhalb des Callbacks aus, z. B. asynchron, und rufen Sie dann „resolve“ auf, wenn alles funktioniert hat. Andernfalls rufen Sie „ablehnen“ auf.

Wie bei throw im einfachen alten JavaScript ist es üblich, aber nicht zwingend erforderlich, die Anfrage mit einem Fehlerobjekt abzulehnen. Fehlerobjekte haben den Vorteil, dass sie einen Stacktrace erfassen, wodurch Debugging-Tools hilfreicher sind.

So verwendest du dieses Promise:

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

then() verwendet zwei Argumente: einen Callback für einen erfolgreichen Fall und ein weiteres für den Fehler. Beide sind optional. Sie können also nur für den Erfolgs- oder Fehlerfall einen Callback hinzufügen.

JavaScript-Versprechen begannen im DOM als "Futures", wurden in "Promises" umbenannt und schließlich in JavaScript verschoben. Es ist großartig, sie in JavaScript und nicht im DOM zu verwenden, da sie in nicht browsergestützten JS-Kontexten wie Node.js verfügbar sind. Ob sie sie in ihren Kern-APIs verwenden, ist eine andere Frage.

Obwohl es sich um eine JavaScript-Funktion handelt, kann das DOM sie problemlos verwenden. Tatsächlich verwenden alle neuen DOM APIs mit asynchronen Erfolgs-/Fehlermethoden Promise. Dies ist bereits bei Kontingentverwaltung, Font Load-Ereignissen, ServiceWorker, Web MIDI, Streams und mehr der Fall.

Kompatibilität mit anderen Bibliotheken

Die JavaScript Promis API behandelt alles, was eine then()-Methode enthält, wie versprochen (oder thenable in Promise-speak sigh). Wenn Sie also eine Bibliothek verwenden, die ein Q-Promise zurückgibt, ist das kein Problem. Es ist kein Problem mit dem neuen JavaScript.

Obwohl, wie bereits erwähnt, sind die Deferreds von jQuery ein wenig... nicht hilfreich. Zum Glück kannst du sie auf Standardversprechen übertragen, was sich so schnell wie möglich lohnen würde:

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

Hier gibt der $.ajax von jQuery einen Deferred. Da sie eine then()-Methode hat, kann Promise.resolve() es in ein JavaScript-Promise umwandeln. Manchmal übergeben jedoch deferreds mehrere Argumente an ihre Callbacks, zum Beispiel:

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

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

JS-Versprechen hingegen ignorieren alle bis auf die erste:

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

Glücklicherweise ist dies in der Regel das, was Sie wollen, oder bietet Ihnen zumindest Zugriff auf das, was Sie möchten. Beachten Sie außerdem, dass jQuery nicht der Konvention für die Übergabe von Fehlerobjekten an Ablehnungen folgt.

Einfacher komplexer asynchroner Code

Okay, lassen Sie uns ein paar Dinge programmieren. Angenommen, wir möchten:

  1. Rotierendes Ladesymbol starten
  2. Rufen Sie JSON für eine Geschichte ab, das uns den Titel und die URLs für jedes Kapitel liefert.
  3. Titel zur Seite hinzufügen
  4. Jedes Kapitel abrufen
  5. Geschichte zur Seite hinzufügen
  6. Rotierendes Ladesymbol anhalten

...aber informieren Sie den Nutzer auch, wenn auf dem Weg etwas schiefgelaufen ist. Wir sollten das Kreiselsymbol auch an diesem Punkt anhalten, sonst dreht es sich weiter, wird schläfrig und stürzt ab.

Natürlich würden Sie kein JavaScript verwenden, um eine Story zu übermitteln, die Bereitstellung als HTML ist schneller, aber dieses Muster ist bei der Arbeit mit APIs recht üblich: Sie müssen mehrere Daten abrufen und dann eine Aktion ausführen, wenn alles fertig ist.

Beginnen wir mit dem Abrufen von Daten aus dem Netzwerk:

Versprechen von XMLHttpRequest

Alte APIs werden aktualisiert, um Promise zu verwenden, wenn dies auf abwärtskompatible Weise möglich ist. XMLHttpRequest ist ein Hauptkandidat, aber schreiben wir in der Zwischenzeit eine einfache Funktion, um eine GET-Anfrage zu stellen:

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();
  });
}

Gehen wir nun wie folgt vor:

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

Jetzt können wir HTTP-Anfragen senden, ohne XMLHttpRequest manuell einzugeben. Das ist großartig, denn je weniger ich die ärgerliche Kamelschreibweise von XMLHttpRequest sehen muss, desto glücklicher wird mein Leben.

Verkettung

then() ist nicht das Ende der Abfolge. Sie können thens jedoch verketten, um Werte zu transformieren, oder zusätzliche asynchrone Aktionen nacheinander ausführen.

Werte transformieren

Sie können Werte einfach transformieren, indem Sie den neuen Wert zurückgeben:

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
})

Gehen wir als Beispiel aus der Praxis zurück zu:

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

Die Antwort ist im JSON-Format, aber wir empfangen sie derzeit als Nur-Text. Wir könnten unsere get-Funktion so ändern, dass die JSON-responseType verwendet wird, aber wir könnten es auch in der Promise-Landschaft lösen:

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

Da JSON.parse() ein einzelnes Argument verwendet und einen transformierten Wert zurückgibt, können wir eine Abkürzung erstellen:

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

Tatsächlich könnten wir eine getJSON()-Funktion ganz einfach erstellen:

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

getJSON() gibt trotzdem ein Promise zurück, das eine URL abruft und die Antwort als JSON parst.

Asynchrone Aktionen in die Warteschlange stellen

Sie können thens auch verketten, um asynchrone Aktionen nacheinander auszuführen.

Wenn du etwas von einem then()-Callback zurückgibst, ist das schon ein bisschen Magie. Wenn Sie einen Wert zurückgeben, wird die nächste then() mit diesem Wert aufgerufen. Wenn Sie jedoch etwas zurückgeben, das einem Versprechen ähnelt, wird das nächste then()-Objekt nur dann aufgerufen, wenn es aufgelöst wird (erfolgreich/fehlgeschlagen). Beispiel:

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

Hier senden wir eine asynchrone Anfrage an story.json, die uns eine Reihe von URLs zur Verfügung stellt, die angefragt werden sollen, und dann die erste URL. Ab diesem Zeitpunkt heben sich Versprechungen wirklich von einfachen Callback-Mustern ab.

Du kannst sogar eine Tastenkombination zum Abrufen von Kapiteln einrichten:

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 wird erst heruntergeladen, wenn getChapter aufgerufen wird, aber beim nächsten Aufruf von getChapter wird das Promise der Geschichte wiederverwendet, sodass story.json nur einmal abgerufen wird. Geschworen!

Fehlerbehandlung

Wie wir bereits gesehen haben, verwendet then() zwei Argumente, eines für Erfolg und eines für Misserfolg (oder erfüllen und ablehnen, wie in der Versprechenssprache bezeichnet):

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

Sie können auch catch() verwenden:

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

catch() hat nichts Besonderes an catch(), es ist nur Zucker für then(undefined, func), ist aber besser lesbar. Beachten Sie, dass sich die beiden Codebeispiele oben nicht gleich verhalten. Letzteres entspricht:

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

Der Unterschied ist zwar subtil, aber äußerst nützlich. Bei Ablehnungen von Promise wird mit einem Ablehnungs-Callback (oder catch(), da dies gleichbedeutend ist) zum nächsten then() gewechselt. Mit then(func1, func2) wird func1 oder func2 aufgerufen, niemals beides. Mit then(func1).catch(func2) werden jedoch beide aufgerufen, wenn func1 ablehnt, da es sich um separate Schritte in der Kette handelt. Beachten Sie dabei Folgendes:

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!");
})

Der obige Ablauf ähnelt sehr dem normalen JavaScript-Versuch/Catching. Fehler, die innerhalb eines „try“-Vorgangs auftreten, werden sofort an den catch()-Block gesendet. Hier ist das obige Diagramm als Flussdiagramm (weil ich Flussdiagramme liebe):

Folge den blauen Linien für Versprechen, die erfüllt werden, die roten Linien für Versprechungen, die abgelehnt werden.

JavaScript-Ausnahmen und -Promis

Ablehnungen treten auf, wenn ein Promise explizit abgelehnt wird, aber auch implizit, wenn im Konstruktor-Callback ein Fehler ausgegeben wird:

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);
})

Das bedeutet, dass es hilfreich ist, alle Ihre Promise-bezogenen Aufgaben innerhalb des Promise-Konstruktor-Callbacks zu erledigen, damit Fehler automatisch erfasst und zu Ablehnungen werden.

Dasselbe gilt für Fehler in then()-Callbacks.

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);
})

Fehlerbehandlung in der Praxis

Mit unserer Geschichte und den Kapiteln können wir einen Catch verwenden, um den Nutzenden einen Fehler anzuzeigen:

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';
})

Wenn das Abrufen von story.chapterUrls[0] fehlschlägt (z. B. HTTP 500 oder der Nutzer offline ist), werden alle folgenden erfolgreichen Callbacks übersprungen, einschließlich des Callbacks in getJSON(), der versucht, die Antwort als JSON zu parsen. Außerdem wird der Callback übersprungen, durch den „kapitel1.html“ der Seite hinzugefügt wird. Stattdessen geht es zum Catch-Callback. Daher wird der Seite „Fehler beim Anzeigen des Kapitels“ hinzugefügt, wenn eine der vorherigen Aktionen fehlgeschlagen ist.

Wie bei „try/catch“ in JavaScript wird der Fehler abgefangen und der nachfolgende Code wird fortgesetzt, sodass das rotierende Ladesymbol immer ausgeblendet ist, was wir wollen. Dies wird zu einer nicht blockierenden asynchronen Version von:

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'

Sie können den catch() nur zu Protokollierungszwecken verwenden, ohne den Fehler zu beheben. Geben Sie dazu den Fehler einfach noch einmal aus. Das könnten wir in unserer getJSON()-Methode tun:

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

Wir haben es geschafft, ein Kapitel zu holen, aber wir möchten alle. Lassen Sie uns das machen.

Parallelität und Sequenzierung: Das Beste aus beidem erhalten

Asynchron zu denken, ist nicht einfach. Versuchen Sie, den Code so zu schreiben, als wäre er synchron. In diesem Fall gilt:

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'

Das funktioniert! Er wird jedoch synchronisiert und sperrt den Browser währenddessen. Damit dies asynchron funktioniert, verwenden wir then(), damit die Aktionen nacheinander ausgeführt werden.

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';
})

Aber wie können wir durch die URLs der Kapitel suchen und sie der Reihe nach abrufen? Dies funktioniert nicht:

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

forEach ist nicht asynchron, unsere Kapitel werden also in der Reihenfolge angezeigt, in der sie heruntergeladen wurden. Das entspricht im Grunde der Art und Weise, wie Pulp Fiction geschrieben wurde. Es handelt sich nicht um Pulp Fiction, also lass uns das ändern.

Sequenz erstellen

Wir möchten das Array chapterUrls in eine Folge von Versprechen umwandeln. Dazu können wir then() verwenden:

// 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);
  });
})

Dies ist das erste Mal, dass wir Promise.resolve() sehen, das ein Versprechen abbildet, das auf den von dir angegebenen Wert aufgelöst wird. Wenn Sie ihr eine Instanz von Promise übergeben, wird sie einfach zurückgegeben (Hinweis: Dies ist eine Änderung der Spezifikation, die für einige Implementierungen noch nicht eingehalten wird. Wenn du ihm etwas versprechendes Beispiel übergibst (eine then()-Methode hat), wird ein echtes Promise erstellt, der auf dieselbe Weise erfüllt bzw. abgelehnt wird. Wenn Sie einen anderen Wert übergeben, z.B. Promise.resolve('Hello') ist ein Versprechen, das diesen Wert erfüllt. Wenn Sie sie wie oben ohne Wert nennen, erfüllt sie sich mit „nicht definiert“.

Mit Promise.reject(val) wird ein Versprechen erstellt, das mit dem Wert, den du ihm gibst (oder nicht definiert), ablehnt.

Wir können den obigen Code mit array.reduce aufräumen:

// 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())

Der Vorgang entspricht dem vorherigen Beispiel, aber die separate "Sequence"-Variable ist nicht erforderlich. Unser Reduce-Callback wird für jedes Element im Array aufgerufen. "Sequence" ist beim ersten Mal Promise.resolve(), aber für die restlichen Aufrufe ist "Sequence" das, was wir vom vorherigen Aufruf zurückgegeben haben. array.reduce ist sehr nützlich, um ein Array auf einen einzigen Wert zu reduzieren, was in diesem Fall ein Versprechen ist.

Fassen wir nun alles zusammen:

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';
})

Und da haben wir es, eine vollständig asynchrone Version der Synchronisierungsversion. Aber wir können das besser machen. Derzeit wird unsere Seite wie folgt heruntergeladen:

Browser sind ziemlich gut darin, mehrere Inhalte auf einmal herunterzuladen, sodass die Leistung beeinträchtigt wird, wenn Kapitel nacheinander heruntergeladen werden. Wir möchten sie alle gleichzeitig herunterladen und verarbeiten, sobald sie alle sind. Zum Glück gibt es dafür eine API:

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

Promise.all nimmt eine Reihe von Versprechen und erstellt ein Versprechen, das erfüllt wird, wenn alle Versprechen erfolgreich abgeschlossen wurden. Du erhältst eine Reihe von Ergebnissen (je nachdem, was du versprichst) in der Reihenfolge, in der du deine Versprechen gegeben hast.

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';
})

Je nach Verbindung kann dies Sekunden schneller sein als das direkte Laden. Außerdem ist der Code weniger Code als beim ersten Versuch. Die Kapitel können in beliebiger Reihenfolge heruntergeladen werden, erscheinen aber in der richtigen Reihenfolge.

Die wahrgenommene Leistung lässt sich jedoch trotzdem verbessern. Wenn Kapitel 1 erscheint, sollten wir es auf der Seite hinzufügen. So können Nutzende mit dem Lesen beginnen, bevor die restlichen Kapitel angekommen sind. Wenn Kapitel 3 da ist, würden wir es nicht auf der Seite hinzufügen, da der Nutzer vielleicht gar nicht bemerkt, dass Kapitel 2 fehlt. Wenn Kapitel 2 erscheint, können wir Kapitel 2 und 3 usw. hinzufügen.

Dazu rufen wir JSON für alle Kapitel gleichzeitig ab und erstellen dann eine Sequenz, um sie dem Dokument hinzuzufügen:

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';
})

Und das Beste von beidem! Es dauert genauso lange, bis alle Inhalte bereitgestellt werden, aber der Nutzer erhält den ersten Teil des Inhalts früher.

In diesem einfachen Beispiel erscheinen alle Kapitel zur selben Zeit, aber der Vorteil, eins nach dem anderen angezeigt zu werden, wird mit mehr, größeren Kapiteln übertrieben sein.

Wenn Sie dafür Callbacks oder Ereignisse im Node.js-Stil verwenden, ist der Code doppelt so groß, was noch wichtiger ist, aber nicht so einfach nachzuvollziehen. Dies ist jedoch noch nicht das Ende der Geschichte für Versprechen, da sie in Kombination mit anderen ES6-Funktionen noch einfacher werden.

Zusatzrunde: erweiterte Funktionen

Seit ich diesen Artikel ursprünglich geschrieben habe, haben sich die Möglichkeiten, Promise-Objekte zu verwenden, enorm erweitert. Seit Chrome 55 ist es mit asynchronen Funktionen möglich, Promise-basierten Code so zu schreiben, als wäre er synchron, aber ohne den Hauptthread zu blockieren. Weitere Informationen dazu finden Sie in my async functions article. Sowohl Promise- als auch asynchrone Funktionen werden von den wichtigsten Browsern unterstützt. Weitere Informationen finden Sie in der Referenz zu Promise und Asynchronen Funktionen von MDN.

Vielen Dank an Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans und Yutaka Hirano, die dies Korrektur gelesen und Korrekturen/Empfehlungen vorgenommen haben.

Vielen Dank auch an Mathias Bynens für die Aktualisierung verschiedener Teile des Artikels.