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, die das Ergebnis als Argument übergeben bekommt, 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. Ein einfacher 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.

Die Grundproblematik, dass ein Programm auf das Ergebnis eines parallel laufenden Vorgangs warten muss, ist nicht neu und wurde bereits in den 1970er Jahren durch das Konzept eines Future-Objekts behandelt. Das Future repräsentiert den erwarteten Wert und ermöglicht, das Vordergrundprogramm mit dem parallel laufenden Vorgang wieder zu synchronisieren.

Promises in JavaScript sind eine Implementierung des Future-Konzepts. Sie bieten zwar (noch) keine Möglichkeit an, eine Berechnung automatisch im Hintergrund ausführen zu lassen. Durch die Kombination mit einem Web Worker wäre das natürlich möglich, aber man muss es dann selbst bauen; es ist kein fertiges Browser API. Aber sie ermöglichen das Reagieren auf eine asynchrone Operation und auch die Koordination mehrerer parallel laufender asnychroner Vorgänge.

Exkurs: Die Ablaufsteuerung in Javascript

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

JavaScript ist ereignisgetrieben, d.h. ein Stück 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 JavaScript auszuführen 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. Gerade wenn nicht klar ist, ob beispielsweise 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

Ein Promise kennt einen von drei möglichen Zuständen: pending (schwebend), fulfilled (erfüllt) und rejected (zurückgewiesen). Die Zustände fulfilled und rejected fasst man auch unter dem Oberbegriff settled (festgelegt) zusammen.

Solange die gewünschte asynchrone Operation noch nicht beendet ist, ist der Zustand des Promise schwebend. 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.

Sobald sein Zustand festgelegt ist, ist das Promise nicht mehr veränderbar.

Programmieren mit Promises

„Promise-Objekt“ - Konstruktor oder Konstrukt?

Das JavaScript-Objektkonzept bringt eine sprachliche Mehrdeutigkeit mit sich. Es gibt die globale Konstruktorfunktion Promise, und Sie können damit 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-Objekt“ immer das konstruierte Objekt gemeint sein. Wenn es um die Konstruktorfunktion Promise geht, soll das ausdrücklich gesagt werden.

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

Auf Zustandsänderungen reagieren

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 dann aufzurufen sind. Ein Promise hat aber nicht nur eine Liste von Callbacks, sondern gleich drei.

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 completed Liste
diese Callbacks werden zusätzlich ausgeführt, wenn der Zustand des Promise festgelegt wird. Ihnen wird nichts übergeben.

Die Ausführung der Callbacks besteht darin, dass pro Callback ein Microtask erzeugt wird, 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. Mit finally stellen Sie einen Callback in die completed Liste ein.

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, gefolgt von Microtasks aus den Einträgen der completed-Liste. 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 dieser 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 Beispielen kommen, wie man then, catch und finally verwendet, wollen wir uns noch 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 eine Funktion, die den Vorgang einleitet, sich auf die Benachrichtigungen zu seinem Fortschritt registriert und zum Abschluss den Zustand des Promise festlegt. Diese Funktion - auch Executor genannt - übergeben Sie der Promise-Konstruktorfunktion 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.

Beispiel: Ein Promise, das setTimeout kapselt ansehen …
function delay(millisekunden, wert) {

   function timerController(resolve, reject) {
      setTimeout(timerComplete, millisekunden);

      function timerComplete() {
         resolve(wert);
      }
   }

   return new Promise(timerController);
}

delay(1000, "Welt")
.then(text => console.log(text));

console.log("Hallo");

Die gezeigte delay-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

Lassen wir den Inhalt der Funktion delay zunächst einmal außen vor. Die Programmausführung beginnt damit, dass delay vom Hauptprogramm aufgerufen wird und zwei Argumente enthält: eine Wartezeit von 1000 Millisekunden, und einen Wert, den das Hauptprogramm nach Ablauf der Wartezeit vom Promise übergeben bekommen möchte. Einen solchen Wert durchzureichen ergibt nicht allzuviel Sinn, aber es ist ja auch nur eine Demonstration.

Das Hauptprogramm erwartet von der delay-Funktion ein Promise, um dann damit arbeiten zu können.

Den asynchronen Vorgang einleiten

Es ist nun an der Zeit, die delay-Funktion näher anzuschauen. Zunächst wird eine Funktion timerController 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. Bei diesem Aufruf werden zwei Funktionen als Argument übergeben, auf die gleich eingegangen werden soll.

Als erstes muss der Executor den asynchronen Vorgang einleiten. Für unser Timer-Beispiel ruft er setTimeout auf und übergibt die Funktion timerComplete als Callback, der nach Ablauf der Wartezeit ausgeführt wird. Die Wartezeit selbst wird von der delay-Funktion als Parameter erwartet und steht innerhalb von timerController zur Verfügung (siehe dazu auch den Abschnitt Variablen-Scope).

Wichtig ist, dass die Funktion timerComplete innerhalb von timerController definiert wird, denn dadurch hat sie die Parameter von timerController in ihrer Closure zur Verfügung.

Unser Executor hat nun eine asynchrone Operation gestartet und dafür gesorgt, dass er über ihr Ende - den Ablauf des Timers - informiert wird. Damit sind alle Vorbereitungen getroffen, der Executor endet und das Promise-Objekt ist fertig. Es wird mit return an den Aufrufer von delay zurückgegeben und kann dort genutzt werden.

Warten auf die Wunscherfüllung

Der Aufrufer von delay 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 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 Funktion timerComplete aufruft. Deren Aufgabe ist es, den Schwebezustand des Promise-Objekts zu beenden, und dafür benötigt sie die Funktionen, die die Executor-Funktion als Parameter erhalten hat. Die Executor-Funktion ist zwar bereits zu Ende, aber weil timerComplete darin definiert wurde, hat sie den Scope der Executor-Funktion und damit ihre Parameter und lokalen Variablen noch als Closure zur Verfügung. Genauso auch den Scope des delay-Aufrufs, der diesen Executor in ein Promise gesteckt hat.

Der erste Parameter nennt sich resolve und enthält eine Funktion, mit der man das Promise in den Zustand fulfilled schaltet. Diese Funktion kann einen Wert entgegennehmen, der als Wert des Promise gespeichert wird und 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 delay-Funktion.

Der zweite Parameter, reject, enthält ebenfalls eine Funktion. Mit ihr könnten wir das Promise auf den Zustand rejected schalten. 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 delay-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 timerComplete 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 delay ist, wie gesagt, umständlich. Den timerController kann man als Pfeilfunktion schreiben und direkt an den Promise-Konstruktor übergeben. Die setTimeout Funktion verfügt über die Möglichkeit, ab dem dritten Parameter Argumente festzulegen, der der Timeout-Funktion übergeben werden sollen. Nutzt man das, braucht man keine eigene timerComplete-Funktion, sondern kann die resolve-Funktion direkt an setTimeout übergeben. Das sieht dann so aus:

Beispiel: delay - kompakt
function delay(millisekunden, wert) {

   return new Promise((resolve, reject) => setTimeout(resolve, millisekunden, wert));

}

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 für die catch-Methode, denn sie ist ja nur ein Synonym für then(undefined, onRejected).

Der Zustand von „Promise B“ ist zunächst schwebend. Worauf „Promise B“ festgelegt wird, hängt vom Rückgabewert des aufgerufenen Callbacks ab. Und jetzt beginnt die Sache, kompliziert zu werden, denn es gibt sechs Möglichkeiten.

Wenn ein onFulfilled- oder onRejected-Callback...

nichts zurückgibt
so wird „Promise B“ auf fulfilled festlegt und erhält den Wert undefined
einen Wert zurückgibt, der kein Promise-Objekt darstellt
so wird Promise B auf fulfilled festlegt und erhält diesen Wert.
ein „Promise C“ zurückgibt, dessen Zustand bereits festgelegt ist
so wird „Promise B“ auf den Zustand und den Wert von „Promise C“ festgelegt
ein schwebendes „Promise D“ zurückgibt
so wird „Promise B“ an „Promise D“ gekoppelt. In dem Moment festgelegt, wo „Promise D“ festgelegt wird, wird sein neuer Zustand und sein Wert auf „Promise B“ übertragen.
mittels throw einen Fehler signalisiert
so wird „Promise B“ auf den Zustand rejected festlegt und der geworfene Wert wird zu seinem Wert.

Der sechste Fall liegt vor, wenn für den Zustand, auf den das Promise festgelegt wurde, gar kein Callback gesetzt wurde. In diesem Fall erhält „Promise B“ den Zustand und den Wert von „Promise A“.

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

Unterschiedliche Rückgabewerte von then - keine Rückgabe
delay(1000, "Selfhtml")                       // Erzeugt Promise A
.then(wert1 => {                              // Registriert then-Callback auf Promise A
         console.log("1. Hallo ", wert1);        
      })                                      // ...und gibt Promise B zurück!
.then(wert2 => {                              // Registriert then-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".

Unterschiedliche Rückgabewerte von then - keine Rückgabe
delay(1000, "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 in der Konsole ein "Hallo *Selfhtml*".

Als nächste Erweiterung wird die delay-Funktion genutzt, um ein weiteres Promise zu erzeugen und aus dem ersten onFulfilled-Handler zurückzugeben.

Unterschiedliche Rückgabewerte von then - ein Promise
delay(1000, "Selfhtml")                       // Erzeugt Promise A
.then(wert1 => {                              // Registriert then-Callback auf Promise A
         console.log("Hallo ", wert1);        
         return delay(1000, "*" + 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 delay-Aufruf erzeugte „Promise C“ keine Callbacks registriert werden. Sie sind nicht nötig, „Promise B“ das Ergebnis automatisch übernimmt und damit dann seine eigenen Callbacks aufruft.

Fehlerbehandlung und zurückgewiesene Promises

Für eine Fehlerbehandlung benötigen wir vor allem eins: einen Fehler. Deswegen wollen wir unsere Funktion delay 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 delay auf, und man kann sie auf unterschiedliche Arten bauen:

Beispiel: delay mit Prüfung - Methode A
function delay(millisekunden, wert) {

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

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

Die Fassung 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 sofort den reject-Callback auf. Das neue Promise wird damit sofort bei seiner Erstellung auf rejected festgelegt.

Die Fassung 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 - die Promise-Funktion 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(-100) gibt ein zurückgewiesenes Promise zurück.

Schauen wir uns an, wie man das verwenden kann:

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

Dieses Beispiel verwenden then, um auf dem von delay gelieferten Promise je einen fulfilled und einen rejected 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
delay(-100, "Time Machine?")
.then(wert    => console.log("Erfolg", wert))
.catch(meldung => console.log("Fehler", meldung));

Statt als zweiten Parameter des </ode>then</code>-Aufrufs wird der onRejected-Callback nun als weiterer Aufruf 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 delay 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 kein onRejected-Callback definiert. Aus diesem Grund leitet „Promise B“ den rejected-Zustand von „Promise A“ durch, und wir gelangen in unseren onRejected-Callback.

Welche Alternative ist nun besser? Oder „richtiger“? Die Antwort lautet natürlich: 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")
.then(response => {
   if (response.ok)
      return response.json();
   else
      throw new Error(response.status);
})
.catch(error => 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 grad 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 von 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 ja 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.

Der onRejected-Callback, der auf dem von then gelieferten Promise registriert wird, ist im Stande, eine Zurückweisung aus fetch oder aus dem json-Aufruf zu verarbeiten. Er stellt einen gemeinsamen Fehlerausgang für den gesamten Fetch dar.

Nur dann, wenn Sie Netzwerkfehler getrennt behandeln wollen, benötigen Sie einen eigenen onRejected-Callback auf dem von fetch gelieferten Promise.