JavaScript-Promises: Eine Einführung

Promises vereinfachen verzögerte und asynchrone Berechnungen. Ein Promise stellt einen Vorgang dar, der noch nicht abgeschlossen ist.

Jake Archibald
Jake Archibald

Entwickler, macht euch bereit für einen entscheidenden Moment in der Geschichte der Webentwicklung.

[Trommelwirbel]

Promises sind jetzt in JavaScript verfügbar.

[Feuerwerk explodiert, Glitzerpapier regnet von oben, die Menge jubelt]

An dieser Stelle fallen Sie in eine der folgenden Kategorien:

  • Die Leute um dich herum jubeln, aber du weißt nicht, worum es geht. Vielleicht weißt du nicht einmal, was ein „Versprechen“ ist. Du würdest mit den Schultern zucken, aber das Gewicht des glitzernden Papiers lastet schwer auf deinen Schultern. Falls ja, machen Sie sich keine Sorgen. Ich habe auch lange gebraucht, um zu verstehen, warum ich mich damit beschäftigen sollte. Am besten fängst du von vorne an.
  • Du schlägst in die Luft! Das wurde auch Zeit, oder? Sie haben diese Promise-Objekte 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? Am besten beginnen Sie mit der Terminologie.
  • Sie wussten das bereits und machen sich über diejenigen lustig, die so tun, als wäre das etwas Neues. Nehmen Sie sich einen Moment Zeit, um sich über Ihre Überlegenheit zu freuen, und rufen Sie dann direkt die API-Referenz auf.

Browserunterstützung und Polyfill

Browser Support

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

Source

Wenn du Browser, die keine vollständige Promise-Implementierung haben, auf den neuesten Stand bringen oder Promises zu anderen Browsern und Node.js hinzufügen möchtest, sieh dir das Polyfill (2 KB, mit gzip komprimiert) an.

Worum geht es bei dem ganzen Trubel?

JavaScript ist ein Single-Thread-System. Das bedeutet, dass zwei Skriptteile nicht gleichzeitig ausgeführt werden können, sondern nacheinander ausgeführt werden müssen. In Browsern teilt sich JavaScript einen Thread mit vielen anderen Dingen, die sich von Browser zu Browser unterscheiden. In der Regel befindet sich JavaScript jedoch in derselben Warteschlange wie das Zeichnen, das Aktualisieren von Stilen und das Verarbeiten von Nutzeraktionen (z. B. das Markieren von Text und das Interagieren mit Formularsteuerelementen). Aktivitäten in einem dieser Bereiche verzögern die anderen.

Als Mensch sind Sie multithreaded. Sie können mit mehreren Fingern tippen, Sie können fahren und sich gleichzeitig unterhalten. Die einzige Blockierfunktion, mit der wir es zu tun haben, ist das Niesen, bei dem alle aktuellen Aktivitäten für die Dauer des Niesens ausgesetzt werden müssen. Das ist ziemlich nervig, besonders wenn man gerade fährt und sich unterhalten möchte. Sie möchten keinen Code schreiben, der „sneezy“ ist.

Sie haben wahrscheinlich Ereignisse und Callbacks verwendet, um dieses Problem zu umgehen. Hier sind Termine:

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

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

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

Das ist überhaupt nicht niesen. Wir rufen das Bild ab, fügen ein paar Listener hinzu und dann kann die Ausführung von JavaScript gestoppt werden, bis einer dieser Listener aufgerufen wird.

Im obigen Beispiel ist es jedoch möglich, dass die Ereignisse bereits eingetreten sind, bevor wir mit dem Abhören begonnen haben. Daher müssen wir dieses Problem mit der Eigenschaft „complete“ 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, die vor dem Abhören einen Fehler verursacht haben, werden nicht erfasst. Leider bietet das DOM keine Möglichkeit, dies zu tun. Außerdem wird hier nur ein Bild geladen. Noch komplexer wird es, wenn wir wissen möchten, wann eine Reihe von Bildern geladen wurde.

Ereignisse sind nicht immer die beste Möglichkeit,

Ereignisse eignen sich hervorragend für Dinge, die mehrmals für dasselbe Objekt auftreten können, z. B. keyup, touchstart usw. Bei diesen Ereignissen ist es nicht wichtig, was passiert ist, bevor Sie den Listener angehängt haben. Wenn es jedoch um asynchronen Erfolg/Misserfolg geht, möchten Sie idealerweise etwas wie Folgendes:

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

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

Das ist genau das, was Promises tun, aber mit einer besseren Benennung. Wenn HTML-Bildelemente eine „ready“-Methode hätten, die ein Promise zurückgibt, könnten wir Folgendes tun:

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 sind Promises ein bisschen wie Event-Listener, mit folgenden Unterschieden:

  • Ein Promise kann nur einmal erfolgreich sein oder fehlschlagen. Sie kann nicht zweimal erfolgreich sein oder fehlschlagen und auch nicht von „erfolgreich“ zu „fehlgeschlagen“ oder umgekehrt wechseln.
  • Wenn ein Promise erfolgreich war oder fehlgeschlagen ist und Sie später einen Erfolgs- oder Fehler-Callback hinzufügen, wird der richtige Callback aufgerufen, auch wenn das Ereignis bereits stattgefunden hat.

Dies ist für asynchrone Erfolge/Fehler äußerst nützlich, da Sie weniger am genauen Zeitpunkt interessiert sind, zu dem etwas verfügbar wurde, sondern mehr daran, auf das Ergebnis zu reagieren.

Promise-Terminologie

Domenic Denicola hat den ersten Entwurf dieses Artikels Korrektur gelesen und mir für die Terminologie die Note „F“ gegeben. Er hat mich nachsitzen lassen, mich gezwungen, States and Fates 100 Mal abzuschreiben, und einen besorgten Brief an meine Eltern geschrieben. Trotzdem verwechsle ich immer noch viele Begriffe, aber hier sind die Grundlagen:

Ein Versprechen kann Folgendes sein:

  • fulfilled: Die Aktion im Zusammenhang mit dem Promise war erfolgreich.
  • rejected: Die mit dem Promise verknüpfte Aktion ist fehlgeschlagen.
  • Ausstehend: Die Anfrage wurde noch nicht erfüllt oder abgelehnt.
  • settled: Erfüllt oder abgelehnt

In der Spezifikation wird auch der Begriff thenable verwendet, um ein Objekt zu beschreiben, das einem Promise ähnelt, da es eine then-Methode hat. Dieser Begriff erinnert mich an den ehemaligen englischen Fußballmanager Terry Venables, daher werde ich ihn so selten wie möglich verwenden.

Promise-Objekte in JavaScript

Promises gibt es schon seit einiger Zeit in Form von Bibliotheken, z. B.:

Die oben genannten und JavaScript-Promises haben ein gemeinsames, standardisiertes Verhalten, das als Promises/A+ bezeichnet wird. Wenn Sie jQuery verwenden, gibt es dort etwas Ähnliches, das als Deferreds bezeichnet wird. Deferreds sind jedoch nicht Promise/A+-konform, was sie etwas anders und weniger nützlich macht. Seien Sie also vorsichtig. jQuery hat auch einen Promise-Typ, aber das ist nur eine Teilmenge von Deferred und hat dieselben Probleme.

Obwohl Promise-Implementierungen einem standardisierten Verhalten folgen, unterscheiden sich ihre APIs insgesamt. JavaScript-Promises ähneln in der API RSVP.js. So erstellen Sie 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 „reject“. Führen Sie im Callback etwas aus, möglicherweise asynchron, und rufen Sie dann „resolve“ auf, wenn alles funktioniert hat, andernfalls „reject“.

Wie bei throw in einfachem JavaScript ist es üblich, aber nicht erforderlich, die Ablehnung mit einem Error-Objekt durchzuführen. Der Vorteil von Error-Objekten besteht darin, dass sie einen Stacktrace erfassen, was Debugging-Tools hilfreicher macht.

So nutzen Sie dieses Versprechen:

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 Erfolgsfall und einen weiteren für einen Fehlerfall. Beide sind optional, sodass Sie nur für den Erfolgs- oder Fehlerfall einen Callback hinzufügen können.

JavaScript-Promises begannen im DOM als „Futures“, wurden in „Promises“ umbenannt und schließlich in JavaScript verschoben. Die Bereitstellung in JavaScript statt im DOM ist vorteilhaft, da sie in JS-Kontexten außerhalb des Browsers wie Node.js verfügbar sind (ob sie in ihren Kern-APIs verwendet werden, ist eine andere Frage).

Obwohl es sich um ein JavaScript-Feature handelt, werden sie im DOM verwendet. Tatsächlich verwenden alle neuen DOM-APIs mit asynchronen Erfolgs-/Fehlermethoden Promises. Dies geschieht bereits mit der Kontingentverwaltung, Schriftladeereignissen, ServiceWorker, Web MIDI, Streams und mehr.

Kompatibilität mit anderen Bibliotheken

Die JavaScript Promises API behandelt alles mit einer then()-Methode als Promise-ähnlich (oder thenable in der Promise-Sprache *seufz*). Wenn Sie also eine Bibliothek verwenden, die ein Q-Promise zurückgibt, ist das in Ordnung und funktioniert gut mit den neuen JavaScript-Promises.

Allerdings sind die Deferreds von jQuery, wie bereits erwähnt, etwas unpraktisch. Glücklicherweise können Sie sie in Standard-Promises umwandeln, was Sie so schnell wie möglich tun sollten:

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

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

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

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

JS-Promises ignorieren hingegen alle außer dem ersten:

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

Glücklicherweise ist dies normalerweise das, was Sie wollen, oder es gibt Ihnen zumindest Zugriff auf das, was Sie wollen. Beachten Sie außerdem, dass jQuery nicht der Konvention folgt, Fehlerobjekte an Ablehnungen zu übergeben.

Komplexer asynchroner Code leicht gemacht

Lassen Sie uns ein paar Dinge programmieren. Angenommen, wir möchten Folgendes tun:

  1. Spinner starten, um den Ladevorgang anzuzeigen
  2. JSON-Daten für eine Geschichte abrufen, die den Titel und die URLs für jedes Kapitel enthalten
  3. Titel zur Seite hinzufügen
  4. Jedes Kapitel abrufen
  5. Story zur Seite hinzufügen
  6. Spinner anhalten

… aber auch, wenn etwas schiefgelaufen ist. Wir müssen auch den Spinner anhalten, sonst dreht er sich weiter, wird schwindelig und stürzt in eine andere Benutzeroberfläche ab.

Natürlich würden Sie JavaScript nicht verwenden, um eine Geschichte bereitzustellen, da die Bereitstellung als HTML schneller ist. Dieses Muster ist jedoch bei der Arbeit mit APIs ziemlich gängig: Mehrere Datenabrufe, dann etwas tun, wenn alles erledigt ist.

Beginnen wir mit dem Abrufen von Daten aus dem Netzwerk:

XMLHttpRequest mit Promises verwenden

Alte APIs werden aktualisiert, um Promises zu verwenden, sofern dies abwärtskompatibel möglich ist. XMLHttpRequest ist ein guter Kandidat, aber schreiben wir erst einmal 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();
  });
}

Jetzt verwenden wir sie:

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

Jetzt können wir HTTP-Anfragen stellen, ohne XMLHttpRequest manuell eingeben zu müssen. Das ist großartig, denn je weniger ich die Camel-Case-Schreibweise von XMLHttpRequest sehen muss, desto glücklicher bin ich.

Verkettung

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

Werte transformieren

Sie können Werte transformieren, indem Sie einfach 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
})

Sehen wir uns dazu noch einmal das folgende Beispiel an:

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

Die Antwort ist im JSON-Format, wird aber derzeit als Nur-Text empfangen. Wir könnten unsere GET-Funktion so ändern, dass sie das JSON-responseType verwendet, aber wir könnten das Problem auch mit Promises 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 akzeptiert und einen transformierten Wert zurückgibt, können wir eine Abkürzung verwenden:

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

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

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

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

Asynchrone Aktionen in die Warteschlange stellen

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

Wenn Sie etwas von einem then()-Callback zurückgeben, ist das ein bisschen magisch. Wenn Sie einen Wert zurückgeben, wird der nächste then() mit diesem Wert aufgerufen. Wenn Sie jedoch etwas Promise-ähnliches zurückgeben, wartet das nächste then() darauf und wird erst aufgerufen, wenn dieses Promise abgeschlossen ist (erfolgreich/fehlgeschlagen). Beispiel:

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

Hier stellen wir eine asynchrone Anfrage an story.json, die uns eine Reihe von URLs zurückgibt, die wir anfragen können. Anschließend fragen wir die erste dieser URLs an. Hier heben sich Promises wirklich von einfachen Callback-Mustern ab.

Sie können sogar eine Shortcut-Methode erstellen, um Kapitel abzurufen:

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. Bei den nächsten Aufrufen von getChapter wird jedoch das Story-Promise wiederverwendet, sodass story.json nur einmal abgerufen wird. Yay Promises!

Fehlerbehandlung

Wie wir bereits gesehen haben, verwendet then() zwei Argumente, eines für den Erfolg und eines für den Fehler (oder „fulfill“ und „reject“ in der Promise-Sprache):

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() ist nichts Besonderes, sondern nur eine Kurzform für then(undefined, func), die aber besser lesbar ist. Die beiden obigen Codebeispiele verhalten sich nicht gleich. Letzteres entspricht:

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

Der Unterschied ist subtil, aber äußerst nützlich. Promise-Ablehnungen springen zum nächsten then() mit einem Ablehnungs-Callback (oder catch(), da dies äquivalent ist). Bei then(func1, func2) wird entweder func1 oder func2 aufgerufen, aber nie beide. Mit then(func1).catch(func2) werden jedoch beide aufgerufen, wenn func1 abgelehnt wird, da es sich um separate Schritte in der Kette handelt. Beispiel:

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 ist dem normalen JavaScript-try/catch-Block sehr ähnlich. Fehler, die innerhalb eines „try“-Blocks auftreten, werden sofort an den catch()-Block weitergeleitet. Hier ist das Ganze als Flussdiagramm (weil ich Flussdiagramme liebe):

Folgen Sie den blauen Linien für erfüllte Promises oder den roten Linien für abgelehnte Promises.

JavaScript-Ausnahmen und -Promises

Ablehnungen treten auf, wenn ein Promise explizit abgelehnt wird, aber auch implizit, wenn im Konstruktor-Callback ein Fehler ausgelöst 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);
})

Es ist daher sinnvoll, alle Promise-bezogenen Aufgaben im Promise-Konstruktor-Callback auszuführen, damit Fehler automatisch abgefangen und zu Ablehnungen werden.

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

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

In unserer Geschichte mit Kapiteln können wir mit „catch“ einen Fehler für den Nutzer anzeigen lassen:

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 Nutzer ist offline), werden alle folgenden Erfolgs-Callbacks übersprungen, einschließlich des Callbacks in getJSON(), der versucht, die Antwort als JSON zu parsen. Außerdem wird der Callback übersprungen, der chapter1.html zur Seite hinzufügt. Stattdessen wird der Catch-Callback aufgerufen. Wenn eine der vorherigen Aktionen fehlschlägt, wird der Seite „Failed to show chapter“ hinzugefügt.

Wie bei „try/catch“ in JavaScript wird der Fehler abgefangen und der nachfolgende Code wird fortgesetzt. Der Ladekreis wird also immer ausgeblendet, was wir auch wollen. Das obige Beispiel 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'

Möglicherweise möchten Sie catch() nur zu Logging-Zwecken verwenden, ohne den Fehler zu beheben. Dazu müssen Sie den Fehler einfach noch einmal auslösen. Das können wir in der Methode getJSON() tun:

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

Wir haben also ein Kapitel abgerufen, möchten aber alle. Lassen Sie uns das machen.

Parallelität und Sequenzierung: Das Beste aus beiden Welten

Asynchrones Denken ist nicht einfach. Wenn Sie Schwierigkeiten haben, den Anfang zu finden, versuchen Sie, den Code so zu schreiben, als wäre er synchron. In diesem Fall:

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'

Gute Idee. Aber es ist synchron und blockiert den Browser, während Inhalte heruntergeladen werden. Damit dies asynchron funktioniert, verwenden wir then(), damit die Dinge nacheinander geschehen.

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 die Kapitel-URLs durchlaufen und sie der Reihe nach abrufen? Das 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, daher würden unsere Kapitel in der Reihenfolge angezeigt, in der sie heruntergeladen werden. Das ist im Grunde so, wie Pulp Fiction geschrieben wurde. Das ist nicht Pulp Fiction, also bringen wir das in Ordnung.

Sequenz erstellen

Wir möchten unser chapterUrls-Array in eine Sequenz von Promises umwandeln. Das können wir mit then() tun:

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

Hier sehen wir zum ersten Mal Promise.resolve(). Dadurch wird ein Promise erstellt, das zu dem von Ihnen angegebenen Wert aufgelöst wird. Wenn Sie eine Instanz von Promise übergeben, wird sie einfach zurückgegeben (Hinweis: Dies ist eine Änderung der Spezifikation, die von einigen Implementierungen noch nicht befolgt wird). Wenn Sie ein Promise-ähnliches Objekt übergeben (mit einer then()-Methode), wird ein echtes Promise erstellt, das auf dieselbe Weise erfüllt/abgelehnt wird. Wenn Sie einen anderen Wert übergeben, z.B. Promise.resolve('Hello'), wird ein Promise erstellt, das mit diesem Wert erfüllt wird. Wenn Sie sie wie oben ohne Wert aufrufen, wird sie mit „undefined“ erfüllt.

Außerdem gibt es Promise.reject(val), wodurch ein Promise erstellt wird, das mit dem von Ihnen angegebenen Wert (oder „undefined“) abgelehnt wird.

Wir können den obigen Code mit array.reduce bereinigen:

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

Dies entspricht dem vorherigen Beispiel, erfordert jedoch nicht die separate Variable „sequence“. 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 einzelnen Wert zu reduzieren, in diesem Fall ein Promise.

Fassen wir 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 schon haben wir eine vollständig asynchrone Version der synchronen Version. Aber wir können es besser machen. Im Moment wird unsere Seite so heruntergeladen:

Browser sind ziemlich gut darin, mehrere Dinge gleichzeitig herunterzuladen. Wenn wir Kapitel nacheinander herunterladen, verlieren wir also an Leistung. Wir möchten sie alle gleichzeitig herunterladen und dann verarbeiten, wenn sie alle angekommen sind. Glücklicherweise gibt es dafür eine API:

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

Promise.all verwendet ein Array von Promises und erstellt ein Promise, das erfüllt wird, wenn alle erfolgreich abgeschlossen wurden. Sie erhalten ein Array von Ergebnissen (unabhängig davon, was die Promises erfüllt haben) in derselben Reihenfolge wie die übergebenen Promises.

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 das einige Sekunden schneller sein als das Laden einzelner Bilder und es ist weniger Code als bei unserem ersten Versuch. Die Kapitel können in beliebiger Reihenfolge heruntergeladen werden, werden aber in der richtigen Reihenfolge auf dem Bildschirm angezeigt.

Wir können jedoch die wahrgenommene Leistung verbessern. Wenn Kapitel 1 eintrifft, sollten wir es der Seite hinzufügen. So kann der Nutzer mit dem Lesen beginnen, bevor die restlichen Kapitel angekommen sind. Wenn Kapitel 3 erscheint, würden wir es nicht auf der Seite hinzufügen, da der Nutzer möglicherweise nicht bemerkt, dass Kapitel 2 fehlt. Wenn Kapitel 2 verfügbar ist, können wir Kapitel 2 und 3 hinzufügen usw.

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 schon haben wir das Beste aus beiden Welten. Es dauert genauso lange, bis der gesamte Inhalt bereitgestellt wird, aber der Nutzer erhält den ersten Teil des Inhalts früher.

In diesem einfachen Beispiel werden alle Kapitel ungefähr zur gleichen Zeit geladen. Der Vorteil, sie einzeln anzuzeigen, wird jedoch bei mehr und größeren Kapiteln deutlicher.

Wenn Sie das oben Genannte mit Callbacks oder Ereignissen im Node.js-Stil tun, ist der Code etwa doppelt so lang, aber vor allem nicht so leicht nachvollziehbar. Das ist jedoch noch nicht alles. In Kombination mit anderen ES6-Funktionen werden Promises noch einfacher.

Zusatzrunde: Erweiterte Funktionen

Seit ich diesen Artikel ursprünglich geschrieben habe, hat sich die Möglichkeit, Promises zu verwenden, erheblich erweitert. Seit Chrome 55 können mit asynchronen Funktionen Promise-basierte Codes so geschrieben werden, als wären sie synchron, ohne jedoch den Hauptthread zu blockieren. Weitere Informationen dazu finden Sie in meinem Artikel zu asynchronen Funktionen. Sowohl Promises als auch asynchrone Funktionen werden von den gängigen Browsern umfassend unterstützt. Weitere Informationen finden Sie in der MDN-Referenz zu Promise und asynchronen Funktionen.

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

Außerdem danken wir Mathias Bynens für die Aktualisierung verschiedener Teile des Artikels.