JavaScript/Objekte/Promise

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

Die mit ECMAScript 2015 (ES6) eingeführte Konstruktorfunktion Promise dient dazu, asynchrone Abläufe zu steuern und zu koordinieren. Ein solcher Vorgang wird durch eine Funktion eingeleitet, die der Promise-Konstruktor als Parameter erhält. Das Ergebnis ist über Callback-Funktionen abrufbar, die über die then-, catch und finally Methoden des Promise-Objekts registriert werden. Für den Einsatz in älteren Browsern steht ein Polyfill zur Verfügung (siehe Weblinks).

Details: caniuse.com

Konstruktor

var promise = new Promise(executor);

Methoden

  • all
  • allSettled
  • any
  • race
  • reject
  • resolve

Methoden von Promise.prototype

  • then
  • catch
  • finally

Einführung

Das Konzept von JavaScript sieht keinerlei Nebenläufigkeit vor. Es gibt nur einen Hauptverarbeitungsstrang (Thread), und auf diesem Strang müssen alle anfallenden Aufgaben erledigt werden. Es gibt aber auch Aufgaben, die Zeit benötigen, wie z. B. ein fetch-Request oder auch länger laufende interne Operationen. Würde ein JavaScript Programm auf die Fertigstellung dieser Aufgaben warten, dann würden bis dahin keine weiteren Aufgaben mehr erledigt werden. Das betrifft vor allem die Verarbeitung von GUI-Ereignissen wie click oder input. Für den Anwender sieht es so aus, als wäre das Programm träge oder würde sogar hängenbleiben.

Deshalb werden solche Operationen asynchron ausgeführt. Sie kennen das vielleicht vom XMLHttpRequest. Sie rufen dort send auf, aber um die Antwort zu bekommen, müssen Sie sich auf das load oder readystatechange Event des XMLHttpRequest-Objekts registrieren. Wenn der Browser die Antwort vom Server erhält, löst er das entsprechende Event aus und Sie können die Daten abholen.

„Auslösen des Events“ – was bedeutet das? Die JavaScript Engine besitzt eine Aufgabenwarteschlange, die Task Queue. Jeder Aufgabe ist eine JavaScript-Funktion zugeordnet, die diese Aufgabe zu erfüllen hat. Aufgaben entstehen beim Auslösen von Events, für die ein Eventhandler registriert wurde, oder durch Ablauf einer Zeitspanne (z. B. durch setTimeout angefordert). Immer, wenn eine Aufgabe verarbeitet wurde, wird die Anzeige im Browser aktualisiert (Layout). Mit der Einführung von Promises besteht die Möglichkeit, dass die Häufigkeit von Ereignissen stark zunimmt und es würde zu viel Zeit kosten, zwischen zwei Ereignissen jedesmal die Layout-Phase ablaufen zu lassen. Deswegen wird die asynchrone Verarbeitung durch Promises durch so genannte Mikrotasks gesteuert. Die Mikrotask-Queue wird jedesmal abgearbeitet, wenn ein Ereignis aus der Task-Queue fertiggestellt wurde. Erst danach folgt die Layout-Phase.

Aus Sicht der Ablaufsteuerung sind Promises also durchaus etwas Neues, aus Sicht der Programmierung eigentlich nicht. Eventhandler und Rückruffunktionen werden schon lange verwendet. Promises können das Design von Callback-Schnittstellen standardisieren, bieten ein Protokoll zur Fehlerbehandlung und helfen bei der Synchronisierung, wenn man mit mehreren gleichzeitig angestoßenen asynchronen Abläufen hantiert.

Promises benutzen

Der Lebenszyklus eines Promise

Ein Promise wird mit Hilfe der Promise Konstruktorfunktion erzeugt. Diese Funktion erhält als Parameter eine Steuerfunktion (eng.: executor function), deren Aufgabe es ist, die asynchron zu erledigende Aufgabe einzuleiten und sich – je nach verwendetem API – die erforderlichen Callbacks zu registrieren, um auf den Abschluss der Aufgabe reagieren zu können. Die Steuerfunktion bekommt vom Promise-Konstruktor zwei Callback-Funktionen übergeben: resolve und reject, die das erzeugte Promise kennen und manipulieren können.

  • resolve(ergebnis)
  • reject(fehlerursache)

Das erzeugte Promise weiß von dieser Funktion nichts. Es weiß eigentlich zuerst einmal gar nichts, und befindet sich in einem unbestimmten Schwebezustand: pending. Bis zu dem Moment, wo die Steuerfunktion eine der Callback-Funktionen des Konstruktors aufruft. resolve schaltet das Promise in den Zustand fulfilled (erfüllt), und reject in den Zustand rejected (zurückgewiesen). Diese beiden Zustände werden unter dem Oberbegriff settled (festgelegt) zusammengefasst.

Außer einem Zustand besitzt ein Promise auch einen Wert. Er ist undefiniert, solange das Promise pending ist. Der Aufruf von resolve setzt den Wert auf das übergebene Ergebnis, und der Aufruf von reject setzt den Wert auf die übergebene Fehlerursache.

Bei einem solchen Promise kann man sich nun registrieren. Dafür verwenden Sie Methoden, die das vom API gelieferte Promise-Objekts von Promise.prototype erbt.

  • then(onFulfill, onReject)
  • catch(onReject)
  • finally(onFinally)

onFulfill, onReject und onFinally sind Callback-Funktionen, die Sie bereitstellen, um auf den Übergang von pending nach settled reagieren zu können. Es kann dabei durchaus mehr als einen Reaktions-Callback geben, das Promise-Objekt führt intern zwei Listen, in die die bei Aufruf von then, catch oder finally übergebenen Callbackfunktionen eingetragen werden.

Ein Promise, das in den settled Zustand übergeht, erzeugt für jeden Eintrag der zutreffenden Callbackliste einen Mikrotask und stellt ihn in die Mikrotask-Queue ein. Dabei bekommen fulfilled Callbacks den Ergebniswert übergeben, der von der Steuerfunktion an resolve übergeben wurde, und rejected Callbacks erhalten die Fehlerursache aus dem reject Aufruf. Callbacks, die mit finally registriert wurden, erhalten keinen Parameter.

Es kann auch vorkommen, dass die Registrierungsfunktionen then, catch und finally erst dann aufgerufen werden, wenn das Promise bereit in einem settled Zustand ist. Zum Beispiel kann die Steuerfunktion, die der Promise-Konstruktor bekommt, feststellen, dass sie die das gewünschte Ergebnis in einem Cache vorfindet. Oder sie stellt fest, dass ein fehlerhafter Aufruf vorliegt und ruft sofort reject auf. Die Reaktions-Callbacks werden in diesem Fall nicht mehr in die Callbackliste eingetragen, sondern der Mikrotask wird sofort erzeugt.

Beachten Sie: Ganz gleich, ob das Promise pending oder settled ist, die Reaktions-Callbacks werden auf jeden Fall in einem neuen Mikrotask ausgeführt, niemals synchron.

Promise-Ketten

Bitte vergleichen Sie die beiden folgenden Codefragmente:

Beispiel
let promiseA = fetch("/resources/test.txt");
promiseA.then(onFulfilled, onRejected1);
promiseA.catch(onRejected2);
Beispiel
let promiseA = fetch("/resources/test.txt");
promiseA.then(onFulfilled, onRejected)
        .catch(onRejected);

Beide Varianten verwenden fetch, um eine Serverressource anzufordern, und speichern das erzeugte Promise-Objekt als promiseA. Auf der linken Seite erfolgen die nachfolgenden Aufrufe von then und catch ausdrücklich auf diesem Objekt. Die rechte Seite verwendet dagegen eine andere Notation. Sie ruft zunächst then auf promiseA auf, hängt den Aufruf von catch dann aber daran an, d. h. catch wird auf dem Objekt aufgerufen, das von then zurückgegeben wird.

Ein häufig genutztes Pattern beim API Design ist, dass Methoden this zurückgeben, um auf einfache Weise mehrere Methodenaufrufe auf dem gleichen Objekt zu unterstützen. Das ist bei Promises nicht der Fall. Es ist vielmehr so, dass von then ein neues Promise erzeugt wird, das promiseB genannt werden soll. promiseB ist solange im pending Zustand, bis die in diesem then, catch oder finally Aufruf registrierte Callbackfunktion ausgeführt wurde. Was dann geschieht, hängt vom Rückgabewert der Callbackfunktion ab – wobei es unerheblich ist, ob das eine Callbackfunktion für fulfilled' oder 'rejected ist:

  • Ein skalarer Wert, oder ein Objekt, das **kein** Promise ist: Das neue Promise wird auf fulfilled gesetzt und sein Wert ist der Rückgabewert der Callbackfunktion
  • Die Funktion wirft mit throw einen Fehler: Das neue Promise wird auf rejected gesetzt und sein Wert (die Fehlerursache) ist der geworfene Fehler
  • Die Funktion gibt ein drittes Promise zurück, promiseC. In diesem Fall wird das Verhalten von promiseC in promiseB übernommen, d. h. die weiteren Reaktionen hängen vom Status von promiseC ab.

Es gibt noch eine weitere Sache zu beachten:

Beispiel
fetch("/resources/test.txt");
.then(onFulfilled);
.catch(onRejected);

Was passiert, wenn fetch sein Promise auf rejected setzt? Der then Aufruf hat lediglich einen Handler für fulfilled registriert, es gibt also im Promise A keinen registrierten Handler, dessen Rückgabewert interpretierbar wäre.

Die Lösung besteht darin, dass das von den Registrierungsfunktionen erzeugte Promise in dem Fall, dass kein Handler vorliegt, den Zustand des ursprünglichen Promise übernimmt. Ein Reject für Promise A führt damit automatisch zum Reject des Promise B, wodurch der durch catch registrierte Callback aufgerufen wird. Der fulfilled Callback hat aber ebenfalls die Möglichkeit, den rejected Callback auszulösen, indem er mit throw ein Fehlerobjekt wirft.

Schauen Sie sich dazu einmal das Beispiel an, das im Wiki auf der Seite von fetch gezeigt wird:

Beispiel
fetch('kurse.php')            // Erzeugt Promise A
.then(function(response) {    // Registriert fulfilled-Callback 1 auf A
  if (response.ok)
    return response.json();   // Gibt Promise E zurück!
  else
    throw new Error('Kurse konnten nicht geladen werden');
})                            // Gibt Promise B zurück
.then(function(json) {        // Registriert fulfilled-Callback 2 auf B
  // Hier Code zum einarbeiten der Kurse in die Anzeige
})                            // Gibt Promise C zurück
.catch(function(err) {        // Registriert rejected-Callback auf A
  // Hier Fehlerbehandlung
});                           // Gibt Promise D zurück

Betrachten wir die möglichen Codepfade. Beim Durchlaufen dieses Codestücks werden insgesamt vier Promises erzeugt: ein Promise A von fetch, ein Promise B und Promise C von den then-Funktionen und schließlich ein Promise D von der catch-Funktion auch. Promise D wird allerdings nirgends verwendet.

Falls der HTTP Request technisch fehlschlägt, wird Promise A auf rejected gesetzt. Weil für Promise A und B keine Reject-Callbacks registriert sind, führt dieser Reject zum automatischen Reject der Promises B und C, woraufhin der Reject-Callback von Promise C aufgerufen wird.

Bei technischem Erfolg wird Promise A auf fulfilled gesetzt und der Callback 1 aufgerufen. Er erhält das Response-Objekt des fetch-API übergeben und prüft, ob eine HTTP Antwort der Kategorie OK gegeben wurde. Wenn nicht, wird ein Error-Objekt geworfen, was das Promise B auf rejected setzt, und Promise C automatisch auch. Auch in diesem Fall wird der Reject-Callback von Promise C aufgerufen.

Bei technischem Erfolg wird die json-Methode des Response-Objekts genutzt, um den Antwortdatenstrom als JSON-String zu lesen und in ein Objekt zu konvertieren. Bis zu diesem Zeitpunkt ist der eigentliche Inhalt der Serverantwort noch gar nicht gelesen worden, die json-Methode muss also vom Netzwerk Daten lesen und führt dies ebenfalls asynchron aus. Deswegen gibt sie ihrerseits ein neues Promise E zurück.

Dieses Promise kann, je nach Verlauf des Einlesens, vom fetch API auf fulfilled oder rejected gesetzt werden. Nur wenn ein korrektes JSON Objekt erstellt werden konnte, wird es fulfilled und Callback 2 wird aufgerufen, um es zu verarbeiten. Andernfalls wird Promise E rejected, was zum Reject von Promise B und C führt und zum Aufruf des Reject-Callbacks auf Promise C.

Sie sehen, dass eine scheinbar einfache Verkettung von Funktionsaufrufen zu einem Schwarm an Promises führen kann. Dennoch entsteht ein natürlich aussehender Ablauf, in dem zwei Arbeitsschritte durchgeführt werden und von einer gemeinsamen Fehlerbehandlung abgesichert werden.

Promises erzeugen

Betrachten wir nun, wie man ein Promise selbst erzeugt und steuert. Es wurde schon erwähnt, dass man hierfür der Promise Konstruktorfunktion eine Steuerfunktion übergibt.

Syntax

new Promise(function(resolve, reject) { ... });

resolve und reject sind Callback-Funktionen. Ihre Funktion muss sich dieser Funktionen bedienen, um den Erfolg oder Misserfolg des Vorgangs zu melden.

Ein Warte-Promise
function warte(zeit, info) {
  if (zeit < 0)
    return Promise.reject(new Error("Negative Wartezeit!"));
  else if (zeit < 10)
    return Promise.resolve(info);
  else
    return new Promise(function(resolve, reject) {
      const button = document.getElementById("abbruch-button");
      const timeoutId = setTimeout(completeWait, zeit);
      button.addEventListener("click", abortWait, { once: true });

      function completeWait() {
        button.removeEventListener("click", abortWait)
        resolve(info);
      }

      function abortWait() {
        clearTimeout(timeoutId);
        reject(new Error("Wait time cancelled"));
      }
    });
}

warte(2000, "Test")
.then(info => {
  console.log("Wartezeit für " + info + " abgelaufen");
})
.catch(err => console.log(err));

Das Beispiel ist etwas umfangreicher. Es zeigt eine Funktion warte, die eine Wartezeit mittels eines Promise realisiert. Die Funktion warte erhält die Wartezeit und ein Datenobjekt als Parameter. Sie gibt ein Promise zurück, das nach Ablauf der Wartezeit auf fulfilled gesetzt wird. Bei falschen Parametern oder wenn die Wartezeit abgebrochen wird wird es auf rejected gesetzt.

Das erste, was die Funktion tut, ist eine Plausibilitätsprüfung auf die Wartezeit. Eine negativer Wartezeit soll einen Fehler darstellen und das Promise auf rejected gesetzt werden. Dafür bietet der Promise-Konstruktor die statische Methode reject an.

Als zweites soll eine geringe Wartezeit ignoriert werden – setTimeout kann kleine Wartezeiten ohnehin nicht genau genug steuern. Mit Hilfe von Promise.resolve wird für Zeiten unter 10 Millisekunden ein Promise erzeugt, das sofort fulfilled ist.

Andernfalls wird ein eigenes Promise erzeugt und eine Steuerfunktion übergeben, die den Wartevorgang mit Hilfe von setTimeout realisiert. Um auch eine Reject-Möglichkeit zu haben – nicht, dass das wirklich sinnvoll wäre – wird ein Einmalhandler für 'click' auf einem Button registriert, der die Wartezeit abbrechen kann. Natürlich ist dieser 'click' Handler bei einer regulär abgelaufenen Wartezeit auch wieder zu entfernen.

Sie sehen, dass die Erstellung eigener Promises schnell komplex werden kann. Eine gründliche Kenntnis von Funktionen und Closures ist unerlässlich, um die Steuerung korrekt aufbauen zu können.

Der Gebrauch dieser Warte-Funktion ist dann aber wieder einfach, wie das im Anschluss gezeigte Aufrufbeispiel zeigt.

Weblinks

deutsch: