Startseite / Artikel

Refaktorisieren von Promise-Ketten zu Async/Await ohne Veränderung des Verhaltens

Wie man .then()-Ketten in lesbaren async/await-Code umwandelt, was await tatsächlich pausiert, wann Schritte parallel ausgeführt werden sollten und wie try/catch .catch() ersetzt.

Verlag: REACTAPP.TOP7 Min. Lesezeit1597 Wörter

Quellen: Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Promises haben JavaScript vor verschachtelten Callbacks gerettet, doch eine Kette abhängiger .then()-Schritte liest sich immer noch wie eine komplizierte Verkabelung statt wie eine einfache Abfolge von Anweisungen. async/await schließt diese Lücke, indem es asynchronen Code so aussehen lässt, als wäre er gewöhnlicher, von oben nach unten ausgeführter Code, während die zugrunde liegenden Promises erhalten bleiben. Diese Anleitung zeigt, wie man eine Promise-Kette sicher umwandelt, was await tatsächlich pausiert, wann sequenzielle await-Anweisungen Zeit verschwenden und wie sich die Fehlerbehandlung verändert, damit Sie bestehenden Code ohne Änderung seiner Funktionalität refaktorieren können.

Von einer Then-Kette zu einer linearen Funktion

Hier ist eine typische Kette, bei der jeder Schritt vom Ergebnis des vorherigen abhängt: Zuerst wird ein Benutzer geladen, anschließend seine Bestellungen und danach die Quittungen für diese Bestellungen, wobei es einen Handler für alle Fehlern gibt.

getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getReceipts(orders))
  .then(receipts => {
    console.log(receipts);
  })
  .catch(err => handleError(err));

Das ist korrekt und weitaus besser als verschachtelte Callbacks, doch man muss die Kette weiterhin visuell nachverfolgen, um den Datenfluss zu verstehen. async/await, das in ES2017 standardisiert wurde, soll genau diese letzte Hürde beseitigen. Die gleiche Logik in überarbeiteter Form sieht so aus:

async function loadData(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    console.log(receipts);
  } catch (err) {
    handleError(err);
  }
}

Das Verhalten entspricht der Kette: Jeder Schritt beginnt erst, nachdem der vorherige abgeschlossen ist, und jedes Versagen wird im einzigen Fehlerhandler behandelt. Was sich geändert hat, ist die Lesbarkeit. Jeder Zwischenwert erhält nun einen Namen, und der Ablauf ist von oben nach unten lesbar.

Haben Sie stets eines im Hinterkopf: async/await ersetzt Promises nicht. Es handelt sich dabei um syntaktischen „Zucker“ über ihnen. Promises erledigen weiterhin die asynchrone Arbeit; die Syntax ändert lediglich, wie man sie schreibt und liest.

Was das Schlüsselwort async verändert

Das Hinzufügen von async vor eine Funktion hat zwei Auswirkungen. Die untenstehende Funktion gibt ein einfaches Objekt zurück:

async function getUser(id) {
  return { id, name: "Aarav" };
}

Es gibt immer eine Promise zurück

Auch wenn getUser ein gewöhnliches Objekt zurückgibt, liefert der Aufruf eine Promise, die zu diesem Objekt auflöst. Somit kann sie wie jede andere Promise verarbeitet werden:

getUser(1).then(user => console.log(user));
// { id: 1, name: "Aarav" }

Falls die Funktion einen Fehler wirft, wird die zurückgegebene Promise abgelehnt, anstatt dass der Fehler synchron weitergeleitet wird. Das wird leicht übersehen, wenn eine zuvor synchrone Funktion zu async wird: Jeder Aufrufer, der früher den Rückgabewert direkt verwendete, erhält nun eine Promise und muss entsprechend angepasst werden.

Es ermöglicht das Verwenden von await im Funktionskörper

await ist innerhalb einer async-Funktion gültig. Moderne JavaScript-Versionen erlauben außerdem await auf oberster Ebene in ES-Modulen, was für Skripte und die Initialisierung von Modulen praktisch ist – in regulären Funktionen benötigt man jedoch weiterhin async.

Was wird durch await tatsächlich pausiert?

Eine await-Expression gibt die Kontrolle zurück, bis ihre Promise auf eine Weise oder andere gelöst wird. Ein Erfolg führt dazu, dass die Expression den gelösten Wert zurückgibt; ein Misserfolg veranlasst diese Zeile dazu, einen Fehler auszulösen, genauso wie es eine throw-Anweisung tun würde. Das Beispiel gibt eine Meldung aus, wartet auf den Benutzer und protokolliert anschließend das Ergebnis:

async function loadUser() {
  console.log("Fetching user...");
  const user = await getUser(1); // pauses here until getUser's promise settles
  console.log("Got user:", user);
}

Die Ausgabe erscheint in dieser Reihenfolge:

Fetching user...
Got user: { id: 1, name: "Aarav" }

Der wichtige Punkt ist, was „Pausieren“ bedeutet. Nur diese eine Funktion wird an dieser genauen Stelle ausgesetzt. Der Rest des Programms, einschließlich anderer Codeblöcke, Timer sowie eingehender Anfragen auf einem Server, läuft weiter. Wenn die Promise abgearbeitet ist, setzt die Funktion ihren Lauf an der Stelle fort, an der sie aufgehört hat, wobei der bereitgestellte Wert der Variablen zugewiesen wird.

Serielle awaits und ihre versteckten Kosten

Mehrere awaits hintereinander werden nacheinander ausgeführt:

async function loadDashboard(id) {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  const receipts = await getReceipts(orders);
  return receipts;
}

Jede Zeile wartet auf ihren eigenen Schritt, bevor der nächste beginnt – dadurch entsteht das Verhalten mehrerer aufgereihter .then()-Aufrufe ohne die eigentliche Kette. Das ist korrekt, wenn jeder Schritt das vorherige Ergebnis benötigt, wie hier: Man kann keine Bestellungen abrufen, bevor man den Benutzer kennt.

Probleme entstehen, wenn derselbe Stil auf Operationen angewendet wird, die voneinander unabhängig sind. Im untenstehenden Dashboard-Loader sind der Benutzer, die Benachrichtigungen und die Empfehlungen unabhängig voneinander, doch jede Anfrage wartet auf die vorherige:

async function loadDashboard() {
  const user = await getUser();
  const notifications = await getNotifications();
  const recommendations = await getRecommendations();

  return { user, notifications, recommendations };
}

Die Gesamtzeit ergibt sich dann aus der Summe aller drei Anfragen statt aus der Zeit der langsamsten davon.

Unabhängige Aufgaben gleichzeitig starten

Weil die drei Aufrufe unabhängig sind, können Sie sie gleichzeitig starten und mit Promise.all() auf alle warten:

async function loadDashboard() {
  const [user, notifications, recommendations] = await Promise.all([
    getUser(),
    getNotifications(),
    getRecommendations()
  ]);

  return { user, notifications, recommendations };
}

Sämtliche drei Anfragen werden gleichzeitig ausgeführt, und durch das Destructuring erhalten Sie die Ergebnisse in derselben Reihenfolge wie der Eingabearray. Ein wichtiges Verhalten zu kennen: Promise.all() erfüllt sich erst, wenn jede Promise erfüllt ist, und lehnt ab, sobald eine einzige Promise abgelehnt wird, ohne auf die anderen zu warten. Wenn Sie alle Ergebnisse – unabhängig von Fehlern – erhalten möchten, eignet sich Promise.allSettled() besser. Die Vor- und Nachteile dieser Kombinatoren werden in „Choosing between Promise.all, Promise.race and sequential awaits“ erläutert.

Fehlerbehandlung mit try und catch

Mit async/await ersetzt der bekannte try/catch-Block .catch(). Somit teilen sich synchrone und asynchrone Fehler schließlich dieselbe Struktur:

async function loadUser(id) {
  try {
    const user = await getUser(id);
    console.log(user);
  } catch (err) {
    console.log("Failed to load user:", err.message);
  }
}

Falls die von getUser(id) zurückgegebene Promise abgelehnt wird, springt die Kontrolle direkt zum catch-Block, genauso wie es bei einem von synchronem Code ausgelösten Fehler der Fall wäre. Beachten Sie, dass diese Funktion den Fehler verarbeitet und anschließend normal weiterläuft, sodass ihre eigene Promise mit undefined erfüllt wird. Die Aufrufer können nicht erkennen, dass das Laden fehlgeschlagen ist. Wenn ein Aufrufer reagieren muss, sollte er nach dem Protokollieren den Fehler erneut werfen oder stattdessen ein explizites Ergebnis zurückgeben.

Ein Handler für mehrere Schritte

Ein einziger try-Block kann eine ganze Abfolge von awaits abdecken:

async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    return receipts;
  } catch (err) {
    console.log("Something failed in the dashboard load:", err.message);
  }
}

Sollte ein Schritt fehlschlagen, wechselt die Ausführung direkt zum catch-Block, sodass man keinen separaten Handler pro Schritt benötigt. Dies spiegelt die zentralisierende Wirkung von .catch() am Ende einer Kette wider. Wenn unterschiedliche Schritte unterschiedliche Wiederherstellungsmaßnahmen erfordern – beispielsweise nur den Anfrageversuch für Rechnungen wiederholen –, sollte man um diesen Schritt herum einen enger gefassten try-Block einbetten.

Vergleich der beiden Ansätze nebeneinander

Hier ist derselbe Lader in beiden Formen:

// Promises
function loadDashboard(id) {
  return getUser(id)
    .then(user => getOrders(user.id))
    .then(orders => getReceipts(orders))
    .catch(err => console.log("Failed:", err.message));
}

// async/await
async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    return await getReceipts(orders);
  } catch (err) {
    console.log("Failed:", err.message);
  }
}

Auch die eine Version ändert nicht die zugrunde liegende asynchrone Verarbeitung. Die Entscheidung richtet sich danach, welche Version von den Nutzern leichter gelesen und gewartet werden kann – bei mehrschrittiger Logik gewinnt in der Regel async/await. Ein wichtiger Punkt in der zweiten Version: return await getReceipts(orders) innerhalb von try ist entscheidend. Wenn man return getReceipts(orders) ohne await schreibt, wird die Promise bereits vor deren Abwicklung zurückgegeben, wodurch ein Fehler aus diesem letzten Schritt den catch-Block völlig verlassen würde.

Häufige Fragen

Blockiert await die gesamte Anwendung?

Nicht unbedingt. Er pausiert nur die asynchrone Funktion, in der er verwendet wird. Andere Codeabschnitte, einschließlich weiterer Anfragen, laufen weiter, während diese Funktion wartet.

Kann man await außerhalb einer asynchronen Funktion verwenden?

Nicht in regulären Funktionen. Die Ausnahme bildet das oberste Ebene await in ES-Modulen, das auf Modulebene funktioniert, aber nicht innerhalb gewöhnlicher, nicht-asynchroner Funktionen.

Ist async/await schneller als Promises?

Nicht. Es basiert auf Promises, sodass durch den Wechsel der Syntax kein Geschwindigkeitsvorteil zu erwarten ist. Der Vorteil liegt in der Lesbarkeit und Wartbarkeit. Eigentliche Beschleunigungen entstehen durch die gleichzeitige Ausführung unabhängiger Aufgaben, nicht durch das Schlüsselwort.

Was passiert ohne try/catch?

Falls eine erwartete Promise abgelehnt wird und nichts sie auffängt, wird die eigene Promise der asynchronen Funktion abgelehnt. Wenn auch kein Aufrufer diese Ablehnung handhabt, meldet die Laufzeitumgebung eine unverarbeitete Ablehnung, was in aktuellen Node.js-Versionen standardmäßig den Prozess beendet. Behandeln Sie Fehler mit try/catch innerhalb der Funktion oder mit .catch() an der Stelle, an der sie aufgerufen wird. Weitere Fälle dieser Art finden Sie unter gängige Missverständnisse zu async/await, die Produktionsfehler verursachen.

Kernpunkte

  • async/await ändert die Schreibweise von asynchronem Code, nicht seine Ausführung; die Promises erledigen weiterhin die Arbeit.
  • Eine async-Funktion gibt immer eine Promise zurück, wodurch die Umwandlung einer Funktion in async ihr Verhalten für jeden Aufrufer verändert.
  • await pausiert nur seine eigene Funktion, niemals das gesamte Programm.
  • Aufgaben, die auf await warten, werden nacheinander ausgeführt, während unabhängige Aufgaben mit Promise.all() oder Promise.allSettled() gleichzeitig gestartet werden können.
  • Ein try/catch-Block um mehrere await-Anweisungen zentralisiert die Fehlerbehandlung; vergessen Sie nicht, Fehler erneut auszuschließen, wenn die Aufrufer davon erfahren müssen, und verwenden Sie return await innerhalb von try.