JavaScript Promises: eine Einführung

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

Archibald
Jake Archibald

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

[Trommelwirbel beginnt]

Promise-Objekte in JavaScript verfügbar

[Feuerwerk explodiert, glitzerndes Papier regnet von oben, die Menge raubt aus]

An dieser Stelle fallen Sie in eine der folgenden Kategorien:

  • Sie 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 zucken mit den Achseln, aber das Gewicht des glitzernden Papiers liegt auf Ihren Schultern. Falls ja, keine Sorge, ich habe ewig gebraucht, um herauszufinden, warum ich mich dafür interessieren sollte. Sie sollten wahrscheinlich am Anfang beginnen.
  • Du drückst in die Luft! Richtig Zeit? Sie haben diese Promise-Funktionen schon einmal verwendet, aber es stört, dass alle Implementierungen eine etwas andere API haben. Wie lautet die API für die offizielle JavaScript-Version? Beginnen Sie mit der Terminologie.
  • Sie wussten das bereits und Sie schauten über diejenigen, die auf und ab springen, als seien es Nachrichten für sie. Nehmen Sie sich einen Moment Zeit, um herauszufinden, wie Sie sind und gehen Sie dann direkt zur API-Referenz.

Browserunterstützung und Polyfill

Unterstützte Browser

  • 32
  • 12
  • 29
  • 8

Quelle

Um Browser, die keine vollständige Promise-Implementierung enthalten, auf die Spezifikations-Compliance zu bringen oder Promise-Werte für andere Browser und Node.js hinzuzufügen, kannst du Polyfill (2K mit gzipped) ausprobieren.

Woran liegt das?

JavaScript ist Single-Threaded, das heißt, zwei Skript-Bits können nicht gleichzeitig ausgeführt werden, sondern müssen nacheinander ausgeführt werden. In Browsern teilt JavaScript einen Thread mit einer Menge anderer Dinge, die sich von Browser zu Browser unterscheiden. In der Regel befindet sich JavaScript jedoch in derselben Warteschlange wie das Painting, die Aktualisierung von Stilen und die Verarbeitung von Nutzeraktionen (z. B. das Hervorheben von Text und das Interagieren mit Formularsteuerelementen). Aktivitäten in einem dieser Vorgänge verzögern die anderen.

Als Mensch hat man mehrere Threads. Sie können mit mehreren Fingern tippen, Sie können gleichzeitig eine Unterhaltung führen. Die einzige Funktion zum Niesen ist die einzige Blockierfunktion, bei der alle aktuellen Aktivitäten während des Niesens ausgesetzt werden müssen. Das ist ziemlich ärgerlich, vor allem, wenn Sie Auto fahren und versuchen, ein Gespräch zu führen. Sie möchten keinen hinteren Code schreiben.

Wahrscheinlich haben Sie Ereignisse und Callbacks verwendet, um dieses Problem zu umgehen. Hier sind die 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 nicht angesagt. Wir rufen das Image ab und fügen einige Listener hinzu. Anschließend stoppt JavaScript die Ausführung, bis einer dieser Listener aufgerufen wird.

Im obigen Beispiel ist es möglich, dass die Ereignisse stattgefunden haben, bevor wir mit der Überwachung begonnen haben. Daher müssen wir dies mithilfe 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
});

Dabei werden keine Bilder erfasst, die fehlerhaft waren, bevor wir auf sie warten konnten. Leider bietet uns das DOM keine Möglichkeit, dies zu tun. Außerdem wird hier ein Bild geladen. Wenn wir wissen möchten, wann eine Reihe von Bildern geladen ist,

Ereignisse sind nicht immer die beste Möglichkeit,

Ereignisse eignen sich hervorragend für Dinge, die mehrmals über dasselbe Objekt wie keyup oder touchstart auftreten können. Bei diesen Ereignissen ist es für Sie nicht wichtig, was vor dem Anhängen des Listeners passiert ist. Wenn es jedoch um asynchrone Erfolge/Fehler geht, sollten Sie idealerweise etwa Folgendes erreichen:

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

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

Versprechen machen das, nur mit besserer Benennung. Wenn HTML-Bildelemente eine „ready“-Methode hätten, die ein Versprechen zurückgegeben hätte, 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
});

Promise ähneln Event-Listenern, mit Ausnahme von:

  • Ein Versprechen kann nur einmal erfolgreich sein oder scheitern. Ein Vorgang kann nicht zweimal erfolgreich sein oder fehlschlägt, weder kann er von Erfolg zu Misserfolg wechseln noch umgekehrt.
  • Wenn ein Promise erfolgreich war oder fehlgeschlagen ist und Sie später einen Erfolgs-/fehlgeschlagenen Callback hinzufügen, wird der richtige Callback aufgerufen, obwohl das Ereignis früher stattgefunden hat.

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

Promise-Terminologie

Der Proof of Domenic Denicola las den ersten Entwurf dieses Artikels und bewertete mich mit „F“ für die Terminologie. Er hat mich inhaftiert, zwingt mich, States and Fates 100 Mal zu kopieren, und schrieb einen besorgten Brief an meine Eltern. Trotzdem wird immer noch viel Terminologie verwechselt, aber hier sind die Grundlagen:

Ein Versprechen kann Folgendes sein:

  • erfüllt - Die Aktion im Zusammenhang mit dem Versprechen war erfolgreich
  • rejected (abgelehnt): Die Aktion im Zusammenhang mit dem Promise ist fehlgeschlagen.
  • Ausstehend – 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 versprechend ist und die Methode then hat. Dieser Begriff erinnert mich an den ehemaligen englischen Fußballmanager Terry Venables, deshalb werde ich ihn so wenig wie möglich verwenden.

Promise-Objekte kommen auch in JavaScript an!

Promise-Objekte gibt es schon seit einiger Zeit in Form von Bibliotheken, zum Beispiel:

Die oben genannten und die JavaScript-Versprechen haben ein gemeinsames, standardisiertes Verhalten, das als Promises/A+ bezeichnet wird. Wenn Sie ein jQuery-Nutzer sind, haben sie etwas Ähnliches, das als Deferreds bezeichnet wird. Verzögerte Vorgänge sind jedoch nicht Promise/A+-konform, wodurch sie deutlich anders und weniger nützlich sind. Achtung: jQuery hat auch einen Promise-Typ, aber dies ist nur ein Teil von „Deferred“ und hat die gleichen Probleme.

Obwohl Promise-Implementierungen einem standardisierten Verhalten folgen, unterscheiden sich ihre APIs insgesamt. JavaScript-Versprechen ähneln in der API denen von RSVP.js. So erstellst du ein Versprechen:

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 übernimmt ein Argument – einen Callback mit zwei Parametern – „auflösen“ und „ablehnen“. Führen Sie innerhalb des Callbacks etwas aus, das möglicherweise asynchron sein könnte. Rufen Sie dann "resolve" auf, wenn alles funktioniert hat. Andernfalls rufen Sie "Call Ablehnen" auf.

Wie throw im einfachen alten JavaScript ist es üblich, aber nicht erforderlich, um Elemente mit einem Fehlerobjekt abzulehnen. Fehlerobjekte haben den Vorteil, dass sie einen Stacktrace erfassen, wodurch Debugging-Tools noch hilfreicher werden.

So nutzt du 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 den Erfolg und ein weiteres Argument für den Fehler. Beide sind optional, sodass Sie nur für den erfolgreichen oder fehlgeschlagenen Fall einen Callback hinzufügen können.

JavaScript-Versprechen begannen im DOM als „Futures“, umbenannt in „Promise-Objekte“ und wurden schließlich in JavaScript verschoben. Es ist großartig, sie in JavaScript statt im DOM zu verwenden, da sie in Nicht-Browser-JS-Kontexten wie Node.js verfügbar sind. Ob sie diese in ihren Kern-APIs verwenden, ist eine andere Frage.

Obwohl es sich um eine JavaScript-Funktion handelt, schreckt das DOM gerne ab, sie zu verwenden. Tatsächlich verwenden alle neuen DOM APIs mit asynchronen Erfolgs-/Fehlermethoden Promise. Dies gilt bereits für die Kontingentverwaltung, Font Load-Ereignisse, ServiceWorker, Web MIDI, Streams und mehr.

Kompatibilität mit anderen Bibliotheken

Die JavaScript Promise API behandelt alles mit einer then()-Methode als Versprechung (oder thenable in Versprechen und sprechendem Seigh). Wenn Sie also eine Bibliothek verwenden, die ein Q Promise zurückgibt, ist das in Ordnung und funktioniert mit den neuen JavaScript-Promise-Werten.

Wie bereits erwähnt, sind die Deferreds von jQuery etwas ... nicht hilfreich. Zum Glück kannst du sie in Standardversprechen umwandeln. Dies lohnt sich so schnell wie möglich:

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

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

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

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

Während in JS alles außer dem ersten ignoriert wird:

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

Zum Glück ist das normalerweise das, was Sie wollen, oder zumindest haben Sie Zugriff auf das, was Sie wollen. Beachten Sie außerdem, dass jQuery nicht der Konvention folgt, Fehlerobjekte an Ablehnungen weiterzugeben.

Komplexer asynchroner Code

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

  1. Rotierendes Ladesymbol einblenden, um anzuzeigen, dass der Ladevorgang abgeschlossen ist
  2. Rufen Sie JSON-Daten für eine Geschichte ab, die den Titel und die URLs für jedes Kapitel enthalten.
  3. Titel zur Seite hinzufügen
  4. Alle Kapitel abrufen
  5. Geschichte zur Seite hinzufügen
  6. Rotierendes Ladesymbol anhalten

... sondern auch, wenn dabei etwas schiefgelaufen ist. An diesem Punkt sollten wir das rotierende Ladesymbol anhalten, damit es sich weiter dreht, schwächer wird und in eine andere Benutzeroberfläche stürzt.

Natürlich würden Sie kein JavaScript verwenden, um eine Story zu übermitteln, da die Bereitstellung als HTML ist schneller. Bei APIs tritt dieses Muster jedoch ziemlich häufig auf: Mehrere Datenabrufe und eine Aktion werden ausgeführt, wenn sie fertig sind.

Beginnen wir mit dem Abrufen von Daten aus dem Netzwerk:

XMLHttpRequest programmieren

Alte APIs werden aktualisiert, um Promise zu verwenden, wenn dies abwärtskompatibel ist. XMLHttpRequest ist ein Primkandidat. Schreiben wir jedoch 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();
  });
}

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. Je weniger ich die wütende Kamelschrift von XMLHttpRequest sehen muss, desto glücklicher wird mein Leben sein.

Verkettung

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

Werte umwandeln

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 als praktisches Beispiel Folgendes an:

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

Die Antwort ist JSON, aber wir empfangen sie derzeit im Nur-Text-Format. Wir können unsere get-Funktion so ändern, dass die JSON-responseType verwendet wird, aber wir könnten sie auch in der Promise-Land 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 Verknüpfung erstellen:

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

Die Funktion getJSON() lässt sich ganz einfach erstellen:

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

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

Asynchrone Aktionen in der Wiedergabeliste

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

Es ist magisch, wenn du etwas von einem then()-Callback zurückgibst. Wenn Sie einen Wert zurückgeben, wird die nächste then() mit diesem Wert aufgerufen. Wenn Sie jedoch etwas Versprechensähnliches zurückgeben, wartet die nächste then() darauf und wird nur aufgerufen, wenn dieses Versprechen erfüllt 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 eine Reihe von angeforderten URLs angibt. Dann fordern wir die erste dieser URLs an. An diesem Punkt wird verspricht, sich deutlich von einfachen Callback-Mustern abzuheben.

Sie können sogar eine Tastenkombination verwenden, um Kapitel aufzurufen:

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

Wir laden story.json erst herunter, wenn getChapter aufgerufen wird. Beim nächsten Mal wird aber getChapter aufgerufen, wird das Story Promise wiederverwendet, sodass story.json nur einmal abgerufen wird. Hurra!

Fehlerbehandlung

Wie bereits erwähnt, verwendet then() zwei Argumente, eines für Erfolg und eines für Misserfolg (oder „Versprechen“, „Erfüllen und ablehnen“):

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), aber es ist besser lesbar. Die beiden Codebeispiele oben 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 geringfügig, aber äußerst nützlich. Bei Promise-Ablehnungen wird mit einem Ablehnungs-Callback (oder catch(), da dies äquivalent ist) zum nächsten then() springen. Mit then(func1, func2) werden func1 oder func2 aufgerufen, nie beide. Mit then(func1).catch(func2) werden jedoch beide aufgerufen, wenn func1 ablehnt, da dies separate Schritte in der Kette sind. Gehen Sie dazu so vor:

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 „try/catch“-Vorgang in JavaScript sehr ähnlich. Fehler, die innerhalb eines „try“-Vorgangs auftreten, werden sofort zum catch()-Block weitergeleitet. Hier ist das Oben als Flussdiagramm (weil ich Flussdiagramme liebe):

Folgen Sie den blauen Linien für Versprechen, die erfüllt werden, oder den roten für Versprechen, die abgelehnt werden.

JavaScript-Ausnahmen und -Versprechungen

Ablehnungen erfolgen, wenn ein Promise explizit abgelehnt wird, aber auch implizit, wenn im Konstruktorrückruf 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);
})

Daher ist es nützlich, alle Promise-bezogenen Aufgaben innerhalb des Promise-Konstruktors-Callbacks auszuführen, damit Fehler automatisch abgefangen und zu Ablehnungen werden.

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

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

Bei unserer Story und den Kapiteln können wir die Funktion „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 ist offline), werden alle nachfolgenden erfolgreichen Callbacks übersprungen. Dazu gehört auch der Callback in getJSON(), mit dem versucht wird, die Antwort als JSON zu parsen. Außerdem wird der Callback übersprungen, mit dem der Seite Chapter1.html hinzugefügt wird. Stattdessen wird der Catch-Callback fortgesetzt. Daher wird der Seite „Fehler beim Anzeigen des Kapitels“ angezeigt, wenn eine der vorherigen Aktionen fehlgeschlagen ist.

Wie bei der Funktion „try/catch“ von JavaScript wird der Fehler abgefangen und der nachfolgende Code wird fortgesetzt, sodass das rotierende Ladesymbol immer ausgeblendet ist, was wir wollen. Das 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() auch einfach zu Logging-Zwecken ausführen, ohne den Fehler wiederherzustellen. Dazu müssen Sie den Fehler einfach noch einmal ausgeben. Das können 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 ein Kapitel abgerufen, möchten aber alle. Lassen Sie uns das schaffen.

Parallelität und Sequenzierung: das Beste aus beidem herausholen

Asynchron zu denken ist nicht einfach. Wenn Sie Schwierigkeiten haben, das Ziel zu erreichen, versuchen Sie, den Code synchron zu schreiben. 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! Das Programm ist jedoch synchron und sperrt den Browser, während etwas heruntergeladen wird. Damit die Vorgänge asynchron laufen, verwenden wir then(), damit die Dinge 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 die Kapitel-URLs in einer Schleife durchlaufen und 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, sodass unsere Kapitel in der Reihenfolge angezeigt werden, in der sie heruntergeladen werden. Das entspricht im Grunde der Schreibung von Pulp Fiction. Das ist kein Pulp Fiction, also fangen wir an.

Sequenz erstellen

Wir möchten unser chapterUrls-Array in eine Reihe von Promise 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() gesehen haben. Dabei wird ein Versprechen erstellt, das auf jeden Wert auflöst, den du ihm gibst. Wenn Sie eine Instanz von Promise übergeben, wird diese einfach zurückgegeben (Hinweis:Dies ist eine Änderung an der Spezifikation, die für einige Implementierungen noch nicht gilt). Wenn Sie die Antwort mit einem Versprechen ü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') erstellt sie ein Versprechen, das mit diesem Wert erfüllt wird. Wenn Sie sie wie oben ohne Wert aufrufen, wird sie mit „nicht definiert“ erfüllt.

Außerdem gibt es Promise.reject(val), das ein Versprechen erstellt, das mit dem Wert, den du ihm gibst, (oder nicht definiert) ablehnt.

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, benötigt jedoch die separate "Sequence"-Variable nicht. 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“ der Wert, der vom vorherigen Aufruf zurückgegeben wurde. array.reduce ist sehr nützlich, um ein Array auf einen einzelnen Wert herunterzuzählen, was in diesem Fall ein Versprechen ist.

Fassen wir 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 das war es auch schon, eine vollständig asynchrone Version. Aber das können wir noch besser. Derzeit wird unsere Seite so heruntergeladen:

Browser sind ziemlich gut darin, mehrere Elemente gleichzeitig herunterzuladen. Daher kann die Leistung sinken, wenn Kapitel nacheinander heruntergeladen werden. Wir möchten sie alle zur selben Zeit herunterladen und dann verarbeiten, wenn sie alle angekommen sind. Zum Glück gibt es dafür eine API:

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

Promise.all nimmt ein Array von Versprechen und erstellt ein Versprechen, das erfüllt wird, wenn alle Versprechen erfolgreich abgeschlossen wurden. Du erhältst ein Array von Ergebnissen (je nachdem, was die Versprechen erfüllt haben), in der gleichen Reihenfolge wie die von dir übergebenen Versprechen.

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 dauern als das Laden einzelner Elemente, und es ist weniger Code als beim ersten Versuch. Die Kapitel können in beliebiger Reihenfolge heruntergeladen werden, sie werden aber in der richtigen Reihenfolge auf dem Bildschirm angezeigt.

Wir können die wahrgenommene Leistung jedoch immer noch verbessern. Wenn Kapitel 1 kommt, sollten wir es zur Seite hinzufügen. So kann der Nutzer schon vor den Kapitel mit dem Lesen beginnen. Wenn Kapitel 3 erscheint, fügen wir es der Seite nicht hinzu, da der Nutzer möglicherweise nicht merkt, dass Kapitel 2 fehlt. Wenn Kapitel 2 beginnt, können wir Kapitel 2, 3 usw. hinzufügen.

Dazu rufen wir für alle unsere Kapitel gleichzeitig JSON 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';
})

Das war das Beste von beidem! Die Bereitstellung des gesamten Inhalts dauert genauso lange, aber der erste Teil des Inhalts ist früher.

In diesem einfachen Beispiel kommen alle Kapitel ungefähr zur gleichen Zeit an, aber der Vorteil, sich nacheinander anzusehen, wäre mit mehr, größeren Kapiteln übertrieben.

Wenn Sie oben mit Callbacks oder Ereignissen im Node.js-Stil arbeiten, ist der Code doppelt so groß, aber, was noch wichtiger ist, nicht so einfach zu befolgen. Aber in Kombination mit anderen ES6-Funktionen wird es noch einfacher.

Bonusrunde: erweiterte Funktionen

Seit ich diesen Artikel verfasst habe, haben sich die Möglichkeiten zur Verwendung von Promises erheblich erweitert. Seit Chrome 55 ist es mit asynchronen Funktionen möglich, Promise-basierter Code so zu schreiben, als wäre er synchron, ohne dass der Hauptthread blockiert wird. Weitere Informationen dazu finden Sie im my async functions article. In den wichtigsten Browsern werden sowohl Promise-Objekte als auch asynchrone Funktionen 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 dies Korrektur gelesen und Korrekturen/Empfehlungen vorgenommen haben.

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