JavaScript Promises: eine Einführung

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

Jake Archibald
Jake Archibald

Entwickler, bereitet euch auf einen entscheidenden Moment in der Geschichte der Webentwicklung vor.

[Trommelwirbel beginnt]

Promises sind in JavaScript angekommen!

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

Sie fallen derzeit in eine der folgenden Kategorien:

  • Die Leute um Sie herum jubeln, aber Sie sind sich nicht sicher, was das ganze Aufhebens soll. Vielleicht sind Sie sich nicht einmal sicher, was ein „Versprechen“ ist. Sie würden die Schultern zucken, aber das Gewicht des glitzernden Papiers lastet auf Ihren Schultern. Keine Sorge, es hat auch bei mir ewig gedauert, bis ich herausgefunden habe, warum ich mich um diese Dinge kümmern sollte. Am besten fangen Sie am Anfang an.
  • Sie schlagen in die Luft. Das wurde auch Zeit, oder? Sie haben diese Promise-Dinge schon einmal verwendet, aber es stört Sie, dass alle Implementierungen eine etwas andere API haben. Welche API wird für die offizielle JavaScript-Version verwendet? Am besten beginnen Sie mit der Terminologie.
  • Sie wussten das schon und lachen über diejenigen, die herumspringen, als wäre es eine Neuigkeit für sie. Genießen Sie einen Moment lang Ihr Gefühl der Überlegenheit und rufen Sie dann die API-Referenz auf.

Browserunterstützung und Polyfill

Unterstützte Browser

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

Quelle

Wenn Sie Browser, die keine vollständige Implementierung von Promises haben, an die Spezifikation anpassen oder Promises anderen Browsern und Node.js hinzufügen möchten, sehen Sie sich die Polyfill-Version (2 KB, komprimiert) an.

Aber was hat es damit eigentlich auf sich?

JavaScript ist ein einzelner Thread, d. h. zwei Script-Abschnitte können nicht gleichzeitig ausgeführt werden, sondern müssen nacheinander ausgeführt werden. In Browsern teilt sich JavaScript einen Thread mit vielen anderen Elementen, die sich von Browser zu Browser unterscheiden. Normalerweise befindet sich JavaScript jedoch in derselben Warteschlange wie das Zeichnen, das Aktualisieren von Stilen und die Verarbeitung von Nutzeraktionen wie das Hervorheben von Text und die Interaktion mit Formularelementen. Aktivitäten in einem dieser Bereiche verzögern die anderen.

Als Mensch sind Sie multi-threaded. Sie können mit mehreren Fingern tippen und gleichzeitig Auto fahren und telefonieren. Die einzige Blockierungsfunktion, mit der wir umgehen müssen, ist das Niesen. Dabei müssen alle aktuellen Aktivitäten für die Dauer des Niesens pausiert werden. Das ist ziemlich ärgerlich, vor allem, wenn Sie fahren und ein Gespräch führen möchten. Sie möchten keinen Code schreiben, der schnupft.

Wahrscheinlich haben Sie Ereignisse und Callbacks verwendet, um dieses Problem zu umgehen. 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 überhaupt kein Problem. Wir rufen das Bild ab, fügen einige Listener hinzu und JavaScript kann die Ausführung beenden, bis einer dieser Listener aufgerufen wird.

Leider ist es im Beispiel oben möglich, dass die Ereignisse bereits stattgefunden haben, bevor wir damit begonnen haben, darauf zu warten. Daher müssen wir das Problem 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
});

Bilder, die einen Fehler verursacht haben, bevor wir die Möglichkeit hatten, sie zu erfassen, werden dadurch nicht erfasst. Leider bietet das DOM keine Möglichkeit dazu. Außerdem wird hier nur ein Bild geladen. Noch komplizierter wird es, wenn wir wissen möchten, wann eine Reihe von Bildern geladen wurde.

Ereignisse sind nicht immer die beste Lösung

Ereignisse eignen sich hervorragend für Dinge, die mehrmals am selben Objekt auftreten können, z. B. keyup oder touchstart. Bei diesen Ereignissen spielt es keine Rolle, was vor dem Anhängen des Listeners passiert ist. Bei asynchronen Erfolgen/Fehlern sollte es aber idealerweise so aussehen:

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

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

Das ist auch bei Versprechen der Fall, aber mit einer besseren Benennung. Wenn HTML-Bildelemente eine „ready“-Methode hätten, die ein Versprechen 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 genommen sind Versprechen ein bisschen wie Ereignis-Listener, mit folgenden Ausnahmen:

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

Das ist äußerst nützlich für asynchrone Erfolgs-/Fehlschlagsmeldungen, da Sie weniger an der genauen Zeit interessiert sind, zu der etwas verfügbar wurde, sondern eher daran, auf das Ergebnis zu reagieren.

Terminologie für Promise

Domenic Denicola hat den ersten Entwurf dieses Artikels Korrektur gelesen und mir eine „F“ für die Terminologie gegeben. Er setzte mich in den Arrest, zwang mich, Staaten und Schicksale 100 Mal abzuschreiben, und schrieb meinen Eltern einen besorgten Brief. Trotzdem verwechsele ich immer noch viele Begriffe. Hier sind die Grundlagen:

Ein Versprechen kann:

  • fulfilled: Die Aktion im Zusammenhang mit der Zusicherung war erfolgreich.
  • rejected: Die Aktion im Zusammenhang mit dem Versprechen 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 Versprechen ähnelt, da es eine then-Methode hat. Dieser Begriff erinnert mich an den ehemaligen englischen Fußballmanager Terry Venables. Deshalb werde ich ihn so wenig wie möglich verwenden.

Promises in JavaScript

Versprechen gibt es schon länger in Form von Bibliotheken, z. B.:

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

Obwohl Promise-Implementierungen einem standardisierten Verhalten folgen, unterscheiden sich ihre APIs insgesamt. JavaScript-Promises ähneln in der API 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 nimmt ein Argument an, einen Callback mit zwei Parametern: „resolve“ und „reject“. Führe im Callback etwas aus, z. B. asynchron, und rufe dann „resolve“ auf, wenn alles funktioniert hat, andernfalls „reject“.

Wie bei throw in Plain Old JavaScript ist es üblich, aber nicht erforderlich, mit einem Fehlerobjekt abzulehnen. Der Vorteil von Fehlerobjekten besteht darin, dass sie einen Stack-Trace erfassen, wodurch die Debugging-Tools hilfreicher werden.

So verwenden Sie dieses Versprechen:

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

then() nimmt zwei Argumente an, einen Callback für den Erfolgsfall und einen weiteren für den Fehlerfall. Beides ist optional. Sie können also einen Callback nur für den Fall des Erfolgs oder des Fehlers hinzufügen.

JavaScript-Promises wurden im DOM als „Futures“ eingeführt, in „Promises“ umbenannt und schließlich in JavaScript verschoben. Es ist großartig, sie in JavaScript statt im DOM zu haben, 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 sie eine JavaScript-Funktion sind, werden sie im DOM verwendet. Tatsächlich werden bei allen neuen DOM-APIs mit asynchronen Erfolg-/Fehlermethoden Promises verwendet. Dies geschieht bereits bei Kontingentverwaltung, Schriftladeereignissen, ServiceWorker, Web MIDI und Streams.

Kompatibilität mit anderen Bibliotheken

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

Wie bereits erwähnt, sind die Deferreds von jQuery jedoch etwas… unpraktisch. Glücklicherweise können Sie sie in Standardversprechen umwandeln. Das sollten Sie so bald wie möglich tun:

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

Hier gibt die $.ajax von jQuery ein Deferred zurück. Da es eine then()-Methode hat, kann Promise.resolve() daraus ein JavaScript-Promise erstellen. Manchmal übergeben Deffereds jedoch mehrere Argumente an ihre Rückrufe, z. B.:

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

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

Bei JS-Versprechen werden alle außer dem ersten ignoriert:

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

Glücklicherweise ist das in der Regel das, was Sie möchten, oder Sie erhalten zumindest Zugriff auf das, was Sie möchten. Beachten Sie außerdem, dass jQuery nicht der Konvention folgt, Fehlerobjekte an Ablehnungen weiterzugeben.

Komplexer asynchroner Code wird einfacher

Okay, dann programmieren wir etwas. Nehmen wir an, wir möchten:

  1. Ein Ladesymbol anzeigen
  2. JSON-Daten für eine Geschichte abrufen, die den Titel und die URLs für jedes Kapitel enthalten
  3. Fügen Sie der Seite einen Titel hinzu.
  4. Jedes Kapitel abrufen
  5. Story zur Seite hinzufügen
  6. Ladeanimation beenden

… aber informieren Sie den Nutzer auch, wenn etwas schiefgelaufen ist. An dieser Stelle sollten wir auch den Spinner anhalten, da er sonst weiter rotiert, sich überschlägt und mit einer anderen Benutzeroberfläche kollidiert.

Natürlich würden Sie JavaScript nicht verwenden, um eine Story zu liefern, da das Bereitstellen als HTML schneller ist. Dieses Muster ist jedoch bei der Arbeit mit APIs ziemlich häufig: Mehrere Datenabrufe und dann eine Aktion, wenn alles erledigt ist.

Zuerst geht es um das Abrufen von Daten aus dem Netzwerk:

XMLHttpRequest als Promise verwenden

Alte APIs werden auf die Verwendung von Promises umgestellt, sofern dies abwärtskompatibel möglich ist. XMLHttpRequest ist ein geeigneter Kandidat, aber in der Zwischenzeit schreiben wir eine einfache Funktion, um eine GET-Anfrage zu senden:

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 können wir es verwenden:

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 das irritierende Camel-Case-Format von XMLHttpRequest sehen muss, desto glücklicher bin ich.

Verkettung

then() ist aber nicht das Ende der Geschichte. Sie können then-Aktionen verketten, um Werte zu transformieren oder zusätzliche asynchrone Aktionen nacheinander auszufü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
})

Als praktisches Beispiel gehen wir zurück zu:

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

Die Antwort ist JSON, wird aber derzeit als Nur-Text empfangen. Wir könnten unsere get-Funktion so ändern, dass sie das JSON-Objekt responseType verwendet. Wir könnten das Problem aber 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 annimmt 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);
})

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

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

getJSON() gibt weiterhin ein Versprechen zurück, das eine URL abruft und dann die Antwort als JSON-Objekt analysiert.

Asynchrone Aktionen in die Warteschlange stellen

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

Wenn du etwas von einem then()-Callback zurückgibst, ist das ein bisschen magisch. Wenn Sie einen Wert zurückgeben, wird die nächste then() mit diesem Wert aufgerufen. Wenn Sie jedoch etwas Promise-ähnliches zurückgeben, wartet der nächste then() darauf und wird nur aufgerufen, wenn dieses Promise erledigt ist (Erfolg/Fehlschlag). 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 liefert, die angefordert werden sollen. Anschließend wird die erste dieser URLs angefordert. Hier heben sich Versprechen wirklich von einfachen Rückrufmustern ab.

Du könntest sogar eine Tastenkombination für die Kapitel erstellen:

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 das Story-Versprechen jedoch wiederverwendet, sodass story.json nur einmal abgerufen wird. Yay Promises!

Fehlerbehandlung

Wie bereits erwähnt, nimmt then() zwei Argumente an, eines für den Erfolg und eines für den Fehler (oder „erfüllen“ und „ablehnen“ in der Sprache von Versprechen):

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

Sie können catch() auch für Folgendes verwenden:

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

catch() hat keine besondere Bedeutung, sondern ist nur eine Art „Zuckerguss“ für then(undefined, func). Es ist aber leichter zu lesen. Die beiden Codebeispiele oben verhalten sich nicht gleich. Das letzte Beispiel 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. Bei einer abgelehnten Zusicherung wird mit einem Ablehnungs-Callback zum nächsten then() (oder catch(), da dies äquivalent ist) gesprungen. Bei then(func1, func2) wird func1 oder func2 aufgerufen, niemals beides. Bei then(func1).catch(func2) werden jedoch beide aufgerufen, wenn func1 abgelehnt wird, da es sich um separate Schritte in der Kette handelt. Beachten Sie 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 Ablauf oben ähnelt dem normalen JavaScript-Try/Catch-Block. Fehler, die innerhalb eines „try“ 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 Versprechen oder den roten für abgelehnte Versprechen.

JavaScript-Ausnahmen und ‑Versprechen

Ablehnungen treten auf, wenn ein Versprechen explizit abgelehnt wird, aber auch implizit, wenn im Konstruktor-Callback ein Fehler geworfen 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 sinnvoll, alle mit Promises zusammenhängenden Aufgaben im Rückruf des Promise-Konstruktors auszuführen, damit Fehler automatisch erkannt und abgelehnt 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

Mit unserer Story und den Kapiteln können wir mit „catch“ einen Fehler für den Nutzer anzeigen:

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 Erfolgs-Callbacks übersprungen, einschließlich des Callbacks in getJSON(), der versucht, die Antwort als JSON zu parsen. Außerdem wird der Callback übersprungen, der der Seite „chapter1.html“ hinzufügt. Stattdessen wird der Rückgabewert an den catch-Callback übergeben. Wenn eine der vorherigen Aktionen fehlgeschlagen ist, wird der Seite „Kapitel konnte nicht angezeigt werden“ hinzugefügt.

Ähnlich wie bei JavaScripts try/catch wird der Fehler abgefangen und der nachfolgende Code wird fortgesetzt. Das bedeutet, dass der Ladebalken immer ausgeblendet ist, was wir auch möchten. 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 Protokollierungszwecken verwenden, ohne den Fehler zu beheben. Dazu müssen Sie den Fehler einfach noch einmal werfen. 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 konnten also ein Kapitel abrufen, aber wir benötigen alle. Lassen Sie uns das machen.

Parallelisierung und Sequenzierung: Das Beste aus beiden Welten

Es ist nicht einfach, asynchron zu denken. Wenn Sie nicht weiterkommen, 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. Aber es synchronisiert und sperrt den Browser, während Dinge heruntergeladen werden. Damit das asynchron funktioniert, verwenden wir then(), um die Aktionen nacheinander auszuführen.

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 durchgehen 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 werden unsere Kapitel in der Reihenfolge angezeigt, in der sie heruntergeladen werden. So wurde im Grunde Pulp Fiction geschrieben. Das ist kein Pulp Fiction, also lass uns das beheben.

Sequenz erstellen

Wir möchten unser chapterUrls-Array in eine Sequenz 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);
  });
})

Das ist das erste Mal, dass wir Promise.resolve() sehen. Dadurch wird eine Zusicherung erstellt, die zu dem Wert führt, den Sie angeben. Wenn Sie eine Instanz von Promise übergeben, wird sie einfach zurückgegeben. Hinweis:Dies ist eine Änderung an der Spezifikation, die einige Implementierungen noch nicht einhalten. Wenn Sie ihm etwas Versprechendes übergeben (mit einer then()-Methode), wird eine echte Promise erstellt, die auf dieselbe Weise erfüllt oder abgelehnt wird. Wenn Sie einen anderen Wert übergeben, z.B. Promise.resolve('Hello'), wird ein Versprechen erstellt, das mit diesem Wert erfüllt wird. Wenn Sie es wie oben beschrieben ohne Wert aufrufen, wird „undefiniert“ zurückgegeben.

Es gibt auch Promise.reject(val), mit dem ein Versprechen erstellt wird, das mit dem angegebenen Wert (oder „undefiniert“) abgelehnt wird.

Mit array.reduce können wir den Code oben 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())

Dies entspricht dem vorherigen Beispiel, erfordert aber keine separate Variable „sequence“. Der Rückruf für die Reduzierung wird für jedes Element im Array aufgerufen. „sequence“ ist beim ersten Mal Promise.resolve(), aber bei den restlichen Aufrufen ist „sequence“ das, was wir vom vorherigen Aufruf zurückgegeben haben. array.reduce ist sehr nützlich, um ein Array auf einen einzelnen Wert zurückzuführen, in diesem Fall ein Versprechen.

Zusammenfassend:

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 ist es, eine vollständig asynchrone Version der Synchronisationsversion. Aber wir können noch besser sein. Derzeit wird unsere Seite so heruntergeladen:

Browser können mehrere Dinge gleichzeitig herunterladen. Wenn wir die Kapitel nacheinander herunterladen, geht also Leistung verloren. Wir möchten sie alle gleichzeitig herunterladen und dann verarbeiten, sobald sie alle angekommen sind. Glücklicherweise gibt es dafür eine API:

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

Promise.all nimmt eine Reihe von Versprechen entgegen und erstellt ein Versprechen, das erfüllt wird, wenn alle erfolgreich abgeschlossen wurden. Sie erhalten ein Array von Ergebnissen (unabhängig davon, für welche Versprechen sie erfüllt wurden) in der Reihenfolge, in der Sie die Versprechen übergeben haben.

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

Wir können die wahrgenommene Leistung jedoch noch verbessern. Wenn Kapitel 1 da ist, sollten wir es der Seite hinzufügen. So kann der Nutzer mit dem Lesen beginnen, bevor der Rest der Kapitel verfügbar ist. Wenn Kapitel 3 fertig ist, fügen wir es der Seite nicht hinzu, da der Nutzer möglicherweise nicht merkt, 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';
})

So, das war's auch schon. Es dauert genauso lange, bis alle Inhalte gesendet werden, aber der Nutzer erhält die ersten Inhalte früher.

In diesem einfachen Beispiel werden alle Kapitel ungefähr zur selben Zeit empfangen. Bei mehr und größeren Kapiteln ist der Vorteil, sie einzeln anzuzeigen, jedoch noch größer.

Wenn Sie das mit Callbacks oder Ereignissen im Node.js-Stil tun, ist der Code etwa doppelt so lang, aber vor allem nicht so leicht zu verstehen. Das ist jedoch noch nicht alles, was es über Versprechen zu sagen gibt. In Kombination mit anderen ES6-Funktionen sind sie noch einfacher.

Bonusrunde: Erweiterte Funktionen

Seit ich diesen Artikel ursprünglich geschrieben habe, hat sich die Möglichkeit, Promises zu verwenden, stark erweitert. Seit Chrome 55 können mithilfe von asynchronen Funktionen Promise-basierter Code so geschrieben werden, als wäre er synchron, ohne den Hauptthread zu blockieren. Weitere Informationen dazu findest du in meinem Artikel zu asynchronen Funktionen. Sowohl Promises als auch asynchrone Funktionen werden in den gängigen Browsern weithin unterstützt. Weitere Informationen finden Sie in den MDN-Referenzen zu Promises und async-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 bzw. Empfehlungen eingefügt haben.

Außerdem möchten wir uns bei Mathias Bynens bedanken, der verschiedene Teile des Artikels aktualisiert hat.