JavaScript/Objekte/Promise/async await
Die von Promises bereitgestellte Funktionalität kommt zu einem Preis: Du musst Deinen Code auf Callback-Funktionen verteilen. Wenn Deine Callbacks anonyme Funktionen sind, die direkt an then oder catch übergeben werden, stehen Dir in den Callbacks zwar noch die Variablen der Funktion zur Verfügung, in der Du die then- und catch Aufrufe programmiert hastt, 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 Ergebnis 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 ebenfalls neu eingeführten await-Operator. Async Funktionen können unterbrochen und zu einem späteren Zeitpunkt an der Unterbrechungsstelle fortgesetzt werden. Der await Operator stellt eine solche Unterbrechungsstelle dar.
Das ist vergleichbar mit einer Generator-Funktion. Auch die kann unterbrochen werden, wobei yield die Unterbrechungsstelle markiert. Die Rahmenbedingungen, die zur Unterbrechung und Fortsetzung führen, unterscheiden sich natürlich, aber das Konzept, mitten in einer Funktion Pause zu machen und anderswo fortzusetzen, ist identisch. Ohne dieses Unterbrechungsfeatures müsstest Du Dich mit dem Thema Inversion of Control auseinandersetzen, und das ist wirklich kein Spaß, sobald Schleifen ins Spiel kommen.
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.Inhaltsverzeichnis
Zum Vergleich: Promises ohne async/await
Das folgende Beispiel zeigt die Wartefunktion aus dem Promise-Tutorial, 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.
function warte(sekunden, wert) {
return new Promise( (resolve, reject) => setTimeout(resolve, sekunden, wert) );
}
function runTest() {
console.log("Gleich geht's los!");
warte(1000, "Hallo")
.then(wert => console.log(wert));
}
runTest();
console.log("Bitte etwas Geduld!");
Beachte die nicht so bekannte Eigenschaft von setTimeout, mehr als zwei Parameter empfangen zu können. Alle Parameter, die du hinter der Wartezeit übergibst, werden an die Callback-Funktion weitergeleitet. Weil der .then()-Callback nur einen Parameter unterstützt, ergibt es keinen Sinn, für warte() 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().
function warte(sekunden, ...wert) {
// unverändert
}
async function runTest() {
console.log("Gleich geht's los!");
let ergebnis = await warte(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.
Die Ablauflogik im Inneren von JavaScript ist bei Verwendung von await etwas anders als bei Verwendung von then() mit Callbacks. Ein Callback ist eine eigene Funktion, mit eigenem Scope. Ein then-Callback kann eine Unterfunktion der async-Funktion sein oder auch komplett eigenständig.
Mit async/await geschieht etwas anderes. async-Funktionen werden für die JavaScript-Engine anders übersetzt als normale Funktionen, sie erhalten Zusätze, die es ermöglichen, die Funktionsausführung an einer bestimmten Stelle zu unterbrechen und dort später wieder fortzusetzen. Was in einer async-Funktion intern geschieht, lässt sich mit .then()-Callbacks nur sehr umständlich nachbilden und wir wollen hier keine Zeit darauf verschwenden, eine solche Simulation vorzustellen.
Das Ergebnis ist jedenfalls, dass eine async-Funktion, die auf await trifft, auf dem „awaiteten“ Promise Callbacks für fulfilled und rejected registriert und dann mit Rückgabe eines Promise endet. Das zurückgegebene Promise wird erfüllt, sobald die async-Funktion auf ein return-Statement trifft (oder das Ende des Funktionsblocks erreicht), und es wird zurückgewiesen, wenn die async-Funktion mit throw beendet wird.
async kein reserviertes Wort. Aus diesem Grund schießt die Uralt-Logik von JavaScript, die "vergessene" Semikolons automatisch ergänzt, quer, wenn Du zwischen async und function einen Zeilenumbruch setzt. Hinter async darf kein Zeilenumbruch folgen!. Ältere Code-Minifizierer können so ungültigen Code generieren!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..then() oder await abholen.Was ist mit Promise-Ketten?
Du kennst 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, musst Du die zum Inhalt passende Methode auf dem Response-Objekt aufrufen, die den Inhalt liest und passend transformiert. Auch diese Funktion liefert ein Promise, auf das Du erneut warten musst. Die Zweiteilung bewirkt, dass Du auf die Inhalte der Header in der HTTP Antwort reagieren kannst, bevor der eigentliche Inhalt gelesen und verarbeitet wird. Und Du hast die Möglichkeit, die Verarbeitungsteile nach Wunsch zusammenzubauen. Zum Beispiel kannst Du eine Funktion schreiben, die Text vom Server lädt, und ein Promise zurückgibt, das diesen Text bereitstellt:
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:
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 kleine 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.
Ob man getDataFrom als async-Funktion mit await schreibt oder einfach die Standardmethode mit .then verwendet, ist übrigens egal. Die folgende Variante funktioniert genauso gut:
function getDataFrom(url) {
return fetch(url)
.then(response => response.text())
}
async function receiveData() {
let text = await getDataFrom("/my/data");
console.log("Got " + text);
}
receiveData();
Beachte bitte, dass receiveData() wiederum async ist. Wenn Du den erhaltenen Text verarbeiten willst, musst Due das – so wie bei Promises auch – innerhalb dieser Funktion tun.
Was ist mit mehreren asynchronen Abläufen
Es kann vorkommen, dass Du mehr als ein Ergebnis asynchron bestimmen musst. Denke beispielsweise an eine Wetterstation, die einmal pro Minute die Werte mehrerer Thermometer über einen HTTP-Service abfragt, und Du möchtest 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, könnte man so programmieren:
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 Du 50 Stationen abfragst, 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 dann auf alle zusammen zu warten. Das gemeinsame Abschicken und Speichern der entstehenden Promises lässt sich elegant mit Hilfe der Array-Methode map() erreichen, und für das Warten verwendet man die statische Promise-Methode all:
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 Du möchtest, kannst Du auf die promises-Variable auch 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 verwendest Du .catch oder den zweiten Parameter von .then, um einen Callback zum Behandeln eines reject zu hinterlegen. Wenn Du mit await auf ein Promise wartest, führt ein reject des Promise dazu, dass ein Fehler geworfen wird, den Du mit try/catch abfangen musst. Wenn auch nicht zwingend in der async-Funktion!
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();
Aber, wie gesagt, die Fehlerbehandlung muss nicht zwingend in der async-Funktion geschehen. Lässt man den try/catch weg, wird der geworfene Fehler in der Aufrufkette nach oben weitergereicht und gelangt so in die async-Ablaufsteuerung. Dort wird jeder throw eingefangen und bewirkt, dass das Promise, das von der async-Funktion zurückkommt, rejected wird und das geworfene Objekt zum Wert des Promise wird.
Du kannst also auch auf diesem Promise .catch() verwenden und darüber einen Handler registrieren, der sich um den Fehler kümmert.
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));
Statt .catch() auf dem receiveData-Ergebnis aufzurufen, könntest Du auch noch mit await auf das Ergebnis von receiveData warten und diesen await mit try/catch umgeben. Oder den Fehler weiter nach oben treiben lassen. Es gibt viele Möglichkeiten.
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: Jein. async/await ist Syntaxzucker. Zusamen mit try, catch und finally steht das ganze Spektrum der Promise-Behandlung zur Verfügung. Allerdings führt das Feature der unterbrechbaren Funktion dazu, dass mit Hilfe von await ein asynchroner Ablauf deutlich lesbarer formuliert werden kann.
Irrtumskorrektur: An dieser Stelle stand, dass eine asynchrone Funktion von Beginn an als Microtask ausgeführt würde. Das war falsch. Eine async Funktion läuft bis zum ersten await synchron und kehrt dann mit einem Promise zurück.
ToDo (weitere ToDos)