Abschnitt 15 · Lektion 3
Callbacks und ihre Grenzen
Jetzt, wo das Bild steht, kommt die Frage, wie man das in Code schreibt. Der erste Weg dafür ist der älteste, und er ist genau das, was du in Lektion 5.7 schon gemacht hast: eine Funktion mitgeben, die später aufgerufen wird.
Neu ist nur, was „später” hier heißt. Bei map heißt es „gleich, für jeden Eintrag”. Hier heißt es „irgendwann, wenn das Ergebnis da ist”.
Das Fehler-zuerst-Muster
<p>Zwei Aufrufe, einer geht gut aus und einer nicht.</p> body {
font-family: system-ui, sans-serif;
max-width: 40rem;
margin: 0;
padding: 1rem;
line-height: 1.6;
color: #1c1917;
}
p {
margin: 0 0 0.75rem;
} const lager = { a1: "Tastatur", b2: "Maus" };
// Die Verabredung: der Callback bekommt als ERSTES den
// Fehler, danach das Ergebnis. Genau einer von beiden ist
// gesetzt, der andere ist null.
function artikelLaden(id, zurueck) {
setTimeout(() => {
const name = lager[id];
if (!name) {
zurueck(new Error(`Nicht im Lager: ${id}`), null);
return;
}
zurueck(null, name);
}, 150);
}
artikelLaden("a1", (fehler, name) => {
if (fehler) {
console.error("Ging nicht:", fehler.message);
return;
}
console.log("Gefunden:", name);
});
artikelLaden("zzz", (fehler, name) => {
if (fehler) {
console.error("Ging nicht:", fehler.message);
return;
}
console.log("Gefunden:", name);
});
console.log("Beide Aufrufe sind abgeschickt."); Wenn etwas asynchron schiefgehen kann, muss der Fehler denselben Weg nehmen wie das Ergebnis, also durch den Callback. Dafür hat sich eine Verabredung durchgesetzt, die man überall wiedererkennt:
Der Callback bekommt zwei Argumente. Das erste ist der Fehler, das zweite das Ergebnis. Genau eines von beiden ist gesetzt, das andere ist null.
Der Aufrufer prüft deshalb immer zuerst den Fehler und steigt bei Bedarf sofort aus. Erst danach kommt der Teil, der mit dem Ergebnis arbeitet.
Diese Reihenfolge wirkt willkürlich und ist es nicht: Sie stellt sicher, dass man den Fehler nicht übersehen kann, weil er als erstes Argument im Weg steht. Sie stammt aus Node.js, wo bis heute halbe Bibliotheken so gebaut sind, und sie ist der Grund, warum sich dieser Kurs die Mühe macht, ein Muster zu zeigen, das man selbst kaum noch schreibt: Lesen wirst du es garantiert.
Drei Schritte, drei Ebenen
<p id="ausgabe">läuft ...</p> body {
font-family: system-ui, sans-serif;
max-width: 40rem;
margin: 0;
padding: 1rem;
line-height: 1.6;
color: #1c1917;
}
p {
margin: 0 0 0.75rem;
} const ausgabe = document.getElementById("ausgabe");
function schritt(marke, zurueck) {
setTimeout(() => {
console.log("fertig:", marke);
zurueck(null, marke);
}, 100);
}
// Jeder Schritt braucht das Ergebnis des vorigen. Deshalb
// muss er in dessen Callback hinein, und deshalb wandert
// der Code bei jedem Schritt weiter nach rechts.
schritt("a", (fehler1, a) => {
if (fehler1) return;
schritt("b", (fehler2, b) => {
if (fehler2) return;
schritt("c", (fehler3, c) => {
if (fehler3) return;
ausgabe.textContent = `fertig: ${a}, ${b}, ${c}`;
console.log("Alle drei durch.");
});
});
}); Ein einzelner Callback ist harmlos. Unangenehm wird es, sobald ein Schritt auf dem Ergebnis des vorigen aufbaut.
Der zweite Schritt kann erst starten, wenn der erste fertig ist. Also muss sein Aufruf in den Callback des ersten hinein. Der dritte in den des zweiten. Und schon steht dein Code drei Ebenen weit rechts, mit drei Fehlerprüfungen, die alle dasselbe tun.
Bei fünf Schritten ist das ein Gebirge. In der Branche gibt es dafür einen Namen, der ziemlich genau beschreibt, wie es aussieht, wenn man es von weitem betrachtet: Callback Hell.
Das ist allerdings nur die Hälfte des Problems, und ehrlich gesagt die harmlosere. Einrückung ist unangenehm zu lesen, mehr nicht.
Der eigentliche Grund für den Wechsel
<p>Der Fehler wird geworfen, und das catch daneben sieht ihn nie.</p> body {
font-family: system-ui, sans-serif;
max-width: 40rem;
margin: 0;
padding: 1rem;
line-height: 1.6;
color: #1c1917;
}
p {
margin: 0 0 0.75rem;
} function spaeterWerfen(zurueck) {
setTimeout(() => {
zurueck();
}, 100);
}
try {
spaeterWerfen(() => {
console.log("Der Callback läuft jetzt.");
throw new Error("Aus dem Callback geworfen");
});
console.log("Der try-Block ist hier schon zu Ende.");
} catch (fehler) {
console.log("Dieses catch läuft nie:", fehler.message);
}
// Dass der Fehler trotzdem irgendwo landet, siehst du im
// Reiter "Console": als ungefangener Fehler, ohne jede
// Chance, ihn an der richtigen Stelle zu behandeln. Der schwerwiegende Punkt wird seltener genannt: Ein Fehler in einem Callback lässt sich mit try/catch nicht fangen.
Sieh dir das dritte Beispiel an. Der try-Block umschließt den Aufruf. Der Aufruf meldet einen Timer an und ist sofort fertig, der try-Block also auch. Hundert Millisekunden später läuft der Callback, und da ist von diesem try nichts mehr übrig. Der Fehler geht daran vorbei und landet als ungefangener Fehler in der Console.
Das ist keine Nachlässigkeit im Beispiel, sondern eine direkte Folge des Bildes aus der letzten Lektion. Der Callback liegt zu diesem Zeitpunkt frisch auf einem leeren Stapel. Unter ihm steht nichts mehr, was ihn auffangen könnte.
Zeichne es einmal auf, im Reiter Debug. Dann steht der Satz „der try-Block ist längst vorbei” nicht mehr da, sondern du siehst ihn. Die Reihenfolge lautet: Zeile 7 (try {), Zeile 8 (der Aufruf), Zeile 2 (der setTimeout darin), dann Zeile 12. Zeile 12 ist die letzte Zeile im try-Block. Erst danach kommen Zeile 3, 9 und 10, also der Callback und sein throw.
Anders gesagt: Der try-Block war schon durch, bevor der Callback überhaupt angefangen hat. Er kann nichts fangen, was es zu seiner Zeit noch gar nicht gab.
Die Folge davon ist unangenehm: Fehlerbehandlung muss überall dort stehen, wo das Ergebnis ankommt, und lässt sich nicht an einer Stelle zusammenfassen. Bei drei verschachtelten Schritten stehen drei fast gleiche if (fehler) untereinander, und wer einen davon vergisst, merkt es nie.
Was Promises daran ändern
Genau diese beiden Probleme lösen Promises, und deshalb kommen sie als Nächstes.
Sie machen aus der Verschachtelung eine Kette, die untereinander steht statt nach rechts zu wandern. Und sie geben einen Fehler die Kette entlang weiter, bis jemand ihn abfängt, so dass eine einzige Fehlerbehandlung für alle Schritte reicht.
Callbacks verschwinden dabei nicht. Ein addEventListener bekommt weiterhin einen, setTimeout auch, und map sowieso. Was verschwindet, ist die Verschachtelung für Dinge, die nacheinander passieren müssen.
Zum Mitnehmen
Ein try/catch fängt einen Fehler im Callback nicht: Der try-Block ist längst vorbei, wenn der Callback läuft. Genau das lösen Promises.
Jetzt du
Basis Konto, kostenlosZu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.
Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.
Was in dieser Lektion steckt
-
Artikel mit 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe im Editor, direkt im Browser geprüft
Öffnet sich mit dem Basis Konto.