Tutorials › Teil 4: Weiter
Kapitel 16 von 16
Warten können: Promises und async
zeige("erste Zeile");
setTimeout(() => zeige("die verzögerte Zeile"), 0);
zeige("zweite Zeile");
Null Millisekunden Verzögerung, und die Zeile kommt trotzdem zuletzt. Das ist kein Fehler, sondern die Grundregel.
Eine Spur, eine Warteschlange
JavaScript führt immer nur eine Sache gleichzeitig aus. Es gibt einen Ausführungsstrang, mehr nicht.
Was passiert dann mit setTimeout? Der Auftrag wandert in eine Warteschlange. Abgearbeitet wird sie erst, wenn der aktuelle Code komplett durchgelaufen ist. Deshalb bedeutet null Millisekunden nicht sofort, sondern frühestmöglich danach.
Für dich heißt das zweierlei. Erstens: Nichts kann dir mitten in einer Funktion dazwischenfunken. Zweitens: Wenn du den Strang blockierst, steht die ganze Seite.
const start = performance.now();
let summe = 0;
for (let i = 0; i < 40000000; i++) summe += i;
zeige("Diese Schleife hat die Seite für",
Math.round(performance.now() - start), "ms eingefroren.");
Während dieser Schleife reagiert nichts. Keine Klicks, kein Scrollen, keine Animation. Genau deshalb muss alles, was länger dauert, aus dem Weg gehen.
Rückrufe und ihr Problem
Der klassische Weg, auf etwas zu warten, ist eine Funktion, die später aufgerufen wird.
function nachDelay(ms, was) {
setTimeout(was, ms);
}
nachDelay(100, () => {
zeige("Schritt 1");
nachDelay(100, () => {
zeige("Schritt 2");
nachDelay(100, () => {
zeige("Schritt 3");
});
});
});
Drei Schritte, drei Einrückungsebenen. Bei acht Schritten läuft der Code rechts aus dem Bild, und Fehlerbehandlung müsste auf jeder Ebene einzeln stehen. Diese Struktur hat einen eigenen Spitznamen bekommen, Callback Hell, und sie war jahrelang der Normalfall.
Ein Promise
Ein Promise ist ein Platzhalter für ein Ergebnis, das es noch nicht gibt. Es kennt drei Zustände: ausstehend, erfüllt, gescheitert.
function warte(ms) {
return new Promise((fertig) => setTimeout(fertig, ms));
}
zeige("los");
warte(200)
.then(() => { zeige("nach 200 ms"); return warte(200); })
.then(() => { zeige("nach weiteren 200 ms"); return warte(200); })
.then(() => zeige("und fertig"));
Kein Verschachteln mehr, sondern eine Kette. Gibt eine then-Funktion selbst ein Promise zurück, wartet die nächste darauf.
Lesbar ist das trotzdem nicht wirklich. Deshalb gibt es seit 2017 eine Schreibweise, die aussieht wie normaler Code.
async und await
function warte(ms) {
return new Promise((fertig) => setTimeout(fertig, ms));
}
async function ablauf() {
zeige("los");
await warte(200);
zeige("nach 200 ms");
await warte(200);
zeige("nach weiteren 200 ms");
zeige("fertig");
}
ablauf();
await hält die Funktion an, bis das Promise fertig ist, und macht dann weiter. Von oben nach unten lesbar, als gäbe es keine Wartezeit.
Zwei Regeln dazu. await funktioniert nur in einer Funktion, die als async gekennzeichnet ist. Und eine async-Funktion gibt immer ein Promise zurück, auch wenn im Rumpf ein einfacher Wert steht.
async function zahl() {
return 42;
}
zeige("Rückgabe:", zahl());
zahl().then((wert) => zeige("ausgepackt:", wert));
Die erste Ausgabe zeigt ein Promise, keine 42. Wer das vergisst, wundert sich über [object Promise] in seiner Ausgabe.
Fehler abfangen
function kannScheitern(erfolgreich) {
return new Promise((fertig, scheitert) => {
setTimeout(() => {
if (erfolgreich) fertig("hat geklappt");
else scheitert(new Error("ist schiefgegangen"));
}, 150);
});
}
async function versuchen() {
try {
zeige(await kannScheitern(true));
zeige(await kannScheitern(false));
zeige("diese Zeile wird nie erreicht");
} catch (fehler) {
zeige("abgefangen:", fehler.message);
} finally {
zeige("finally läuft immer");
}
}
versuchen();
try und catch funktionieren mit await genauso wie bei normalem Code. Das ist der eigentliche Gewinn gegenüber der then-Kette: eine Fehlerbehandlung für den ganzen Block.
Nebeneinander statt nacheinander
function warte(ms, name) {
return new Promise((f) => setTimeout(() => f(name), ms));
}
async function nacheinander() {
const start = performance.now();
await warte(200, "a");
await warte(200, "b");
await warte(200, "c");
zeige("nacheinander:", Math.round(performance.now() - start), "ms");
}
async function gleichzeitig() {
const start = performance.now();
const alle = await Promise.all([
warte(200, "a"), warte(200, "b"), warte(200, "c"),
]);
zeige("gleichzeitig:", Math.round(performance.now() - start), "ms", alle);
}
nacheinander().then(gleichzeitig);
Sechshundert Millisekunden gegen zweihundert. Wenn drei Vorgänge nicht voneinander abhängen, gibt es keinen Grund, sie zu reihen.
Promise.all scheitert, sobald einer scheitert. Soll das Ergebnis aller Vorgänge kommen, egal wie sie ausgehen, nimm Promise.allSettled.
fetch
Der übliche Grund, überhaupt zu warten: Daten von einem Server holen.
async function holen() {
const antwort = await fetch("/api/orte.json");
if (!antwort.ok) {
throw new Error("Server antwortete mit " + antwort.status);
}
const daten = await antwort.json();
return daten;
}
Zwei Mal await, und das ist kein Versehen. Das erste wartet auf die Antwortkopfzeilen, das zweite darauf, dass der Rumpf vollständig gelesen und ausgewertet ist.
Der Punkt, der am meisten überrascht: fetch wirft keinen Fehler bei 404 oder 500. Für fetch ist eine Fehlerseite auch eine Antwort. Nur wenn gar keine Verbindung zustande kommt, scheitert das Promise. Deshalb die Prüfung auf antwort.ok.
Mit Zeitbegrenzung und Fehlerbehandlung sieht ein belastbarer Aufruf so aus:
async function holenMitFrist(adresse, frist = 8000) {
try {
const antwort = await fetch(adresse, { signal: AbortSignal.timeout(frist) });
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
return await antwort.json();
} catch (fehler) {
if (fehler.name === "TimeoutError") throw new Error("Zeit abgelaufen");
throw fehler;
}
}
Genau dieses Muster steckt im Impressum dieser Seite. Beim Bauen werden die Pflichtangaben von einer anderen Adresse geholt, mit Frist und mit einem Rückfall auf die zuletzt gespeicherte Fassung.
Was du dem Besucher zeigst
Während gewartet wird, darf die Seite nicht tot wirken.
<button id="laden">Daten laden</button>
<p id="status"></p>
<script>
const knopf = document.getElementById("laden");
const status = document.getElementById("status");
function warte(ms) { return new Promise((f) => setTimeout(f, ms)); }
knopf.addEventListener("click", async () => {
knopf.disabled = true;
status.textContent = "Wird geladen...";
try {
await warte(1200);
status.textContent = "Fertig. 4.090 Orte gefunden.";
} catch (fehler) {
status.textContent = "Hat nicht geklappt: " + fehler.message;
} finally {
knopf.disabled = false;
}
});
</script>
Knopf sperren, Zustand anzeigen, im finally wieder freigeben. Ohne das Sperren klickt jemand fünfmal und löst fünf Vorgänge aus.
Der Meldungstext im Fehlerfall sagt, was nicht ging. Nicht „Ein Fehler ist aufgetreten", sondern was der Besucher damit anfangen kann.
Damit ist der Kurs durch. Was dir jetzt noch fehlt, ist Übung, und dafür liegen 188 fremde Skripte im Werkzeugschrank, von denen die meisten nach genau den Regeln gebaut sind, gegen die dieser Kurs argumentiert hat. Such dir eins aus und schreib es um. Das lehrt mehr als jedes weitere Kapitel.
Wer etwas Bestimmtes sucht: die Befehlsübersicht sammelt alles aus diesen sechzehn Kapiteln an einer Stelle.