JavaScript/Objekte/Promise/async await

Aus SELFHTML-Wiki
< JavaScript‎ | Objekte‎ | Promise(Weitergeleitet von Async)
Wechseln zu: Navigation, Suche

Die von Promises bereitgestellte Funktionalität kommt zu einem Preis: Sie müssen Ihren Code auf Callback-Funktionen verteilen. Wenn Ihre Callbacks anonyme Funktionen sind, die direkt an then oder catch übergeben werden, stehen Ihnen in den Callbacks zwar noch die Variablen der Funktion zur Verfügung, in der Sie die then- und catch Aufrufe programmiert hatten, aber der Lesefluss wird unterbrochen.

Mit ECMAScript 2017 wurde ein neues Konzept eingeführt, das die Lesbarkeit verbessert: Asynchrone Funktionen. Dazu notiert man vor function das Schlüsselwort async. Eine solche Funktion gibt ihr grundsätzlich als Promise zurück, was den Aufrufer dazu zwingt, dieses Ergebnis explizit als asynchronen Wert zu erwarten. Das kann entweder durch die Verwendung der bekannten .then() und .catch() Methoden von Promises geschehen, oder durch den await. Operator. Wenn JavaScript await vorfindet, so verpackt es den restlichen Code in der asynchronen Funktion automatisch in eine anonyme Funktion (die im Scope der asynchronen Funktion definiert wird und damit ihre Variablen nutzen kann) und übergibt sie dem Promise als .then()-Callback.

Grundsätzlich geschieht hier nichts Neues. Das, was async und await machen, konnten Sie auch schon in ECMAScript 2015 tun - Sie mussten nur die Promise-Abläufe von Hand programmieren. Genau genommen konnten Sie ähnliches auch schon vor 20 Jahren tun, nur ohne die standardisierten und optimierten Abläufe von Promises.

Beachten Sie: Außerhalb von asynchronen Funktionen war das await-Schlüsselwort ursprünglich nicht zulässig und wurde nicht einmal erkannt - was merkwürdige Fehlermeldungen auslösen kann, weil es dann nämlich wie ein Variablenname behandelt wird. Seit der ECMAScript-Version 2022 ist es aber erlaubt, in ECMAScript-Modulen auf oberster Ebene await zu verwenden.

Zum Vergleich: Promises ohne async/await

Das folgende Beispiel zeigt eine Funktion, die setTimeout in ein Promise kapselt. Man übergibt ihr eine Wartezeit und einen Wert, und erhält ein Promise, das nach Ablauf der Wartezeit mit diesem Wert resolved wird. Auf dieses Promise wird mit einem .then()-Callback gewartet.

Ein Timer-Promise
function waitFor(delay, value) {
   return new Promise( (resolve, reject) => setTimeout(resolve, delay, value) );
}

function runTest() {
   console.log("Gleich geht's los!");
   waitFor(1000, "Hallo")
   .then(wert => console.log(wert));
}

runTest();
console.log("Bitte etwas Geduld!");

Beachten Sie die nicht so bekannte Eigenschaft von setTimeout, mehr als zwei Parameter empfangen zu können. Alle Parameter, die Sie hinter der Wartezeit übergeben, werden an die Callback-Funktion weitergeleitet. Weil der .then()-Callback nur einen Parameter unterstützt, ergibt es keinen Sinn, für waitFor() mehr als einen Parameter vorzusehen.

In der Konsole wird nun zuerst "Gleich geht's los" und "Bitte etwas Geduld!" erscheinen, und eine Sekunde später das Wort "Hallo".

Das Gleiche mit await

Wie eingangs erwähnt, ist die Verwendung des await-Operators nur in einer async-Funktion oder in einem ECMAScript-Modul möglich. Wir zeigen hier die Variante mit einer async-Funktion namens runTest().

Ein Timer-Promise
function waitFor(delay, ...value) {
   // unverändert
}

async function runTest() {
   console.log("Gleich geht's los!");
   let ergebnis = await waitFor(1000, "Hallo");
   console.log(ergebnis);
}

runTest();
console.log("Bitte etwas Geduld!");

Um eine async-Funktion aufrufen, ist nichts weiter nötig. Die Funktion wird gestartet und liefert ein Promise zurück, das irgendwann resolved (oder rejected) wird. In unserem Beispiel interessiert das Ergebnis von runTest() nicht weiter.

Im Inneren von JavaScript passiert bei Verwendung von await nichts anders als bei .then(). Nur erstellt JavaScript nun die Callback-Funktion, die von .then() erwartet wird, automatisch für uns, und baut den Programmcode entsprechend um. Der Wert, der an die resolve()-Funktion des Promise übergeben wird, wird dadurch zum Wert des await-Operators.

Beachten Sie: Bis zu dem Punkt, wo await steht, wird runTest noch synchron ausgeführt. Das sieht man in der Konsolenausgabe: dort steht zuerst "Gleich geht's los" und dann erst "Bitte etwas Geduld". Der Teil nach dem await folgt dann erst eine Sekunde später.
Beachten Sie: Sie können eine async-Funktion schreiben, die kein einziges await enthält und synchron durchläuft. Trotzdem ist der Rückgabewert der Funktion ein Promise und Sie müssen ihn mit .then() oder await abholen.

Was ist mit Promise-Ketten?

Sie kennen es vielleicht aus dem Fetch-API. Man ruft fetch und bekommt ein Promise. Dieses resolved sich in ein Response-Objekt, und um mit dem Inhalt der Response etwas tun zu können, müssen Sie eine von mehreren Funktionen aufrufen, die diesen Inhalt liest und passend transformiert. Auch diese Funktion liefert ein Promise, auf das Sie erneut warten müssen. Die Zweiteilung bewirkt, dass Sie auf die Inhalte der Header in der HTTP Antwort reagieren können, bevor der eigentliche Inhalt gelesen und verarbeitet wird. Und Sie haben die Möglichkeit, die Verarbeitungsteile nach Wunsch zusammenzubauen. Zum Beispiel können Sie eine Funktion schreiben, die Text vom Server lädt, und ein Promise zurückgibt, das diesen Text bereitstellt:

Beispiel
function getDataFrom(url) {
   return fetch(url)
          .then(response => response.text())
}

getDataFrom("/my/data")
.then(text => console.log("Got " + text));

getDataFrom ruft fetch auf und veranlasst das Lesen der Response als Text. Der then-Aufruf dort liefert seinerseits ein Promise, das sich dann erfüllt, wenn die Callbackfunktion von then ausgeführt wurde (und keinen Fehler auslöst). Dieses Promise wird zurückgegeben und vom Aufrufer verwendet, um seinerseits auf das Bereitstellen des Texts zu reagieren.

Mit await sähe das so aus:

Beispiel
async function getDataFrom(url) {
   let response = await fetch(url);
   return response.json();
}

async function receiveData() {
   let text = await getDataFrom("/my/data");
   console.log("Got " + text);
}

receiveData();

Die einzige Unschönheit an diesem Konstrukt ist, dass getDataFrom() nicht exakt das Promise zurückliefert, das response.json() zurückgibt. Denn async-Funktionen verpacken ihre Rückgabe in eigene Promises. Aber, wie eingangs erwähnt: wenn ein Promise mit einem anderen Promise resolved wird, bilden diese beiden Promises ein gekoppeltes Pärchen, so dass der resolve-Wert des json()-Promise vom await in receiveData verwendet werden kann.

Da await nichts weiter darstellt als Syntaxzucker für einen .then-Aufruf, kann man beide Ansätze auch verbinden und spart sich dieses Extrapromise:

Beispiel
function getDataFrom(url) {
   return fetch(url)
          .then(response => response.text())
}

async function receiveData() {
   let text = await getDataFrom("/my/data");
   console.log("Got " + text);
}

receiveData();

Beachten Sie, dass receiveData() wiederum async ist. Wenn Sie den erhaltenen Text verarbeiten wollen, müssen Sie das - so wie bei Promises auch - innerhalb dieser Funktion tun.

Was ist mit mehreren asynchronen Abläufen

Es kann vorkommen, dass Sie mehr als ein Ergebnis asynchron bestimmen müssen. Denken Sie an eine Wetterstation, die einmal pro Minute die Werte mehrerer Thermometer über Webservices abfragt, und Sie möchten die Durchschnittstemperatur anzeigen.

Mal angenommen, wir hätten ein Array stationen, in dem sich Objekte befinden, die die Wetterstationen repräsentieren. Diese Objekte haben eine Methode ablesen, die ein Promise für den Wert der Station liefert. Um alle Stationen abzufragen, kann man so programmieren:

await mehrerer Services
   const temperaturen = [];
   for (let i=0; i<stationen.length; i++) {
      temperaturen[i] = await stationen[i].ablesen();
   }

Nachteilig daran ist, dass die Abfrage für Station 2 bei diesem Vorgehen erst dann beginnt, wenn Station 1 ihren Wert geliefert hat. Wenn Sie 50 Stationen abfragen, kann dieser Verzug bedeutsam werden. Die Bequemlichkeit von await kommt hier um den Preis der Laufzeit, und es ist besser, in dieser Situation auf await zu verzichten. Besser ist es, zunächst alle Anfragen loszuschicken und gemeinsam zu warten. Das gemeinsame Abschicken kann man mit Hilfe der Array-Methode map() erreichen, und für das Warten verwendet man die statische Promise-Methode all:

await mehrerer Services
  const promises = stationen.map(station => station.ablesen());
  const temperaturen = await Promise.all(promises);

Die map()-Methode ruft für jedes Array-Element eine Callback-Funktion auf und erzeugt ein neues Array, das alle Funktionsergebnisse enthält. In diesem Fall ist das ein Array von Promises. Ein solches Array wird von der Funktion Promise.all() erwartet. Man erhält von ihr ein neues Promise, das sich erfüllt, wenn alle an Promise.all übergebenen Promises erfüllt sind. Der Wert des Promises ist ein Array, in dem die Werte aller übergebenen Promises stehen.

Es spricht nichts dagegen, auf dieses Sammel-Promise mit await zu warten. Und wenn Sie möchten, können Sie auf die promises-Variable verzichten und alles in einer Zeile erledigen.

Und wenn das Versprechen gebrochen wird?

Es gibt viele Gründe, warum ein Promise rejected wird. In der funktionalen Schreibweise verwenden Sie .catch oder den zweiten Parameter von .then, um einen Callback zum Behandeln eines reject zu hinterlegen. Wenn Sie mit await auf ein Promise warten, führt ein reject des Promise dazu, dass ein Fehler geworfen wird, den Sie mit try/catch abfangen können.

Wenn Sie .then und .catch verwenden, können Sie auf resolve und reject eines Promises reagieren. Das geht mit await nicht. Statt dessen verwenden Sie das try/catch-Konstrukt:

Abfangen eines reject auf einem mit await erwarteten Promise
async function getDataFrom(url) {
   let response = await fetch("/my/data");
   return response.json();
}

async function receiveData() {
   try {
      let text = await getDataFrom("/my/data");
      console.log("Got " + text);
   } 
   catch(fehler) {
      console.log("Datenempfang gescheitert - " + fehler);
   }
}
receiveData();
Beachten Sie: Wenn Sie den reject eines Promise, das Sie mit await erwarten, mit try/catch behandeln wollen, muss das innerhalb der async-Funktion geschehen. Würden Sie im gezeigten Beispiel den Aufruf von receiveData in try/catch einschließen, würden Sie nichts fangen. Sie können aber auf die funktionale Schreibweise zurückgreifen und auf dem Rückgabewert von receiveData die .catch-Methode aufrufen. Es ist ja eine asynchrone Funktion und liefert deshalb ein Promise.
Alternative zu try/catch
async function getDataFrom(url) {
   let response = await fetch("/my/data");
   return response.json();
}

async function receiveData() {
   let text = await getDataFrom("/my/data");
   console.log("Got " + text);
}

receiveData()
.catch(fehler => console.log("Datenempfang gescheitert - " + fehler));

Was ist besser

Die Frage müsste man vielleicht anders stellen: kann ich mit async/await Dinge tun, die mit then/catch nicht möglich sind? Oder anders herum: kann man Dinge nicht tun?

Nach unserer Kenntnis lautet die Antwort darauf: Nein. async/await ist Syntaxzucker. Zusamen mit try, catch und finally steht das ganze Spektrum der Promise-Behandlung zur Verfügung. Ob ein JavaScript Just-in-Time Compiler aus async/await besseren oder schlechteren Code macht, können wir nicht sagen.

Es gibt einen kleinen Unterschied: Eine asynchrone Funktion wird von Beginn an als Microtask ausgeführt. Ist die Funktion synchron, werden die Befehle vor der Promise-Anforderung noch im UI-Task verarbeitet. Dadurch können Unterschiede im Timing entstehen. Das ist weder gut noch schlecht, man muss es einfach nur wissen und beachten.

Synchron
function getdata() {
   // Teil 1: Request vorbereiten
   fetch(url).then(response => {
      // Teil 2: Response verarbeiten
   })
}

getdata();
somethingElse();
Asynchron
async function getdata() {
   // Teil 1: Request vorbereiten
   fetch(url).then(response => {
      // Teil 2: Response verarbeiten
   })
}

getdata();
somethingElse();

Im synchronen Fall wird Teil 1 von getdata ausgeführt, bevor somethingElse läuft. Im asynchronen Fall ist das anders. Nun läuft getdata komplett als Mikrotask und deshalb wird zunächst somethingElse ausgeführt und der komplette noch offene JavaScript-Stack abgearbeitet. Erst dann beginnt die Verarbeitung der Mikrotasks und Teil 1 von getdata läuft an.

Diese Änderung kann gravierende Folgen haben, wenn Teil 1 auf Daten zugreift, die von somethingElse verändert werden. Sie müssen bei asynchronem Code sehr genau auf das Timing achtgeben.

ToDo (weitere ToDos)

Eigenen Artikel für Async-Funktionen erstellen --Rolf b (Diskussion) 11:23, 26. Apr. 2023 (CEST)