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 Webrequests, aber auch Operationen aus dem File API oder Konvertierungsfunktionen in einem Blob. Da das JavaScript-Programmiermodell eine streng sequenzielle Ausführung der Anweisungen 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 asynchron ausgeführt. Das bedeutet, dass der Browser die gewünschte Operation lediglich einleitet und dann mit der Ausführung des JavaScript-Programms weitermacht. Um das Ergebnis der Operation verarbeiten zu können, muss ein Mechanismus bedient werden, über den der Browser dieses Ergebnis 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 durchgeführt wurde. Eine solche Funktion nennt man einen Eventlistener, oder auch Callback. 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. Das Problem, wie man eine Operation asynchron im Hintergrund durchführt, lösen sie 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.

JavaScript ist größtenteils ereignisgetrieben, d. h. der meiste JavaScript-Code wird immer nur dann aufgerufen, wenn im Browser irgendein Ereignis eintritt. Dafür läuft im Browser eine sogenannte Ereignisschleife. In dieser Schleife liest der Browser Ereignisse von außen ein (also vom Betriebssystem), und prüft, ob für die Behandlung eines solchen Ereignisses eine Behandlung durch JavaScript registriert ist. Wenn ja, wird dazu eine Aufgabe (Task) in eine Warteschlange - die Task-Queue - gestellt. Es ist durchaus möglich, ein Ereignis mehrere Tasks erzeugt, oder Ereignisse aus unterschiedlichen Quellen eintreffen, so dass sich mehrere Tasks in dieser Warteschlange ansammeln können.

Die JavaScript-Engine arbeitet nun die Aufgaben in der Task-Queue nacheinander ab. Das ausgeführte Script kann das DOM verändern, deswegen findet nach jeder Aufgabe eine Layout-Phase statt, in der die Anzeige aktualisiert wird. Wenn viele kleine Aufgaben anfallen, ist dieser Zyklus zu behäbig, deswegen gibt es außer der Task-Queue noch eine Microtask-Queue. Dort können vom Script Funktionen hinterlegt werden, die nach dem Abschluss des Haupt-Tasks noch ausgeführt werden sollen, bevor die Layout-Phase beginnt (siehe auch queueMicrotask).

Microtasks sind für uns deshalb relevant, weil die Ablaufsteuerung mit Promises vollständig darauf basiert. Sie sind allerdings mit etwas Vorsicht zu behandeln, denn der Browser hört mit der Abarbeitung der Microtask-Queue erst auf, wenn sie leer ist. Wenn Sie Microtasks schreiben, die neue Microtasks erzeugen, kann das zu einer Endlosschleife führen.

Ein Vorteil der 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 Promise

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 kann jetzt oder auch später 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, der sich ergeben kann, wenn Promises geschachtelt werden. Dazu später mehr.

Solange die gewünschte asynchrone Operation noch nicht beendet ist, ist der Zustand des Promise pending.

Irgendwann endet die Operation - oder sie bricht aus irgendeinem Grund ab - und dann wird das Promise von dem Programmteil, der den asynchronen Vorgang steuert, auf fulfilled oder rejected festgelegt. Zusammen mit der Festlegung des Zustandes wird dem Promise auch ein Wert zugewiesen - 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 basierende API wissen, wie der Erzeuger des Promise Fehlergründe übermittelt.

Der Zustandsübergang von pending nach fulfilled oder rejected ist endgültig und kann nicht mehr verändert werden.

Der vorhin erwähnte Wartezustand ergibt sich, wenn ein Promise A durch ein anderes Promise B erfüllt wird. In diesem Fall ist das Promise A zwar nicht mehr pending, aber es wartet darauf, wie Promise B festgelegt wird, bevor es sich selbst festlegt. Und dann übernimmt es den Zustand von Promise B. Dieser Wartezustand wird zumeist resolved genannt, darf aber nicht mit fulfilled verwechselt werden. Insbesondere ist in diesem Wartezustand der settled-Status noch nicht erreicht.

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

Der Moment, wo sich der Zustand eines Promises von schwebend auf festgelegt ändert, ist für Ihre Programmsteuerung relevant. Ähnlich wie bei den klassischen Event-Schnittstellen können Sie Callback-Funktionen bereitstellen, die nach Festlegung des Zustandes aufzurufen sind. Ein Promise hat aber nicht 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.

Die hinterlegten Callbacks werden nicht unmittelbar ausgeführt, wenn der Zustand des Promise festgelgt wird. Statt dessen wird pro Callback ein Microtask erzeugt, der für Aufruf und Argumentübergabe sorgt.

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 trägt je einen Callback in die fulfilled und in die rejected Liste ein. Allerdings sind beide Argumente 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 deshalb nichts anderes, als then(undefined, onRejected) aufzurufen.

Für finally() gibt es keine eigene Liste. Statt dessen wird der onCompleted-Callback mit einer Art Adapter versehen und dann in die fulfilled- und rejected-Liste eingetragen, so dass er für erfüllte und zurückgewiesene Promieses aufgerufen wird.

Schauen wir uns ein einfaches Beispiel an. Angenommen, wir hätten eine Funktion warte, der man die Anzahl von Sekunden übergibt, nach denen etwas geschehen soll, und diese Funktion ist so gebaut, dass sie ein Promise zurückliefert, das sich nach Ablauf der angegebenen Zeit erfüllt. Die warte-Funktion werden wir später noch erstellen.

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ühren (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".

Solange das Promise im schwebenden Zustand ist, 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. 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. Kapseln wir doch einfach einmal einen solchen Timer in ein Promise und bauen die vorhin erwähnte warte-Funktion. 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(function(ergebnis) {
   console.log(ergebnis);
});
console.log("Hallo");

Die gezeigte warte-Funktion ist umständlicher geschrieben als nötig, um die einzelnen Bausteine besser voneinander abzugrenzen. Eine kompakte Einzeiler-Version zeigen wir im Anschluss. Was im Beispiel passiert, soll in zeitlicher Reihenfolge beschrieben werden.

Der Wartewunsch

Was die letzten fünf 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

Es ist nun an der Zeit, die warte-Funktion näher anzuschauen. Zunächst wird eine lokale Funktion warteExecutor definiert, die als Executor dienen soll. Sie wird der Promise-Konstruktorfunktion 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 und den Ergebniswert werden die Parameter der warte-Funktion verwendet.

Die Callback-Funktion greift ebenfalls auf ein Argument von warte zu. Das gelingt, 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 Closure 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 und endet. 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 noch 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 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 ergebnis-Parameter des warte-Aufrufs an resolve. Das Promise wird durch diesen 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 timerController 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.

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

Das „Promise B“ ist nicht unabhängig, sondern mit „Promise A“ verbunden. Solange A im pending Zustand ist, ist es auch B. Sobald A festlegt wird, entscheidet sich auch die Zukunft von B, und dafür gibt es mehrere Möglichkeiten.

Wenn der onFulfilled- oder onRejected-Callback...

fehlt
so folgt B 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.
nichts (also undefined) oder einen Wert zurückgibt, der kein Promise-Objekt darstellt
so wird B auf fulfilled festlegt und erhält diesen Wert.
ein neues „Promise C“ zurückgibt
so wird B an C gekoppelt und folgt nun dessen Zustand und dessen Wert.
mittels throw einen Fehler signalisiert
so wird B auf den Zustand rejected festlegt und der geworfene Fehlerwert wird zu seinem 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 ins Leben rufen.

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

Der onFulfilled-Callback des ersten then gibt keinen Wert zurück - was das Gleiche ist wie ein return undefined;. Deswegen wird im ersten Hallo noch "Hallo Selfhtml" ausgegeben, im zweiten Hallo dagegen "Hallo undefined".

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

Wir fügen dem ersten onFulfilled-Handler eine Rückgabe hinzu. Sie besteht aus dem erhaltenen Wert, der mit Sternchen dekoriert wird. Kehrt der erste onFulfilled-Handler zurück, wird der Zustand von „Promise B“ auf fulfilled festgelegt und der onFulfilled-Handler, der im zweiten then angegeben wurde, kommt zur Ausführung. Wir sehen deshalb als zweite Zeile ein "2. Hallo *Selfhtml*".

Für die dritte 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 &ndash - 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: delay 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: delay 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: delay(-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 Promiseketten erwachsen 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.