Beherrschen von asynchronem JavaScript mit Promises und Async/Await
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Einleitung
In der Welt der Webentwicklung ist JavaScript eine unangefochtene Macht. Seine Single-Threaded-Natur, die zwar bestimmte Aspekte der Programmierung vereinfacht, birgt einzigartige Herausforderungen bei der Bewältigung zeitaufwendiger Operationen wie dem Abrufen von Daten von einem Server, dem Lesen aus einer Datenbank oder der Verarbeitung von Benutzereingaben. Das Blockieren des Hauptthreads für diese Operationen würde zu einer eingefrorenen, nicht reagierenden Benutzererfahrung führen – ein absolutes No-Go für moderne Anwendungen. Hier kommt die asynchrone Programmierung ins Spiel, die es unseren Anwendungen ermöglicht, langwierige Aufgaben auszuführen, ohne die gesamte Benutzeroberfläche anzuhalten.
Jahrelang waren Callbacks der primäre Mechanismus zur Behandlung von Asynchronität, was zum berüchtigten "Callback Hell" oder "Pyramid of Doom" führte – tief verschachtelter, schwer lesbarer und noch schwerer zu wartender Code. In Anerkennung dieses Schmerzpunkts entwickelte sich JavaScript weiter und führte elegante und leistungsstarke Konstrukte ein: Promises und anschließend async/await
. Diese Fortschritte veränderten grundlegend, wie wir gleichzeitiges JavaScript schreiben, und machten unseren Code lesbarer, wartbarer und robuster. Das Verständnis ihrer zugrunde liegenden Prinzipien und die Beherrschung ihrer Best Practices ist nicht nur ein Nice-to-have, sondern eine entscheidende Fähigkeit für jeden ernsthaften JavaScript-Entwickler, der performante und skalierbare Anwendungen entwickelt. Dieser Artikel wird Promises und async/await
entmystifizieren, ihre Kernmechanismen, Implementierungsdetails und praktischen Anwendungen untersuchen, um Ihnen zu helfen, saubereren und effektiveren asynchronen Code zu schreiben.
Promises: Die Grundlage moderner Asynchronität
Bevor wir uns async/await
zuwenden, ist es unerlässlich, das Konzept der Promises zu verstehen, da async/await
weitgehend syntaktischer Zucker ist, der auf ihnen aufbaut.
Was ist ein Promise?
Im Kern ist ein Promise ein Objekt, das die eventuale Beendigung oder das Fehlschlagen einer asynchronen Operation und ihr Ergebnis darstellt. Stellen Sie es sich wie ein echtes Versprechen vor: Sie stellen eine Anfrage (z. B. "Ich werde dir diese Daten besorgen"), und irgendwann in der Zukunft wird dieses Versprechen entweder erfüllt (Sie erhalten die Daten erfolgreich) oder abgelehnt (Sie erhalten die Daten nicht, vielleicht aufgrund eines Fehlers).
Ein Promise kann einen von drei Zuständen haben:
- Pending (Ausstehend): Der Anfangszustand; weder erfüllt noch abgelehnt. Die asynchrone Operation läuft noch.
- Fulfilled (Erfüllt) (oder Resolved): Die Operation wurde erfolgreich abgeschlossen und das Promise hat einen Ergebniswert.
- Rejected (Abgelehnt): Die Operation ist fehlgeschlagen und das Promise hat einen Grund für das Fehlschlagen (ein Fehlerobjekt).
Sobald ein Promise erfüllt oder abgelehnt wurde, gilt es als settled (abgeschlossen). Ein abgeschlossenes Promise kann seinen Zustand nicht mehr ändern; es ist unveränderlich.
Erstellen und Verwenden von Promises
Sie erstellen ein Promise mit dem Promise
-Konstruktor, der eine executor
-Funktion als Argument nimmt. Die Executor-Funktion selbst nimmt zwei Argumente entgegen: resolve
und reject
– beides sind Funktionen, die Sie aufrufen, um den Zustand des Promises zu ändern.
const myAsyncOperation = (shouldSucceed) => { return new Promise((resolve, reject) => { // Simuliert eine asynchrone Operation, z.B. einen Netzwerkanfrage setTimeout(() => { if (shouldSucceed) { resolve("Daten erfolgreich abgerufen!"); // Erfüllt das Promise } else { reject(new Error("Fehler beim Abrufen der Daten.")); // Lehnt das Promise ab } }, 1000); // Simuliert eine Verzögerung von 1 Sekunde }); }; // --- Das Promise konsumieren --- // Fall 1: Promise wird erfüllt myAsyncOperation(true) .then((data) => { console.log("Erfolg:", data); // Ausgabe: Erfolg: Daten erfolgreich abgerufen! }) .catch((error) => { console.error("Fehler (sollte nicht passieren):", error.message); }); // Fall 2: Promise wird abgelehnt myAsyncOperation(false) .then((data) => { console.log("Erfolg (sollte nicht passieren):", data); }) .catch((error) => { console.error("Fehler:", error.message); // Ausgabe: Fehler: Fehler beim Abrufen der Daten. });
Die then()
-Methode wird verwendet, um Callbacks zu registrieren, die ausgeführt werden, wenn das Promise erfüllt wird. Sie nimmt einen optionalen onFulfilled
-Callback entgegen. Die catch()
-Methode ist eine Kurzform für then(null, onRejected)
und wird verwendet, um Callbacks für abgelehnte Promises zu registrieren. Es ist entscheidend, immer einen .catch()
-Block einzufügen, um potenzielle Fehler zu behandeln und nicht behandelte Promise-Ablehnungen zu verhindern.
Chaining von Promises
Einer der bedeutendsten Vorteile von Promises gegenüber Callbacks ist ihre Fähigkeit, verkettet zu werden. Wenn ein then()
-Callback einen Wert zurückgibt, erhält das nächste then()
in der Kette diesen Wert. Entscheidend ist, wenn ein then()
-Callback ein weiteres Promise zurückgibt, wartet die Kette, bis dieses verschachtelte Promise erfüllt wird, bevor sie fortfährt. Dies glättet effektiv tief verschachtelte asynchrone Operationen.
function step1() { console.log("Schritt 1: Start..."); return new Promise((resolve) => setTimeout(() => resolve("Ergebnis von Schritt 1"), 1000)); } function step2(prevResult) { console.log(`Schritt 2: "${prevResult}" erhalten. Weitere Arbeit wird erledigt...`); return new Promise((resolve) => setTimeout(() => resolve("Ergebnis von Schritt 2"), 1500)); } function step3(prevResult) { console.log(`Schritt 3: "${prevResult}" erhalten. Finalisierung... `); // Wenn dieser Schritt einen Fehler auslöst, wird der catch-Block ihn behandeln // throw new Error("Etwas ist in Schritt 3 schief gelaufen"); return Promise.resolve("Endergebnis!"); // Direkte Erfüllung für einen synchronen Wert } step1() .then(step2) // step2 erhält das Ergebnis von step1 .then(step3) // step3 erhält das Ergebnis von step2 .then((finalResult) => { console.log("Alle Schritte abgeschlossen:", finalResult); // Ausgabe: Alle Schritte abgeschlossen: Endergebnis! }) .catch((error) => { console.error("Ein Fehler ist während des Prozesses aufgetreten:", error.message); }) .finally(() => { console.log("Prozess beendet (Erfolg oder Fehlschlag)."); });
Die finally()
-Methode, die später eingeführt wurde, ermöglicht es Ihnen, einen Callback zu registrieren, der unabhängig davon ausgeführt wird, ob das Promise erfüllt oder abgelehnt wurde. Sie ist nützlich für Bereinigungsoperationen.
Async/Await: Vereinfachung von asynchronem Code
Während Promises den asynchronen Code erheblich verbessert haben, brachte die Einführung von async/await
in ES2017 eine weitere Ebene der syntaktischen Eleganz, wodurch asynchroner Code fast synchron aussieht und sich auch so anfühlt.
Die async
-Funktion
Eine async
-Funktion ist eine Funktion, die mit dem Schlüsselwort async
deklariert wird. Sie gibt implizit ein Promise zurück. Wenn die Funktion einen Nicht-Promise-Wert zurückgibt, wird dieser in ein Promise verpackt, das mit diesem Wert erfüllt wird. Wenn sie einen Fehler auslöst, wird das zurückgegebene Promise abgelehnt.
async function greet() { return "Hallo, async Welt!"; // Dieser Wert wird in ein erfülltes Promise verpackt } greet().then(message => console.log(message)); // Ausgabe: Hallo, async Welt! async function throwErrorExample() { throw new Error("Das ist ein async Fehler!"); // Dies lässt das zurückgegebene Promise abgeleht werden } throwErrorExample().catch(error => console.error(error.message)); // Ausgabe: Das ist ein async Fehler!
Der await
-Operator
Der await
-Operator kann nur innerhalb einer async
-Funktion verwendet werden. Er pausiert die Ausführung der async
-Funktion, bis das Promise, auf das mit await
gewartet wird, abgeschlossen ist (entweder erfüllt oder abgelehnt). Wenn das Promise erfüllt wird, gibt await
seinen aufgelösten Wert zurück. Wenn das Promise abgelehnt wird, löst await
den abgelehnten Wert als Fehler aus, der dann mit einem try...catch
-Block abgefangen werden kann.
function fetchData() { return new Promise(resolve => { setTimeout(() => resolve({ id: 1, name: "Async Item" }), 2000); }); } function processData(data) { return new Promise((resolve, reject) => { setTimeout(() => { if (data && data.name) { resolve(`Verarbeitet: ${data.name.toUpperCase()}`); } else { reject(new Error("Ungültige Daten zur Verarbeitung.")); } }, 1000); }); } async function performOperations() { try { console.log("Starte Datenabruf..."); // await pausiert die Ausführung hier, bis fetchData() erfüllt const data = await fetchData(); console.log("Daten abgerufen:", data); console.log("Starte Datenverarbeitung..."); // await pausiert hier, bis processData() erfüllt const processedResult = await processData(data); console.log("Verarbeitetes Ergebnis:", processedResult); return processedResult; } catch (error) { console.error("Ein Fehler ist aufgetreten:", error.message); // Sie können den Fehler erneut auslösen oder einen Standardwert/Promise.reject zurückgeben throw error; } } performOperations() .then(finalResult => console.log("Gesamterfolg:", finalResult)) .catch(overallError => console.error("Gesamtes Fehlschlagen:", overallError.message)); // Beispiel für ein Fehlerszenario async function performOperationsWithError() { try { console.log("Versuche, ungültige Daten zu verarbeiten..."); const invalidData = null; // Rufe ungültige Daten ab simulieren const processedResult = await processData(invalidData); // Dies wird abgelehnt console.log("Verarbeitetes Ergebnis:", processedResult); // Diese Zeile wird nicht erreicht } catch (error) { console.error("Fehler abgefangen in performOperationsWithError:", error.message); } } performOperationsWithError();
Der try...catch
-Block um await
ist unerlässlich für die Fehlerbehandlung und repliziert das catch()
-Verhalten von Promises.
Unter der Haube: async/await
und die Event Loop
async/await
führt keine neuen Nebenläufigkeitsprinzipien ein; es ist lediglich eine syntaktische Transformation über Promises und die JavaScript-Event-Loop. Wenn ein await
-Ausdruck angetroffen wird:
- Die Ausführung der
async
-Funktion wird pausiert. - Das Promise, auf das mit
await
gewartet wird, wird zur Microtask-Warteschlange (oder Macrotask-Warteschlange für I/O-Operationen) hinzugefügt. - Die Kontrolle wird an die aufrufende Funktion oder die Event-Loop zurückgegeben, wodurch anderer synchroner Code oder ausstehende Aufgaben ausgeführt werden können.
- Sobald das erwartete Promise abgeschlossen ist, wird sein
then
-Handler (denawait
implizit erstellt) zur Microtask-Warteschlange hinzugefügt. - Wenn der JavaScript-Call-Stack leer ist, holt die Event-Loop die Microtask ab, und die
async
-Funktion wird dort fortgesetzt, wo sie aufgehört hat, entweder mit dem erfüllten Wert oder indem sie den abgelehnten Fehler auslöst.
Diese nicht-blockierende Natur ist entscheidend für die Aufrechterhaltung der Reaktionsfähigkeit in Single-Threaded-JavaScript-Umgebungen.
Best Practices für Promises und Async/Await
1. Fehler immer behandeln
Promises: Beenden Sie Ihre Promise-Ketten immer mit einem .catch()
-Block. Nicht behandelte Promise-Ablehnungen können zu stillen Fehlern oder nicht behandelten Promise-Ablehnungs-Warnungen/-Fehlern führen, die Ihre Node.js-Anwendung abstürzen lassen.
fetch("/api/data") .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.error("Fehler beim Abrufen von Daten:", error)); // Unerlässlich!
Async/Await: Umschließen Sie await
-Aufrufe mit try...catch
-Blöcken.
async function getData() { try { const response = await fetch("/api/data"); if (!response.ok) { // Prüfen auf HTTP-Fehler (z.B. 404, 500) throw new Error(`HTTP-Fehler! Status: ${response.status}`); } const data = await response.json(); console.log(data); } catch (error) { console.error("Fehler beim Abrufen von Daten:", error); } }
2. Bevorzugen Sie async/await
für die Lesbarkeit
Für sequentielle asynchrone Operationen führt async/await
im Allgemeinen zu saubererem, lesbarerem Code als das Verketten mehrerer .then()
-Aufrufe.
Schlecht (Callback Hell):
getData(function(a) { getOtherData(a, function(b) { processData(b, function(c) { console.log(c); }); }); });
Besser (Promises):
getData() .then(a => getOtherData(a)) .then(b => processData(b)) .then(c => console.log(c)) .catch(error => console.error(error));
Am besten (async/await
):
async function performCombinedOperation() { try { const a = await getData(); const b = await getOtherData(a); const c = await processData(b); console.log(c); } catch (error) { console.error(error); } }
3. Führen Sie parallele Operationen mit Promise.all
aus
Wenn Sie mehrere unabhängige asynchrone Operationen haben, die abgeschlossen sein müssen, bevor Sie fortfahren können, rufen Sie sie nicht sequenziell mit await
auf, es sei denn, es gibt eine echte Abhängigkeit. Verwenden Sie Promise.all()
, um sie parallel auszuführen. Dies verbessert die Leistung erheblich.
async function getMultipleData() { try { // Diese beiden unabhängigen Abrufe werden parallel ausgeführt const [usersResponse, productsResponse] = await Promise.all([ fetch("/api/users"), fetch("/api/products") ]); const users = await usersResponse.json(); const products = await productsResponse.json(); console.log("Benutzer:", users); console.log("Produkte:", products); } catch (error) { console.error("Einer der Abrufe ist fehlgeschlagen:", error); } }
Promise.all
nimmt ein iterierbares Objekt (z. B. ein Array) von Promises entgegen und gibt ein neues Promise zurück. Dieses neue Promise wird mit einem Array der Ergebnisse der Eingabe-Promises in der gleichen Reihenfolge erfüllt, sobald alle abgeschlossen sind. Wenn eines der Eingabe-Promises abgelehnt wird, wird Promise.all
sofort mit dem Grund der Ablehnung des ersten abgelehnten Promise abgelehnt.
Promise.allSettled()
ist eine weitere nützliche Methode, wenn Sie das Ergebnis aller Promises kennen müssen, unabhängig davon, ob sie erfolgreich waren oder fehlgeschlagen sind. Sie gibt ein Array von Objekten zurück, von denen jedes das Ergebnis beschreibt (status: 'fulfilled' | 'rejected'
, value
oder reason
).
4. Vermeiden Sie async
-Funktionen für synchrone Operationen
Auch wenn es verlockend ist, async
aus Konsistenzgründen überall zu verwenden, deklarieren Sie eine Funktion nicht als async
, wenn sie tatsächlich keine asynchronen Operationen durchführt (z. B. kein await
verwendet oder ein Promise zurückgibt). Dies führt unnötigen Overhead mit sich, indem der Rückgabewert in ein Promise verpackt wird.
5. Achten Sie auf den Kontext (This-Schlüsselwort)
Bei der Verwendung von then()
oder catch()
mit traditionellen Funktionsausdrücken kann sich der this
-Kontext ändern. Pfeilfunktionen behalten den this
-Kontext ihres lexikalischen Geltungsbereichs bei, was sie oft zu einer besseren Wahl für Promise-Callbacks innerhalb von Klassenmethoden macht. async/await
vermeidet dieses Problem oft von Natur aus, wenn Sie Ihren Code in async
-Funktionsbodies umwandeln.
class MyService { constructor() { this.baseUrl = "/api"; } // Gut: Verwendung einer Pfeilfunktion für den then()-Callback fetchDataArrow() { return fetch(`${this.baseUrl}/data`) .then(response => response.json()) // 'this' bezieht sich korrekt auf die MyService-Instanz .then(data => console.log(data)) .catch(error => console.error(error)); } // Ebenfalls gut: async/await behandelt 'this' von Natur aus gut innerhalb seines Funktionskörpers async fetchDataAsync() { try { const response = await fetch(`${this.baseUrl}/data`); // 'this' bezieht sich korrekt auf die MyService-Instanz const data = await response.json(); console.log(data); } catch (error) { console.error(error); } } }
6. Erwägen Sie Promise.race
für Race-Conditions
Promise.race
nimmt ein iterierbares Objekt von Promises entgegen und gibt ein Promise zurück, das so bald wie möglich nach der Erfüllung oder Ablehnung eines der Promises im iterierbaren Objekt erfüllt oder abgelehnt wird, mit dem Wert oder Grund dieses Promise. Dies ist nützlich für Szenarien wie Timeouts.
function timeout(ms) { return new Promise((resolve, reject) => setTimeout(() => reject(new Error("Anfrage-Timeout!")), ms) ); } async function fetchWithTimeout(url, ms) { try { const response = await Promise.race([ fetch(url), timeout(ms) ]); const data = await response.json(); console.log("Daten innerhalb der Zeit abgerufen:", data); } catch (error) { console.error(error.message); // Zeigt "Anfrage-Timeout!" an, wenn es zu langsam ist } } fetchWithTimeout("/api/slow-data", 3000); // Versuche, innerhalb von 3 Sekunden abzurufen
Fazit
Promises und async/await
haben die asynchrone Programmierung in JavaScript grundlegend verändert und sich vom verschlungenen "Callback Hell" zu einem Paradigma entwickelt, das weitaus lesbarer, wartbarer und robuster ist. Promises bieten einen grundlegenden Mechanismus zur Handhabung von späteren Werten und Fehlern, während async/await
auf dieser Grundlage aufbaut und eine synchrongleiche Syntax bietet, die die kognitive Belastung erheblich reduziert. Das Beherrschen dieser Werkzeuge bedeutet nicht nur, eleganteren Code zu schreiben, sondern auch, reaktionsschnelle, effiziente und fehlertolerante JavaScript-Anwendungen zu erstellen, die die anspruchsvolle asynchrone Natur des modernen Webs nahtlos bewältigen können. Nutzen Sie Promises und async/await
und erschließen Sie ein neues Maß an Klarheit und Leistung in Ihrer JavaScript-Entwicklung.