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 kennst du 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. Du kannst 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.

Eine wichtige Einschränkung von Promises ist, dass sie sich nur für einmalige Benachrichtigungen eignen. Wenn sich ein Ereignis wiederholt, passt das Promise-Programmiermodell nicht mehr und du solltest den Auslöser des Ereignisses als Klasse implementieren, die von EventTarget abgeleitet ist.

Die Ablaufsteuerung in Javascript

Wenn du weißt, was JavaScript mit Tasks und Microtasks tut, kannst du mit dem nächsten Abschnitt weitermachen. Wenn nicht, dann lies bitte den Anhang zur Event-Loop im Artikel zur Ereignisverarbeitung in JavaScript.

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

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 von einem der Eventhandler, die der Executor erstellt hat, 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 als Ergebnis ü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

Der linke Stab zeigt die Abläufe im normalen Programmcode. Dort wird das Promise erstellt (direkt oder indirekt von einem API, das Promises nutzt) und durch Aufruf der Methode then() die Callback-Funktion isFulfilled hinterlegt, die bei Erfüllung des Promises aufzurufen ist.

Der mittlere Stab zeigt den Executor (exec). Er läuft in der gleichen JavaScript-Umgebung wie das normale Programm.

Der rechte Stab steht für die asynchrone Operation, die in einem anderen Browserthread, einem anderen Prozess des Computers oder vielleicht sogar auf einem anderen Computer ausgeführt wird.

Auf Höhe der Linie, die mit „Neuer Task“ beschriftet ist, vergeht Zeit. Der JavaScript-Task, der die asynchrone Operation eingeleitet hat, endet hier und der Browser kann andere Dinge tun, wie zum Beispiel das Fenster neu zeichnen oder Benutzereingaben verarbeiten.

Irgendwann ist die asnychrone Operation abgescchlossen und löst ein Event aus. Dieses Event wird vom Browser in die Taskwarteschlange von JavaScript eingestellt und ruft den done-Handler auf, den der Executor hinterlegt hat. Dieser nutzt die resolve-Funktion, die ihm der Promise-Konstruktor übergeben hat, um das Promise auf resolved zu setzen und den Wert, mit dem es erfüllt wurde, zu speichern.

Für jeden Callback, der mit then hinterlegt wurde, wird von resolve ein Microtask in die Warteschlange gestellt. Sobald die eigentliche Eventverarbeitung abgeschlossen ist, werden diese Microtasks ausgeführt und damit die isFulfilled-Funktion aufgerufen. Danach endet der Event-Task.

Programmieren mit Promises

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 später zeigen, aber erst einmal wollen wir so tun, als wäre sie ein Teil von JavaScript.

Reaktion auf erfolgreiche Erfüllung

Die Idee ist, dass der Aufruf warte(2) ein Promise liefert, das sich nach 2 Sekunden erfüllt. Um darauf zu reagieren, gibt es zwei Möglichkeiten:

  1. Aufruf der Methode then auf dem Promise-Objekt und Übergabe einer Callback-Funktion
  2. Verwenden des Schlüsselwortes await (in einer async-Funktion oder Top-Level in einem ECMAScript-Modul)
Ergebnisse abholen mit Callback
warte(2);
.then(function() {
   console.log("Wartezeit abgelaufen!");
});
console.log("Ich warte");
Ergebnisse abholen mit await
async function warteDemo() {
   await warte(2);
   console.log("Wartezeit abgelaufen!");
}
warteDemo();
console.log("Ich warte");

Der Aufruf von then registriert die übergebene Funktion als Callback. Es kann mehr als einen geben, ein Promise besitzt zu diesem Zweck die so genannte fulfilled-Liste. Sie wird abgearbeitet, sobald die Wartezeit abgelaufen ist und das Promise auf den fulfilled-Status gesetzt wird.

Beachte: Wie im Stabdiagramm gezeigt, werden die Callbacks der fulfilled-Liste nicht synchron aufgerufen, sondern als Microtask registriert und beim Abarbeiten der Microtask-Queue ausgeführt.

Mit await in einer async-Funktion sieht der Ablauf etwas anders aus. Generatoren und async-Funktionen werden von JavaScript so übersetzt, dass sie pausiert werden können. Man kann es sich zwar so vorstellen, dass der Programmteil hinter await in eine eigene Funktion übersetzt wird, die dann mit then in der fulfilled-Liste eingetragen wird, tatsächlich sind die Abläufe aber stärker optimiert. Jedenfalls bedeutet es, dass warteDemo() in dem Moment zurückkehrt, wo die Ausführung auf await stößt. Für weitere Einzelheiten zu async und await gibt es einen eigenen Artikel.

Der Effekt ist in beiden Varianten der Gleiche: Wir sehen in der Konsole die Ausgabe "Ich warte", und nach zwei Sekunden kommt "Wartezeit abgelaufen".

Es scheint umständlich, für await eine neue Funktion schreiben zu müssen. Beachte aber, dass dein Code in vielen Fällen ohnehin schon in einer Funktion steht, z. B. ein Eventhandler. Und die markierst du dann einfach als async.

Fehlerbehandlung bei abgewiesenen Promises

Für unsere warte-Funktion ist eine Fehlersituation kaum vorstellbar. Andere Funktionen, die Promises liefern, können hingegen durchaus auf Probleme stoßen. Beispielsweise wird das von fetch gelieferte Promise abgewiesen, wenn man eine ungültige URL angibt.

Um bei einem abgewiesenen Promise reagieren zu können, besitzen Promises außer der fulfilled-Liste auch noch die rejected-Liste. Es gibt drei Möglichkeiten, hier einen Callback einzutragen:

  1. Übergeben eines zweiten Parameters an then
  2. Aufruf der Promise-Methode catch
  3. Verwenden von await zusammen mit try und catch

catch(x) ist nichts weiter als ein Synonym für then(undefined, x).

Fehlerbehandlung mit Callback
// unbekannte URL
fetch("https://example.ork")
.then(function(response) {
   // weitere Verarbeitung
})
.catch(function(fehler) {
   console.log("Fetch gescheitert:", fehler);
});
Fehlerbehandlung bei await
async function fetchDemo() {
   try {
      const response = await fetch("https://example.ork");
      // weitere Verarbeitung
   }
   catch (fehler) {
      console.log("Fetch gescheitert:", fehler);
   }
}
fetchDemo();
Beachte: Moment mal. Wird in der Callbackvariante die catch-Methode nicht falsch aufgerufen? Wenn man den Code verdichtet, steht da fetch(...).then(...).catch(...). Müsste catch nicht auf dem Promise, also auf dem Rückgabewert von warte, aufgerufen werden?

Die Antwort ist ein klares Jein. Beides ist möglich, aber die Auswirkungen sind unterschiedlich. Wir gehen beim genaueren Blick auf then noch sehr genau auf die Einzelheiten ein; hier soll nur gesagt werden, dass A.then() ein neues Promise B zurückliefert und dieses Promise B eine Fehlersituation aus Promise A „durchreicht“, wenn auf A keine eigene Reject-Behandlung registriert wurde. Der Vorteil ist, dass auf diese Weise Fehler aus fetch UND aus dem then-Callback mit einem catch behandelt werden können. In der await-Version ergibt sich das gemeinsame Fehlerhandling ganz von allein.

Und was ist mit finally?

Wer try...catch verwendet hat, kennt auch finally: ein gemeinsames Stück Code, das auf jeden Fall ausgeführt wird, ganz gleich, ob eine Fehlerbedingung geworfen wurde oder nicht.

Tatsächlich fehlte den Promises, die mit ECMAScript 6 (2015) eingeführt wurden, dieses Feature zunächst und es brauchte drei weitere Versionen, bis es mit ECMAScript 9 (2018) folgte.

finally-Behandlung mit finally()
fetch("example.ork")
.then( (response) => {
   if (response.ok)
      return response.text();
   else
      throw new Error("Fetch lieferte Status " + response.statusText);
})
.then( (text) => console.log("Empfangen wurde", text) )
.catch( (error) => console.log("Fehler:", error) )
.finally( () => console.log("Fetch ist abgeschlossen") );
finally-Behandlung mit await
try {
   const response = await fetch("example.ork");
   if (response.ok) {
      const text = await response.text();
      console.log("Empfangen wurde", text);
   }
   else {
      throw new Error("Fetch lieferte Status " + response.statusText);
   }
}
catch (error) {
   console.log("Fehler:", error);
}
finally {
   console.log("Fetch ist abgeschlossen");
}

Dieses Beispiel zeigt den vollständigen Ablauf eines GET-Requests mit fetch. Die Antwort von fetch muss in zwei Schritten verarbeitet werden, deshalb gibt es zweimal then beziehungsweise zweimal await. Wir werden noch genauer beleuchten, welche Promises hier wie zusammenwirken.

Die finally-Methode des Promise-Objekts ist simpel gestrickt. Sie trägt ihren Callback einfach in die fulfilled- und die rejected-Liste ein. Allerdings nicht direkt, sondern über eine Zwischenfunktion, die dafür sorgt, dass der finally-Handler keine Argumente übergeben bekommt und man im finally-Callback zuverlässig daran gehindert wird, zu erfahren, ob man über den then- oder den catch-Pfad gekommen ist.

Ich habe aber mehr als ein Promise

Das kann vorkommen. Beispielsweise möchte man mehrere Datenblöcke parallel downloaden und benachrichtigt werden, wenn alle Downloads da sind. Oder man möchte eine Aufgabe auf mehrere Web Workern verteilen und nach Abschluss aller Aufgabenteile die Ergebnisse zusammenführen.

Der eigentlichen Hintergrundprozess – ob Download oder Web Worker – interessiert hier nicht. Wir nehmen einfach an, dass es eine Funktion getData() gibt, die asynchron arbeitet und ein Promise zurückliefert, das sich erfüllt, wenn die Daten bereitstehen. Nehmen wir weiter an, dass es ein Array daten gibt, mit einigen Einträgen. Für jeden Eintrag soll getData aufgerufen werden und wir wollen warten, bis alle getData-Aufrufe fertig sind.

Für diesen Zweck besitzt der Promise-Konstruktor die statischen Funktionen all und allSettled. Man übergibt ihnen – beispielsweise – ein Array aus Promises und sie übernehmen die Koordination. Als Aufrufer bekommt man ein einzelnes „Sammelpromise“ zurück, das sich erfüllt oder abgewiesen wird, wenn die übergebenen Promises fertig sind.

Warten auf mehrere Promises
async function handleInParallel(daten) {
   const promises = daten.map(datum => getData(datum));
 
   Promise.all(promises)
   .then(ergebnisse => console.log("Ergebnisse:", ergebnisse)
   .catch(fehler => console.log("Eine Berechnung schlug fehl:", fehler);

   Promise.allSettled(promises) 
   .then(ergebnisInfos => ...);
}

Zu Beginn wird mit Hilfe der Array-Methode map für jeden Eintrag im Datenarray die getData-Funktion aufgerufen. Diese liefert, wie wir annehmen wollten, ein Promise zurück, so dass sich in promises nun ein Array von Promise-Objekten befindet.

Und dann wenden <wir code>all</code> und allSettled sozusagen gleichzeitig auf das promises-Array an? Ja, klar, warum nicht. Jedes Promise hat eine Liste von Callbackfunktionen.

Promise.all wartet darauf, dass sich alle übergebenen Promises erfüllen. Sobald eins davon zurückgewiesen wird, bricht all ab und weist das Sammelpromise mit dem Ergebnis des abgewiesenen Einzelpromise zurück. Nur dann, wenn alle Einzelpromises erfüllt wurden, erfüllt sich das Sammelpromise und übergibt dem then-Callback ein Array mit den Werten, die den Einzelpromises zugewiesen wurden.

Promise.allSettled wartet auf jeden Fall ab, bis sich alle Einzelpromises festgelegt haben. Erst dann erfüllt es sich und übergibt als Ergebnis ein Array aus Objekten. Jedes dieser Objekte besitzt eine status-Eigenschaft mit einem String, der entweder "fulfilled" oder "rejected" lautet, und je nach Status eine Eigenschaft value (für fulfilled) oder reason (für rejected), die den Wert des Promises enthält.

Außerdem gibt es noch Promise.any, die das erste erfüllte Promise liefert und Promise.race, mit der man das erste festgelegte Promise erhält.

Zusammenfassung

Du hast nun gelernt, dass

  • Ein Promise drei Zustände haben kann: schwebend (pending), und festgelegt, in den Varianten erfüllt (fulfilled) und zurückgewiesen (rejected).
  • Man mit Callbacks oder dem Schlüsselwort await auf den Übergang zu einem festgelegten Zustand reagieren kann
  • Diese Callbacks in zwei internen Listen des Promise verwaltet werden
  • Dass ein erfülltes und auch ein abgewieseses Promise einen Wert haben können und dieser Wert an den Callback übergeben wird, bzw. das Ergebnis von await ist.
  • Promises sich irgendwie miteinander verketten können und dabei ein Ablauf herauskommt, der an try...catch...finally erinnert
  • und dass sie ein Wettrennen veranstalten können

Wir möchten auch darauf hinweisen, dass diese merkwürdige Schreibweise, wo ein Methodenaufruf auf eine neue Zeile geschrieben wird, für Promises typisch ist. Würde man das alles hintereinander schreiben, so wie bei anderen Methodenaufrufen, würde der Code schnell unleserlich.

Was wir nur angerissen haben, aber noch nicht ganz klar ist:

  • Ist await nun ein Statement oder ein Operator? Offenbar kann man das Ergebnis von await einer Variablen zuweisen
  • Wie kommt der Wert ins Promise?
  • Wie genau klappt das mit der Kettenbildung

und last, but not least

  • Wie erstelle ich ein eigenes Promise?

Promises selbst erzeugen

Ein selbstgemachtes Promise ist kein Hexenwerk. Letztendlich geschieht nichts weiter, als dass eine vorhandene, auf Events basierende Schnittstelle anders verpackt wird. Ist das sinnlos? Vielleicht. Der Nutzen besteht darin, beim Verwenden des Promises eine standardisierte Vorgehensweise zu haben, die async/await Syntax benutzen zu können und einen Mechanismus zum Synchronisieren mehrerer asynchroner Operationen zu haben, die gleichzeitig laufen.

Um eine asynchrone Operation als Promise darstellen zu können, wird eine Funktion benötigt. Sie hat die Aufgabe, den Vorgang einzuleiten und auf geeignete Weise die nötigen Eventhandler zu registrieren, um die Operation überwachen zu können. Ist die Operation abgeschlossen (oder gescheitert), muss die Funktion das Promise mit dem Operationsergebnis erfüllen oder mit dem Fehlergrund zurückweisen. Diese Funktion nennt sich Executor und wird dem Promise-Konstruktorfunktion als Argument übergeben.

Ein ganz einfacher Fall für eine asynchrone Operation ein mit setTimeout gebildeter Timer. Die warte-Funktion aus dem vorigen Kapitel tut genau das: sie umgibt setTimeout mit einem Promise. Um kein ergebnisloses Promise zu haben (und weil setTimeout das sowieso kann), wollen wir ihr noch einen weiteren Parameter spendieren, den sie uns als Promise-Ergebnis bereitstellen kann.

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

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

   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 gewünschten Ergebniswert. Daraus erstellt sie ein Promise, für das wir mit then eine Pfeilfunktion als fulfilled-Handler registrieren. Darin wird der hinterlegte Ergebniswert auf die Konsole geschrieben.

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. Sie werden gebraucht, um das Promise erfüllen oder zurückweisen zu können.

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 Wert des sekunden-Parameters der warte-Funktion verwendet, und als drittes wird der mitgegebene wert-Parameter weitergegeben. setTimeout reicht das dritte und die folgenden Argumente an seine Callbackfunktion weiter.

Unser Executor hat damit eine asynchrone Operation gestartet und dafür gesorgt, dass er über ihr Ende 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. Auf diesem Promise wird mit then ein fulfilled-Callback registriert. Das kennen wir schon. Auf catch können wir verzichten, unser einfacher Timer hat keine Abbruchmöglichkeit, die zum Zurückweisen des Promise führen könnte.

Während setTimeout auf den Ablauf des Timers wartet, wird das Beispielprogramm fortgesetzt und gibt "Hallo" auf der Konsole aus.

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 um das tun zu können, benötigt sie die Funktionen, die der Promise-Konstruktor an den Executor übergeben hat. Die Executor-Funktion ist zwar bereits zu Ende, aber weil der setTimeout-Callback im Executor definiert wurde, hat er 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 das 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 fulfilled-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, den wir über setTimeout durchgereicht haben.

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 gewünschten Wert aus dem warte-Aufruf an resolve. Das Promise wird mit diesem Wert erfüllt und überträgt den Inhalt der fulfilled-Callbackliste in Microtasks. Sobald der Haupt-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. Eine eigene Funktion als Timer-Callback ist auch nicht nötig, hier kann man direkt resolve übergeben. Das sieht dann so aus:

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

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

Schaue dir bei Unklarkeiten zum Verhalten von setTimeout einfach noch einmal die Dokumentation dieser Funktion an.

Woher kennen resolve und reject ihr Promise?

Wenn du schon erste Gehversuche mit objektorientierter Programmierung in JavaScript gemacht hast, dann weißt du, 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 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).

Der genauere Ablauf von then ist, dass es drei Dinge erzeugt: - ein neues „Promise B“ - ein „Reaktionsobjekt“ für fullfilled, in dem es das neue Promise und der onfulfilled-Callback hinterlegt wird - ein „Reaktionsobjekt“ für rejected, in dem es das neue Promise und der onrejected-Callback hinterlegt wird

Das Reaktionsobjekt ist eine interne Sache von JavaScript und für uns nicht sichtbar. Aber das Reaktionsobjekt ist das, was in die Callback-Listen eingetragen wird und nicht die Callback-Funktion selbst. Dadurch kann es zwischen „Promise A“ und „Promise B“ auch dann noch einen Zusammenhang geben, wenn einer der Callbacks weggelassen wurde.

Der ganz genaue Ablauf von then... ach, glaubt mir, das wollt ihr gar nicht wissen.[2]

Was mit „Promise B“ geschieht, hängt davon ab, ob für den erreichten settled-Zustand eine Callback-Funktion registiert wurde und was diese Funktion tut. Sobald „Promise A“ festgelegt wird, werden die zugehörigen Reaktionsobjekte abgearbeitet und der Aufruf der fullfilled- bzw. rejected-Callbacks in die Microtask-Queue gestellt. Dabei kann die Callback-Funktion - 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).

Das Reaktionsobjekt steuert je nach Situation, was mit „Promise B“ passiert:

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. Für dieses Promise können wiederum fulfilled und rejected-Handler registriert werden 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);
      });                                     // ...und gibt Promise C zurück (wird ignoriert)
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.

Beachte, 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 „Promise 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));

(Wundere Dich nicht, dass das Wort catch grün ist - das Einfärbe-Tool für JavaScript ist nicht immer perfekt.)

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“? Es kommt darauf an. Wenn catch auf dem Promise aufgerufen wird, das von then zurückgegeben wird, dann behandelt es auch Fehler, die erst im onfulfilled-Callback entstanden sind. Werden beide Callbacks auf „Promise A“ registriert, bleiben diese Fehler unbehandelt.

Schauen wir uns 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. Während des fetch()-Aufrufs wird der HTTP-Request abgeschickt und „Promise A“ 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 A“ erfüllt wird, ganz gleich, wie der HTTP Status der Antwort war. „Promise A“ 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 rejected-Callback zumeist weg und verlässt sich darauf, dass der Fehler von dem Promise, das von then zurückgegeben wird, durchgereicht wird.

Im fetch-API ist es eine der Aufgaben des ersten fulfilled-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 nur auf true gesetzt wird, wenn der HTTP-Status der Antwort im Bereich 200-299 ist.

Enthält ok den Wert false, wirft der fulfilled-Callback ein Error-Objekt, was dazu führt, dass das vom ersten then zurückgegebene „Promise B“ zurückgewiesen wird. Für „Promise B“ ist kein rejected-Handler registriert worden, deshalb wird die Zurückweisung automatisch an „Promise D“ durchgereicht und der dort mit catch registrierte rejected-Handler aufgerufen.

Ist ok == true, 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 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 rejected-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 korrekt. Besser wäre es so:

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 sage jetzt bitte nicht „Ist Das Gruselig“ – oder wir geben dir 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 dabei 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.

Oder doch besser eine lineare Kette von then-Aufrufen mit einem abschließenden catch zu programmieren!

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
  2. ECMA Script Spezifikation: The Promise Object, abgerufen am 10.07.2025