Benutzer:Rolf b/JavaScript Tutorials Promises

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

Einführung

Etliche Vorgänge, die in einem JavaScript-Programm ausgeführt werden, benötigen eine wahrnehmbare Zeit, um ausgeführt zu werden. Dies sind vor allem Zugriffe auf externe Ressourcen (fetch), aber auch Operationen aus dem File API oder Konvertierungsfunktionen in einem Blob. Da das JavaScript-Programmiermodell eine streng sequenzielle Ausführung der anstehenden Aufgaben (Tasks) vorsieht und die Verarbeitung von Oberflächenevents (wie z. B. click) mit der Aktualisierung der Browseranzeige verknüpft ist, würde das Warten auf eine länger laufende Operation den Browser blockieren.

Deswegen werden solche Vorgänge von den entsprechenden Browser APIs nicht direkt ausgeführt. Statt dessen nutzt der Browser die Fähigkeit moderner Prozessoren und Betriebssysteme, mehrere Dinge gleichzeitig zu tun (Nebenläufigkeit oder Multithreading[1]), leitet die gewünschte Operation lediglich ein und führt sie dann getrennt vom JavaScript-Programm aus. Um das Ergebnis der Operation verarbeiten zu können, ist ein Mechanismus erforderlich, mit dem der Browser dieses Ergebnis für JavaScript bereitstellen kann.

Grundsätzlich ist dieser Mechanismus immer der gleiche: Als Programmierer schreiben wir eine Funktion, und teilen dem Browser mit, dass er diese Funktion aufrufen soll, wenn die Operation fertiggestellt wurde. Eine solche Funktion nennt man Callback-Funktion, manchmal auch Eventlistener. Einen einfachen Fall eines solchen asynchronen Callbacks kennen Sie beispielsweise aus der setTimeout Funktion. Ein komplexerer Fall ist der XMLHttpRequest, der sieben Events anbietet, um den Ablauf eines Ajax-Requests zu überwachen.

Promises bieten eine allgemeingültige Technik an, den Programmablauf mit nebenläufigen Hintergrundoperationen zu synchronisieren. Sie können Promises mit JavaScript sowohl konsumieren (d.h. auf Ergebnisse warten) wie auch selbst erzeugen. Die Fragestellung, wie man eine Operation asynchron im Hintergrund durchführt, lösen sie allerdings nicht. Dafür gibt es andere Mechanismen, wie zum Beispiel Web Worker.

Exkurs: Die Ablaufsteuerung in Javascript

Wenn Sie wissen, was JavaScript mit Tasks und Microtasks tut, können Sie diesen Exkurs überspringen.

Es gibt zwei Gründe für den Browser, JavaScript-Code auszuführen. Das ist zum einen das Laden eines Scripts. Die Code-Teile, die nicht in Funktionen stehen, werden nach dem Laden sofort ausgeführt. Der zweite und weitaus häufigere Grund ist das Eintreten eines Ereignisses, für das der Programmierer festgelegt hat, dass daraufhin eine bestimmte JavaScript-Funktion aufgerufen werden soll.

Es gibt viele mögliche Quellen für Ereignisse (Events): Benutzeraktionen, Timer, Eintreffen einer Antwort von einer Serveranfrage, und mehr. Für jedes Ereignis erstellt der Browser eine Aufgabe (Task) und stellt sie in eine Warteschlange (die Task-Queue). Um die Warteschlange abzuarbeiten, durchläuft der Browser die sogenannte Ereignisschleife. Sie entnimmt der Warteschlange einen Task und führt die dafür nötigen Aktionen aus (teils browserintern, teils durch Aufruf von JavaScript-Funktionen). Danach muss sie prüfen, ob die ausgeführten Aktionen das DOM verändert haben. Wenn ja, muss das Layout der Seite neu berechnet und die Darstellung auf den Bildschirm übertragen werden. Um Flackern zu vermeiden, wird diese Übertragung mit der Bildwiederholrate des Monitors synchronisiert.

An dieser Stelle befindet sich der Flaschenhals der Ereignisausführung: viele Bildschirme laufen mit lediglich 60 oder 100 Hertz (Framerate), was die Anzahl von möglichen Ereignisverarbeitungen pro Sekunde auf 60 oder 100 begrenzt. Um diesen Flaschenhals zu umgehen, existiert eine weitere Warteschlange für kleine Zusatzaufgaben: die Microtask-Queue. Dort können vom Browser oder auch von JavaScript (mittels queueMicrotask()) Aufgaben hinterlegt werden, die nach der eigentlichen Eventverarbeitung, aber noch vor der Layout-Aktualisierung durchgeführt werden sollen. Der Begriff Microtask ist hier ernst zu nehmen: wenn die Verarbeitung eines Events und der Microtasks länger dauert als ein Frame auf dem Monitor, besteht die Gefahr, dass sich Events in der Task-Queue aufstauen und die Browserbedienung träge wird. Der Browser arbeitet im Anschluss an die Event-Verarbeitung die Microtask-Queue immer vollständig ab. Sollten Sie in einem Microtask ständig neue Microtasks erzeugen, ist der Browser blockiert.

Microtasks sind an dieser Stelle deshalb relevant, weil die Ablaufsteuerung mit Promises vollständig darauf basiert. Nützlich an Microtasks ist, dass man auf diese Weise Vorgänge sammeln kann, die in einer definierten Reihenfolge ausgeführt werden. Wenn beispielsweise nicht klar ist, ob bestimmte Daten erst geholt werden müssen oder sie bereits intern in einem Cache liegen, kann das Steuern der Verarbeitung über die Microtask-Queue dafür sorgen, dass die Ausführungsreihenfolge immer gleich bleibt.

Der Lebenslauf eines Promises

Wenn ein Promise erzeugt wird, wird ihm eine Funktion übergeben: der Executor. Diese Funktion hat die Aufgabe, die gewünschte asynchrone Operation einzuleiten, Eventhandler für die Überwachung der Operation bereitzuhalten und zum Abschluss das Ergebnis bereitzustellen. Wie das genau geht, besprechen wir später.

Nachdem der Executor alles getan hat, was zur Einleitung der Operation nötig ist, kehrt er zum Promise-Konstruktor zurück und das Promise-Objekt ist bereit. Es kennt nun einen von drei möglichen Zuständen einnehmen: pending (schwebend), fulfilled (erfüllt) und rejected (zurückgewiesen). Die Zustände fulfilled und rejected fasst man auch unter dem Oberbegriff settled (festgelegt) zusammen. Hinzu kommt ein Wartezustand resolved, der sich ergeben kann, wenn Promises geschachtelt werden. Dazu später mehr.

Promise States.svg

Der Normalfall ist, dass ein Promise nach dem Ende der Executor-Funktion im Zustand pending ist. Es ist aber auch möglich, Promises so zu erzeugen, dass sie sofort fulfilled oder rejected sind, so etwas kann beispielsweise bei einem Cache-System der Fall sein. Ist die gesuchte Ressource noch nicht im Cache, muss sie erst geladen werden und das Promise ist pending. Ist sie bereits im Cache, steht sie sofort bereit und das Promise kann sofort fulfilled sein. Ist der Versuch, sie zu laden, bereits einmal gescheitert, kann sich der Cache das merken und das Promise kann sofort rejected sein.

Wir betrachten erst einmal den Normalfall. Das Promise ist nach seiner Erstellung pending, und irgendwann endet die Operation – oder sie bricht aus irgendeinem Grund ab – daraufhin wird das Promise vom Executor, der den asynchronen Vorgang steuert, auf fulfilled oder rejected festgelegt. Dafür verwendet er die Funktionen resolve() und reject(), die er vom Promise-Konstruktor zur Verfügung gestellt bekommt.

Diese beiden Funktionen können nur im pending-Zustand verwendet werden. Sie legen einen neuen Zustand des Promises fest und setzen auch seinen Wert – im Falle von fulfilled das Ergebnis der asynchronen Operation und im Falle von rejected der Grund für das Scheitern der Operation. Eine genauere Festlegung, wie ein solcher Grund auszusehen hat, ist nicht spezifiziert, man muss also für jedes auf Promises basierendem API wissen, wie der Erzeuger des Promise Fehlergründe übermittelt.

Der Aufruf der resolve-Funktion kann zu einem Zwischenzustand resolved führen, in dem ein Promise A zwar nicht mehr pending ist, aber auch noch nicht endgültig festgelegt. Das passiert dann, wenn die Executor-Funktion an die resolve-Funktion ein anderes Promise übergibt. In diesem Fall wartet A darauf, bis B in einem der settled-Zustände ist, und übernimmt dann den Zustand und den Wert von Promise B.

Das folgende Stabdiagramm zeigt, wie sich die Arbeit mit einem Promise zwischen Hauptprogramm, Executor und Hintergrundablauf verteilt.

Promise Ablauf.svg

Programmieren mit Promises

„Promise-Objekt“ - Konstruktor oder Konstrukt?

Das JavaScript-Objektkonzept bringt eine sprachliche Mehrdeutigkeit mit sich. Es gibt die globale Konstruktorfunktion Promise, mit der Sie Objekte erzeugen, die Promises darstellen und Methoden von Promise.prototype erben. Natürlich ist die Konstruktorfunktion auch selbst wieder ein Objekt, so dass der Begriff "Promise-Objekt" wahlweise die Konstruktorfunktion meinen könnte, oder die Objekte, die damit erzeugt werden können.

Im folgenden Text soll mit „Promise“ oder „Promise-Objekt“ immer das konstruierte Objekt gemeint sein. Wenn es um die Konstruktorfunktion Promise geht, soll das ausdrücklich gesagt werden.

Der leichteren Lesbarkeit sollen die Methoden, die ein Promise-Objekt von Promise.prototype erbt, auch einfach nur als Methoden des Promise-Objekts bezeichnet werden, statt jedesmal den Vererbungsweg zu beschreiben.

Ein vorhandenes Promise verwenden

Die erste Frage, die sich stellt, ist: Wo bekomme ich ein Promise her? Es gibt etliche APIs im Browser, die Promises liefern. Um uns an dieser Stelle nicht mit konkreten APIs beschäftigen zu müssen, wollen wir annehmen, es gäbe eine Funktion warte(), der man eine Wartezeit in Sekunden übergibt und die ein Promise zurückliefert, das sich nach Ablauf der Wartezeit erfüllt. Wie diese Funktion aussieht, werden wir gleich zeigen, aber erst einmal wollen wir so tun, als wäre sie ein Teil von JavaScript.

Die Idee ist, dass der Aufruf warte(2) ein Promise liefert, das sich nach 2 Sekunden erfüllt. Um darauf zu reagieren, müssen wir wie bei den klassischen Event-Schnittstellen Callback-Funktionen bereitstellen, die nach Festlegung des Zustandes aufzurufen sind. Die Besonderheit ist: Ein Promise hat aber nur eine Liste von Callbacks, sondern gleich zwei:

die fulfilled Liste
diese Callbacks werden ausgeführt, wenn der Zustand des Promise auf fulfilled festgelegt wird. Sie erhalten den Wert des Promise als Argument übergeben.
die rejected Liste
diese Callbacks werden ausgeführt, wenn der Zustand des Promise auf rejected festgelegt wird. Auch sie erhalten den Wert des Promise als Argument übergeben.
Beachten Sie: Die hinterlegten Callbacks werden nicht unmittelbar ausgeführt, wenn der Zustand des Promise festgelegt wird. Statt dessen wird pro Callback ein Microtask erzeugt, der für Aufruf und Argumentübergabe sorgt. Die Reaktion auf die Zustandsänderung erfolgt also frühestens, nachdem der gerade laufende Task oder Microtask beendet ist.

Um Callbacks in diese Listen einzutragen, verfügen Promise-Objekte über drei Methoden (die sie von Promise.prototype erben):

  • then(onFulfilled, onRejected)
  • catch(onRejected)
  • finally(onCompleted)

Die then Methode kann je einen Callback in die fulfilled und in die rejected Liste eintragen. Beide Argumente sind optional; wenn Sie etwas anderes als eine Funktion übergeben, wird das Argument ignoriert und kein Eintrag in der entsprechenden Liste vorgenommen.

Die Methode catch macht nichts anderes, als then(undefined, onRejected) aufzurufen.

Für finally() gibt es keine eigene Liste. Statt dessen wird der onCompleted-Callback in die fulfilled- und rejected-Liste eingetragen, so dass er für erfüllte und zurückgewiesene Promises aufgerufen wird. Er bekommt allerdings keinen Parameter übergeben.

Prinzipiell könnten also alle Promise-Operationen mit Hilfe von then durchgeführt werden. Die catch und finally-Funktionen bilden eine Kruste aus API-Zucker, durch die eine Promise-Aufrufkette ein ähnliches Aussehen wie ein try und catch Konstrukt erhält.

Schauen wir uns als erstes Beispiel an, wie das mit der oben angedeuteten warte-Funktion gemacht wird.

Reaktion auf erfülltes Promise mit then
warte(2)
.then(function() {
   console.log("Wartezeit abgelaufen!");
});
console.log("Ich warte");

Es ist üblich, den Aufruf von .then, .catch und .finally auf eine neue Zeile zu schreiben. Wenn Sie dieses Beispiel ausführten (was wegen der fehlenden warte-Funktion noch nicht geht), dann sehen Sie zuerst die "Ich warte" Ausgabe. Zwei Sekunden später erfolgt dann das "Wartezeit abgelaufen". Die warte-Funktion hält den Programmablauf also nicht an. Was hinter dem then-Aufruf steht, wird sofort ausgeführt, nachdem das Promise erstellt wurde. Alles, was vom Ablauf der Wartezeit abhängig ist, muss in den then-Callback hinein.

Solange das Promise keinen settled-Zustand hat, bleiben die registrierten Callback-Funktionen in ihren Listen und werden nicht aufgerufen. In dem Moment, wo der Zustand des Promise festgelegt wird, wird die fulfilled- oder die rejected-Liste benutzt, um pro registrierter Funktion einen Microtask zu erzeugen und in der JavaScript-Engine zu hinterlegen. Danach werden die Listeninhalte nicht mehr benötigt und verworfen.

Beachten Sie: Sie können then, catch und finally auch dann aufrufen, wenn der Promise-Zustand bereits festgelegt ist. In diesem Fall werden die übergebenen Callback-Funktionen nicht in eine der Listen eingetragen, sondern sofort ein Microtask daraus erzeugt. Aber ein synchroner Aufruf der Callbacks findet nie statt.

Das Programmiermodell entspricht dabei der Idee der Fehlerbehandlung mit try und catch. Die normale Verarbeitung findet bei der Promise-Erstellung und im then-Callback statt, bei einem Fehler springt die Ausführung in den catch-Block und zum Abschluss kann in einem finally-Block noch aufgeräumt werden.

Bevor wir zu weiteren Beispielen kommen, wie man then, catch und finally verwendet, wollen wir uns noch an Hand der warte-Funktion anschauen, wie man ein Promise-Objekt konstruiert.

Promises selbst erzeugen

Wenn Sie eine asynchrone Operation mit einem eigenen Promise steuern möchten, benötigen Sie die Executor-Funktion, die den Vorgang einleitet, sich auf die Benachrichtigungen zu seinem Fortschritt registriert und zum Abschluss den Zustand des Promise festlegt. Diese Funktion übergeben Sie der Promise-Konstruktorfunktion als Argument.

Ein ganz einfacher Fall für einen solche asynchrone Operation ein mit setTimeout gebildeter Timer. Wie schon angedeutet, tut die vorhin erwähnte warte-Funktion nicht weiter, als einen solchen Timer hinter einem Promise zu verstecken. Außer der Wartezeit soll sie auch noch einen Ergebniswert bekommen, den sie als Promise-Ergebnis bereitstellen kann.

Beispiel 1: Ein Promise, das setTimeout kapselt ansehen …
function warte(sekunden, wert) {

   function warteExecutor(resolve, reject) {
      setTimeout(() => resolve(wert), sekunden * 1000);
   }

   return new Promise(warteExecutor);
}

warte(1, "Welt")
.then( (ergebnis) => console.log(ergebnis) );

console.log("Hallo");

Die gezeigte warte-Funktion ist umständlicher geschrieben als nötig, um die einzelnen Bausteine besser voneinander abgrenzen zu können. Eine kompakte Einzeiler-Version zeigen wir im Anschluss. Schauen wir uns Schritt für Schritt an, was in diesem Beispiel passiert.

Der Wartewunsch

Was die letzten drei Zeilen tun, wissen wir bereits. Die warte-Funktion wird aufgerufen und erhält die Wartezeit sowie den Ergebniswert. Daraus erstellt sie ein Promise, für das wir mit then einen fulfilled-Handler registrieren.

Den asynchronen Vorgang einleiten

Die warte-Funktion besteht aus zwei Teilen. Zunächst wird eine lokale Funktion warteExecutor definiert, die als Executor für das Promise dienen soll. Danach wird ein neues Promise-Objekt erzeugt. Der Promise-Konstruktor bekommt die Executor-Funktion als Argument übergeben.

Der Promise-Konstruktor bereitet das Promise vor und ruft dann den Executor auf. Dabei übergibt er dem Executor zwei Funktionen als Argumente: resolve und reject. Wozu sie da sind, besprechen wir, wenn der Timer abgelaufen ist.

Der Executor muss den asynchronen Vorgang einleiten. Für unser Timer-Beispiel ruft er setTimeout auf und übergibt eine kleine Pfeilfunktion als Callback, die nach Ablauf der Wartezeit ausgeführt wird. Für die Wartezeit wird der sekunden-Parameter der warte-Funktion verwendet.

Die Callback-Funktion greift ebenfalls auf ein Argument von warte zu. Das ist möglich, obwohl sie erst dann ausgeführt wird, wenn warte längst beendet ist. Wenn Sie sich wundern, weshalb das funktioniert, lesen Sie bitte den Abschnitt zu Closures im Scope-Artikel.

Unser Executor hat damit eine asynchrone Operation gestartet und dafür gesorgt, dass er über ihr Ende – den Ablauf des Timers – informiert wird. Damit endet er, und das fertige Promise-Objekt wird mit return an den Aufrufer von warte zurückgegeben.

Warten auf die Wunscherfüllung

Der Aufrufer von warte hat nun ein Promise-Objekt zur Verfügung, das ihm verspricht, nach Ablauf einer gewissen Zeit Bescheid zu geben und einen Wert zur Verfügung zu stellen. Um diesen Bescheid empfangen zu können, muss der Aufrufer eine Funktion in die fulfilled-Liste des Promise eintragen. Dazu verwendet er die Methode then des Promise-Objekts. Das Beispiel übergibt als ersten Parameter eine einfache Pfeilfunktion, die den Promise-Wert auf die Konsole schreibt. Der zweite Parameter von then wird weggelassen, der rejected-Zustand kommt in unserem Beispiel nicht vor und braucht darum auch keine Behandlung.

Ganz gleich, ob das Promise beim Aufruf von then noch schwebend oder schon festgelegt war - die damit gespeicherten Callbacks werden als Microtask ausgeführt und stehen damit definitiv erst einmal in einer Warteschlange. Deswegen wird die im Hauptprogramm folgende Ausgabe von "Hallo" das erste sein, was auf die Konsole geschrieben wird.

Der Timer meldet sich

Nach der angegeben Wartezeit läuft der Timer ab und stellt einen neuen JavaScript-Task ein, der die an setTimeout übergebene Funktion aufruft. Ihre Aufgabe ist es, den Schwebezustand des Promise-Objekts zu beenden, und dafür benötigt sie die Funktionen, die der Executor-Funktion beim Aufruf mitgegeben wurden. Die Executor-Funktion ist zwar bereits zu Ende, aber weil die Callback-Funktion für den Timer im Executor definiert wurde, hat sie den Scope der Executor-Funktion und damit ihre Parameter und lokalen Variablen noch als Closure zur Verfügung, genauso wie den Scope des warte-Aufrufs, der diesen Executor in ein Promise gesteckt hat.

Die erste Funktion, die der Executor bekommen hat, nennt sich resolve und dient dazu, das Promise in den Zustand resolved oder fulfilled zu schalten. resolve nimmt einen Wert entgegen, der als Wert des Promise gespeichert und später dem onFulfilled-Callback zur Verfügung gestellt wird. Dieser Wert kann eine beliebige Herkunft haben, in diesem Beispiel ist es einfach der zweite Parameter der warte-Funktion.

Die zweite Funktion heißt reject und schaltet das Promise auf den Zustand rejected. Auch hier kann ein Wert übergeben werden, der an den onRejected-Callback weitergereicht wird. Da ein einfacher Warte-Timer nicht fehlschlägt, verwendet das Beispiel die reject-Funktion nicht.

Unser Beispiel übergibt den wert-Parameter des warte-Aufrufs an resolve. Das Promise wird mit diesem Wert erfüllt und überträgt den Inhalt der fulfilled-Callbackliste in Microtasks. Sobald der Task fertig ist, der den Aufruf von resolve auslöste, kommen diese Microtasks zur Ausführung, der vom Hauptprogramm registrierte fulfilled-Callback wird aufgerufen und gibt "Welt" auf die Konsole aus.

Geht das auch kompakter?

Die erste Formulierung von warte ist, wie gesagt, umständlich. Den warteExecutor kann man als Pfeilfunktion schreiben und direkt an den Promise-Konstruktor übergeben. Sodann verfügt die setTimeout Funktion über die Möglichkeit, ab dem dritten Parameter Argumente festzulegen, der der Timeout-Funktion übergeben werden sollen. Nutzt man das, braucht man keine eigene Funktion als Timer-Callback, sondern kann die resolve-Funktion direkt an setTimeout übergeben. Das sieht dann so aus:

Beispiel: warte - kompakt
function warte(sekunden, ergebnis) {

   return new Promise(
      (resolve, reject) => setTimeout(resolve, 1000 * sekunden, ergebnis)
   );
}

Schauen Sie sich bei Unklarkeiten zum Verhalten von setTimeout einfach noch einmal die Dokumentation dieser Funktion an.

Woher kennen resolve und reject ihr Promise?

Wenn Sie schon erste Gehversuche mit objektorientierter Programmierung in JavaScript gemacht haben, dann wissen Sie, dass man eine Methode nicht einfach aus dem Kontext ihres Objekts herausziehen kann, sondern dass man das Bezugsobjekt beim Aufruf immer mit angeben muss. Aber warum passiert das bei resolve und reject nicht? Der Grund ist, dass diese Funktionen vom Promise-Konstruktor an das Promise gebunden werden und den Bezug zu ihrem Promise damit „eingebrannt“ bekommen.

Ein genauerer Blick auf then

Im Einführungsbeispiel wurde ein Promise erzeugt und darauf ein fulfilled-Callback registriert. Nennen wir es einmal „Promise A“, und es wird gleich eine Menge Nachwuchs bekommen. Wirft man einen Blick auf die Dokumentation von Promise.prototype.then, stellt man fest, dass then ein Promise zurückgibt. Hierbei handelt es sich nicht um das „Promise A“, sondern um ein neues Promise – „Promise B“.

Das Gleiche gilt übrigens für die catch-Methode, denn sie ist ja nur ein Synonym für then(undefined, onRejected).

Was mit „Promise B“ geschieht, hängt davon ab, ob beim then bzw. catch-Aufruf eine Callback-Funktion registiert wurde und was diese Funktion tut. Sobald „Promise A“ festgelegt wird, wird der Aufruf des fullfilled- bzw. rejected-Callbacks in die Microtask-Queue gestellt. Diese Callback-Funktion kann - fehlen (z.B. beim Aufruf von </code>then(onFulfilled)</code> fehlt der rejected-callback - nichts, einen primitiven Wert oder ein Objekt (kein Promise) zurückgeben - ein neues Promise zurückgegeben - einen Fehler signalisieren (mittels throw).

die Callback-Funktion fehlt
B folgt dem Zustand und dem Wert von A. Das heißt:
  • Solange A im Zustand pending ist, ist es auch B
  • Wird A festgelegt (oder ist es das schon von Anfang an), wird auch B festgelegt, und der Wert, der A zugewiesen wird, ist auch in B verfügbar.
Rückgabe eines primitiven Wertes oder eines Objekts (kein Promise)
B wird auf fulfilled festlegt und erhält diesen Wert.
Rückgabe eines neuen „Promise C“
B wird auf resolved festgelegt (der im Bild „Zustandsübergänge“ gezeigte Zustand zwischen pending und settled) und folgt nun dem Zustand und dem Wert von C.
Signalisieren eines Fehlers throw X
B wird auf den Zustand rejected festgelegt und erhält X als Wert.

Das „Promise B“ muss nicht das Ende sein. Sie können für dieses Promise wiederum fulfilled und rejected-Handler registrieren und so eine ganze Promise-Kette erzeugen.

Schauen wir uns das in ein paar Beispielen an. Sie verwenden die warte-Funktion aus dem vorherigen Beispiel.

Beispiel 2: Rückgabe von undefined (bzw. keine Rückgabe) ansehen …
warte(1, "Selfhtml")                          // Erzeugt Promise A
.then(wert1 => {                              // then registriert fulfilled-Callback auf Promise A
         console.log("1. Hallo ", wert1);        
      })                                      // ...und gibt Promise B zurück!
.then(wert2 => {                              // then registriert fulfilled-Callback auf Promise B
         console.log("2. Hallo ", wert2);
      });
Promise chains 1.svg

Promise A wird durch den resolve-Aufruf im warte-Executor erfüllt und hat damit "Selfhtml" als Wert. Dieser Wert wird dem onFulfilled-Callback im ersten then als wert1 übergeben, der daraufhin "1. Hallo Selfhtml" ausgibt.

Allerdings enthält dieser Callback kein return-Statement – wwas das Gleiche ist wie return undefined;. Aus diesem Grund wird Promise B mit dem Wert undefined erfüllt und dieser Wert wird als wert2 an den onFulfilled-Callback des zweiten then übergeben. Deshalb wird dort "2. Hallo undefined" ausgegeben. Das im Bild gezeigte resolve haben wir nicht selbst programmiert, das ist etwas, das JavaScript nach dem Aufruf eines onFulfilled oder onRejected Callbacks automatisch tut.

Man sieht es dem JavaScript nicht an, aber unser Beispielcode erzeugt noch ein drittes Promise C. Auf diesem Promise werden keine Callbacks installiert, und das bedeutet, dass es nach dem Aufruf der zweiten Callback-Funktion automatisch von JavaScript beseitigt wird.

Beispiel 3: Rückgabe einer Zeichenkette ansehen …
warte(1, "Selfhtml")                          // Erzeugt Promise A
.then(wert1 => {                              // Registriert then-Callback auf Promise A
         console.log("1. Hallo ", wert1);        
         return "*" + wert1 + "*";
      })                                      // ...und gibt Promise B zurück!
.then(wert2 => {                              // Registriert then-Callback auf Promise B
         console.log("2. Hallo ", wert2);
      });
Promise chains 2.svg

Wir fügen nun dem ersten onFulfilled-Handler eine Rückgabe hinzu. Sie besteht darin, dass der in wert1 erhaltene Wert mit Sternchen dekoriert wird.

Der Unterschied zum vorigen Beispiel besteht nun darin, dass „Promise B“ beim Übergang auf fulfilled den zurückgegebenen Wert übernimmt, und ihn dem zweiten onFulfilled-Handler, der im zweiten then angegeben wurde, übergibt. Wir sehen deshalb als zweite Zeile ein "2. Hallo *Selfhtml*".

Für die nächste Variante verwenden wir noch einmal die warte-Funktion, um im ersten onFulfilled-Handler ein weiteres Promise zu erzeugen und zurückzugeben.

Beispiel 4: Rückgabe eines Promise ansehen …
warte(1, "Selfhtml")                          // Erzeugt Promise A
.then(wert1 => {                              // Registriert then-Callback auf Promise A
         console.log("Hallo ", wert1);        
         return warte(1, "*" + wert1 + "*");  // onFulfilled-Callback gibt Promise C zurück
      })                                      // then gibt Promise B zurück!
.then(wert2 => {                              // Registriert then-Callback auf Promise B
         console.log("2. Hallo an ", wert2);  // Zeigt den Wert von Promise C
      });

Der dekorierte String soll der Ergebniswert von „Promise C“ sein. „Promise B“ sieht, dass es ein Promise zurückbekommt und bindet seinen Erfüllungszustand daran. Der zweite onFulfilled-Handler wird erst nach einer weiteren Sekunde Wartezeit aufgerufen.

Beachten Sie, dass für das vom zweiten warte-Aufruf erzeugte „Promise C“ keine Callbacks registriert werden. Das wäre auch möglich – wenn auch schon arg unübersichtlich – aber es ist nicht nötig, weil „Promise B“ das Ergebnis von C automatisch übernimmt und damit dann seine eigenen Callbacks aufruft.

Um den Fehlerfall kümmern wir uns im nächsten Abschnitt.

Fehlerbehandlung und zurückgewiesene Promises

Für eine Fehlerbehandlung benötigen wir vor allem eins: eine als Fehler definierte Situation. Deswegen wollen wir unsere Funktion warte um eine solche erweitern. Es wäre sinnlos, sie mit einer negativen Wartezeit aufzurufen, dies soll als Fehler behandelt werden. Die Erweiterung baut auf der Kompaktversion von warte auf, und man kann sie auf unterschiedliche Arten bauen:

Beispiel: warte mit Prüfung - Methode A
function warte(sekunden, wert) {

   return new Promise((resolve,reject) => {
      if (sekunden < 0)
         reject("Ungültige Wartezeit");
      else
         setTimeout(resolve, 1000*sekunden, wert);
   });
}
Beispiel: warte mit Prüfung - Methode B
function warte(sekunden, wert) {

   if (sekunden < 0)
      return Promise.reject("Ungültige Wartezeit");
   else
      return new Promise((resolve,reject) =>
           setTimeout(resolve, 1000*sekunden, wert));
}

Die Methode A führt die Prüfung in der Executorfunktion durch. Wird dort eine ungültige Wartezeit vorgefunden, wird setTimeout nicht aufgerufen, statt dessen ruft der Executor den reject-Callback auf. Das neue Promise wird damit sofort bei seiner Erstellung auf rejected festgelegt.

Die Methode B geht noch einen Schritt weiter und prüft die Wartezeit noch vor dem Aufruf der Promise-Konstruktorfunktion. Wie Sie wissen, sind Funktionen Objekte, und Objekte können Methoden haben. Das gilt auch für Konstruktorfunktionen - der Promise-Konstruktor verfügt über die Methoden resolve und reject, mit denen man erfüllte oder zurückgewiesene Promises fabrizieren kann, ohne eine eigene Executorfunktion schreiben zu müssen.

Der Effekt von beiden Versionen ist der gleiche: warte(-1) gibt ein zurückgewiesenes Promise zurück.

Schauen wir uns an, wie man das verwenden kann:

Promises mit Reject-Behandlung
warte(-1, "Time Machine?")
.then(wert    => console.log("Erfolg", wert),
      meldung => console.log("Fehler", meldung));

Dieses Beispiel verwendet then, um auf dem von warte gelieferten Promise je einen fulfilled- und einen rejected-Handler zu registrieren. Die Prüfung schlägt natürlich fehl, und auf der Konsole erscheint die Ausgabe "Fehler Ungültige Wartezeit".

Es geht aber auch anders! Schauen Sie sich diese Abwandlung an:

Promises mit Reject-Behandlung
warte(-1, "Time Machine?")
.then(wert    => console.log("Erfolg", wert))
.catch(meldung => console.log("Fehler", meldung));

Statt als zweiter Parameter des then-Aufrufs wird der onRejected-Callback nun in einem eigenen Aufruf von catch registriert. Im vorigen Abschnitt haben wir gelernt, dass then ein neues „Promise B“ zurückgibt - das bedeutet, dass der catch-Aufruf nicht auf dem von warte gelieferten „Promise A“ registriert wird, sondern auf „Promise B“. Lässt man den Code laufen, ist das Ergebnis aber das Gleiche.

Die Erklärung ist einfach: Für „Promise A“ wird nun kein onRejected-Callback mehr definiert. Im rejected-Fall sind wir deshalb bei der Variante fehlt des vorigen Abschnitts, „Promise B“ folgt dem Zustand von „Promise A“, und wir gelangen in unseren onRejected-Callback von „Promise B“.

Welche Alternative ist nun besser? Oder „richtiger“? Die Antwort lautet wie so oft: Es kommt darauf an. Schauen wir uns dazu einmal ein Beispiel aus der realen Welt an, das fetch-API:

fetch mit Fehlerbehandlung
fetch("/test.json")                           // fetch() erzeugt Promise A
.then(response => {                           // erstes then wird auf A ausgeführt
         if (response.ok)
            return response.json();           // json() erzeugt Promise C
         else
            throw new Error(response.status);
      })                                      // erstes then gibt Promise B zurück
.then(ergebnis => {                           // zweites then wird auf B ausgeführt
                                              // und empfängt JSON-Objekt aus Promise C
         /* Ergebnis verarbeiten */
      })                                      // zweites then gibt Promise D zurück
.catch(error => {                             // catch wird auf D registriert
          console.log("Fehler", error);
       });

Bei fetch muss man wissen, dass es grundsätzlich zweistufig arbeitet. Im ersten Schritt wird der HTTP-Request abgesetzt und ein Promise zurückgegeben. Sobald die Antwort vom Server eintrifft, wird davon die einleitende Antwortzeile und die HTTP-Header gelesen - mehr nicht. Daraus entsteht ein response-Objekt, mit dem das Promise erfüllt wird. Das von fetch gelieferte Promise wird nur dann zurückgewiesen, wenn die Eingabeparameter von fetch fehlerhaft waren oder der Server überhaupt keine Antwort liefert (Netzwerkfehler).

Kommt eine HTTP-Antwort, gilt der fetch technisch als gelungen. Man könnte einen eigenen onRejected-Callback für das von fetch gelieferte Promise registrieren, das würde die Fehlerbehandlung allerdings in mehrere Teile zerlegen. Zumeist kann man im Fehlerfall ohnehin nicht viel tun als dem Anwender zu melden "geht gerade nicht, weil...". Deswegen lässt man auf dem ersten then den onRejected-Callback zumeist weg und verlässt sich darauf, dass der Fehler von dem Promise, das vom then zurückgegeben wird, durchgereicht wird.

Es ist eine der Aufgaben des ersten onFulfilled-Callbacks, mit HTTP-Fehlermeldungen umzugehen, sei es ein Status 404 weil die Ressource nicht existiert, oder ein Status 503 weil im Server etwas schiefläuft. Man kann sich das mit der ok-Eigenschaft des Response-Objekts etwas einfacher machen, weil diese Eigenschaft auf true gesetzt wird, wenn der HTTP-Status der Antwort im Bereich 200-299 ist.

Enthält ok den Wert false, wirft der onFulfilled-Callback ein Error-Objekt, was dazu führt, dass das vom ersten then zurückgegebene Promise zurückgewiesen wird.

Andernfalls wird das Ergebnis der response.json-Methode zurückgegeben. Dies ist wieder ein Promise! Denn die HTTP Antwort ist noch nicht fertig gelesen, der eigentliche Inhalt muss noch verarbeitet werden. Dazu bietet das response-Objekt verschiedene Methoden an, die bekannte Formate verarbeiten können. Das von response.json gelieferte Promise kann sich erfüllen, oder ebenfalls zurückgewiesen werden, wenn die Serverantwort kein gültiges JSON ist.

Das erzeugte JSON Objekt kann im zweiten fulfilled-Callback verarbeitet werden. Der then-Aufruf, mit dem er auf Promise B registriert wird, gibt das Promise D zurück, auf dem nun mittels catch ein rejected-Callback registriert wird. Falls eines der vorigen Promises auf rejected gelaufen ist, wird dieser Callback aufgerufen und kann die Zurückweisung behandeln. Er stellt einen gemeinsamen Fehlerausgang für den gesamten Fetch dar.

Wenn Sie Netzwerkfehler getrennt behandeln wollen, könnten Sie einen eigenen onRejected-Callback auf dem von fetch gelieferten Promise registrieren. Das ist aber nicht ganz ohne weitere Maßnahmen zu machen, denn die aufgebaute Promise-Kette existiert weiterhin.

fetch mit rejected-Handler auf dem ersten Promise
fetch("test.json")
.then(response => {
         /* siehe oben... */
      },
      fehler1 => {
         console.log('fetch schlug fehl:', fehler2));
      })
.then(ergebnis => {
         console.log(ergebnis.x, ergebnis.y);
      })
.catch(fehler2 => {
          console.log('Fetch-Verarbeitung falsch:', fehler2);
       });

Wenn Sie dieses Beispiel laufen lassen und der fetch auf einen Serverfehler läuft, dann würde sie die erste Fehlermeldung erhalten: "fetch schlug fehl: ...". Aber dann kommt eine zweite: "Fetch-Verarbeitung falsch: TypeError: Cannot read properties of undefined (reading 'x')". Was ist da passiert?

Wenn wir genau hinschauen, finden wir, dass der erste rejected Handler nichts zurückgibt. Der „genauere Blick auf then“ verrät uns, dass dies bedeutet, dass das Promise B, das aus dem ersten then entsteht, auf den Status fulfilled gesetzt wird! Und deswegen läuft nun der fulfilled-Handler des zweiten then an und möchte die x- und y-Eigenschaften des Ergebnisses präsentieren. Was zu einem Error führt, denn der erste rejected-Handler hat ja nichts zurückgegeben, weswegen in ergebnis ein undefined steht. Der Error bewirkt, dass Promise D zurückgewiesen wird, und wir laufen in den abschließenden catch.

Diese Art der Fehlerbehandlung ist daher nicht so geschickt. Wir müssten es so machen:

besseres fetch mit rejected-Handler auf dem ersten Promise
fetch("test.json")
.then(response => {
         if (response.ok) {
            response.json()
            .then(ergebnis => {
               console.log(ergebnis.x, ergebnis.y);
            })
            .catch(fehler2 => {
               console.log('Fetch-Verarbeitung falsch:', fehler2);
            });
         }
      },
      fehler1 => {
         console.log('fetch schlug fehl:', fehler2));
      })

und sagen Sie jetzt nicht „Ist Das Gruselig“ – oder wir geben Ihnen augenblicklich recht. Dieses Schachtelungsmonster ist noch nicht einmal ausgewachsen. Der innere catch-Aufruf fängt nämlich immer noch zwei Fehlersituationen ab: Fehlschlag des json()-Aufrufs, und Fehlschlag der Ergebnisverarbeitung. Um auch diese Fehlerbehandlung zu trennen, müsste man diesen Teil noch mit try/catch einkapseln. Um dann noch die Übersicht zu bewahren, empfiehlt es sich, die Behandlungsfunktionen für fulfilled und rejected nicht mehr inline zu notieren, sondern getrennte Funktionen dafür zu schreiben

Zucker für die Syntax: async und await

Angesichts der Komplexität, die aus verketteten Promises entstehen kann, wurde in einer späteren ECMAScript-Version ein Werkzeug geschaffen, mit dem man Promise-getriebenen Code besser handhaben kann: async-Funktionen und der await-Operator. Darauf gehen wir in einem eigenen Artikel ein: async und await.
  1. Wikipedia: Multithreading