Asynchrones JavaScript
Auftrag
Der Katalog liegt jetzt auf dem Server des Vereins, und die Freiwilligen pflegen ihn dort direkt. Martina Hostettler hat den nächsten Wunsch aufgeschrieben.
Als Freiwillige am Tresen möchte ich, dass die Übersicht immer den aktuellen Katalog vom Server zeigt, damit ich mich auf sie verlassen kann.
Die Akzeptanzkriterien lauten:
- Die Übersicht lädt den Katalog vom Server des Vereins.
- Ist der Server nicht erreichbar oder meldet er einen Fehler, erscheint eine verständliche Meldung.
- Antwortet der Server nicht, gibt die Übersicht nach fünf Sekunden auf.
Lernziele
Nach diesem Kapitel kannst du
- die Reihenfolge von Code mit Timern und Promises vorhersagen und mit der Ereignisschleife erklären,
- Promises mit
then,catchundfinallyund mitasyncundawaitverwenden, - Daten mit
fetchladen und dabei Fehler des Servers und des Netzes unterscheiden, - Lade- und Fehlerzustände festhalten und anzeigen,
- eine Anfrage mit
AbortControllerabbrechen.
Warten, ohne zu blockieren
Abschnitt betitelt „Warten, ohne zu blockieren“Eine Anfrage an einen Server dauert, Millisekunden im besten Fall, Sekunden bei einer schlechten Verbindung. JavaScript läuft in einem einzigen Thread. Würde es auf die Antwort warten, stünde die ganze Seite still, kein Klick und kein Tippen käme an. Darum übergibt JavaScript das Warten an den Browser oder an Node und läuft weiter. Ist die Antwort da, ruft der Browser den Code auf, der sie verarbeitet. Dieser Code läuft asynchron, also später als der Code, der ihn angestossen hat.
Die Ereignisschleife
Abschnitt betitelt „Die Ereignisschleife“Sag voraus, in welcher Reihenfolge die vier Zeilen erscheinen, bevor du das Beispiel ausführst.
console.log('1 Das Programm startet')setTimeout(() => console.log('4 Timer'), 0)Promise.resolve().then(() => console.log('3 Promise'))console.log('2 Das Programm ist durch')Die Ziffern verraten die Reihenfolge. Zuerst läuft das Programm von oben nach unten durch, die Zeilen 1 und 2. Erst dann kommen die Callbacks an die Reihe, und das Promise vor dem Timer, obwohl der Timer 0 Millisekunden wartet.
Der Aufrufstapel hält die Funktionen, die gerade laufen. Timer und Anfragen übernimmt der Browser oder Node. Ist ein Timer abgelaufen, kommt sein Callback in die Task-Queue, ebenso die Callbacks für Klicks und andere Events. Die Fortsetzungen von Promises kommen in die Microtask-Queue. Ist der Stapel leer, holt die Ereignisschleife zuerst alle Microtasks und danach einen einzigen Task, dann wieder alle Microtasks. Im Browser zeichnet sie zwischen den Tasks auch die Seite neu. Ein Callback, der lange rechnet, blockiert deshalb alles andere, auch das Neuzeichnen.
Promises
Abschnitt betitelt „Promises“Ein Promise steht für ein Ergebnis, das später kommt. Es ist zuerst offen und wird dann entweder erfüllt, mit einem Wert, oder abgelehnt, mit einem Fehler. Mit then hängst du Code an, der mit dem Wert weiterarbeitet, mit catch Code für den Fehler und mit finally Code, der in beiden Fällen läuft.
// Stand-ins for slow answers, the promises settle after a few millisecondsfunction answerLater(milliseconds, value) { return new Promise((resolve) => setTimeout(() => resolve(value), milliseconds))}
function failLater(milliseconds, message) { return new Promise((resolve, reject) => setTimeout(() => reject(new Error(message)), milliseconds))}
answerLater(300, 'Katalog geladen') .then((message) => console.log(message)) .finally(() => console.log('fertig'))
failLater(100, 'Server nicht erreichbar') .then(() => console.log('kommt nie')) .catch((error) => console.log(error.message))answerLater und failLater sind Stellvertreter für einen langsamen Server. Sie verpacken einen Timer in ein Promise, resolve erfüllt es, reject lehnt es ab. Beide starten fast gleichzeitig. Die Meldung des Fehlers erscheint zuerst, weil ihr Timer nach 100 Millisekunden abläuft, der andere erst nach 300. Ergebnisse kommen in der Reihenfolge an, in der sie fertig werden, nicht in der Reihenfolge des Codes.
then liefert selbst wieder ein Promise. Darum lassen sich then, catch und finally zu einer Kette verbinden, in der jeder Schritt mit dem Ergebnis des vorherigen arbeitet.
async und await
Abschnitt betitelt „async und await“Mit async und await liest sich Code mit Promises fast wie gewöhnlicher Code. Eine Funktion mit async liefert immer ein Promise. In ihr hält await die Funktion an, bis das Promise erfüllt ist, und liefert seinen Wert. Den Thread hält await dabei nicht an, die Ereignisschleife arbeitet in dieser Zeit anderes ab.
function request(what, milliseconds) { return new Promise((resolve) => setTimeout(() => resolve(what), milliseconds))}
async function oneAfterTheOther() { const items = await request('Dinge', 200) const members = await request('Mitglieder', 200) return [items, members]}
async function atTheSameTime() { return Promise.all([request('Dinge', 200), request('Mitglieder', 200)])}
// Rounded down to 200 milliseconds, the step of the timers. Timers never fire early, and a busy// computer may start them up to 199 milliseconds late without changing the outputconst elapsed = (start) => Math.floor((Date.now() - start) / 200) * 200
let start = Date.now()console.log(await oneAfterTheOther(), elapsed(start))start = Date.now()console.log(await atTheSameTime(), elapsed(start))oneAfterTheOther wartet auf die Dinge und fragt erst danach nach den Mitgliedern, zusammen 400 Millisekunden. atTheSameTime startet beide Anfragen sofort und wartet mit Promise.all auf beide, das dauert so lange wie die langsamere. Unabhängige Anfragen startest du deshalb gleichzeitig. Lehnt eines der Promises ab, lehnt auch Promise.all ab. Brauchst du alle Ergebnisse, auch die abgelehnten, nimmst du Promise.allSettled.
In einem Modul darf await auch ausserhalb einer Funktion stehen, wie in den letzten Zeilen des Beispiels. Ein abgelehntes Promise fängst du mit try und catch ab, genau wie einen Fehler aus der Einheit Fehler behandeln.
fetch schickt eine Anfrage an einen Server und liefert ein Promise, das sich mit einer Antwort erfüllt, einem Response. Die Beispiele dieses Kapitels laufen im Browser ohne den Server des Vereins, darum antwortet ein Stellvertreter. Er liefert echte Objekte vom Typ Response, wie fetch es täte.
const items = [ { id: 1, name: 'Bohrmaschine' }, { id: 6, name: 'Beamer' },]
// Stand-in for the association's server, it answers like fetch after 200 millisecondsasync function fetchFromServer(url) { await new Promise((resolve) => setTimeout(resolve, 200)) if (url.endsWith('/items')) { return new Response(JSON.stringify(items), { status: 200, headers: { 'Content-Type': 'application/json' } }) } return new Response('Not Found', { status: 404 })}
const response = await fetchFromServer('https://lorraine-commons.example/items')console.log(response.ok, response.status)const data = await response.json()console.log(data.length, data[1].name)
const missing = await fetchFromServer('https://lorraine-commons.example/reservations')console.log(missing.ok, missing.status)response.ok ist true, wenn der Status zwischen 200 und 299 liegt. response.status nennt den Status, etwa 200 für Erfolg oder 404, wenn es die Adresse nicht gibt. Den Inhalt liest response.json() und wandelt ihn mit JSON.parse in Daten um. Auch das liefert ein Promise, weil die Daten noch unterwegs sein können, wenn die Antwort schon da ist.
Wichtig ist der letzte Fall. fetch lehnt bei Status 404 oder 500 nicht ab. Für fetch ist eine Antwort eine Antwort, auch wenn sie einen Fehler meldet. Abgelehnt wird das Promise nur, wenn gar keine Antwort kommt, etwa weil das Netz fehlt oder der Server nicht läuft, und dann mit einem TypeError. Den Status musst du selbst prüfen.
Laden und Fehler anzeigen
Abschnitt betitelt „Laden und Fehler anzeigen“Während eine Anfrage läuft, soll die Oberfläche das zeigen, und wenn sie scheitert, eine Meldung, mit der Mitglieder etwas anfangen können. Dafür hält der Code den Zustand der Anfrage fest.
// Stand-in for fetch, without a connection fetch rejects with a TypeErrorasync function fetchFromServer(url, online) { await new Promise((resolve) => setTimeout(resolve, 200)) if (!online) throw new TypeError('Failed to fetch') return new Response(JSON.stringify([{ id: 1 }, { id: 2 }]), { status: 200 })}
async function loadCatalog(online) { const state = { status: 'loading', items: [], error: null } console.log(state.status) try { const response = await fetchFromServer('https://lorraine-commons.example/items', online) state.items = await response.json() state.status = 'success' } catch { state.error = 'Der Katalog konnte nicht geladen werden.' state.status = 'error' } console.log(state.status, state.items.length, state.error) return state}
await loadCatalog(true)await loadCatalog(false)Der Zustand ist ein Objekt mit dem Feld status, das loading, success oder error sein kann, dazu die Daten und die Meldung. Die Oberfläche richtet sich nach diesem Objekt. Im Browser sieht das so aus.
<!doctype html><p id="status">Der Katalog wird geladen …</p><ul id="catalog"></ul><script> // Stand-in for fetch, the association's server answers after 500 milliseconds async function fetchFromServer() { await new Promise((resolve) => setTimeout(resolve, 500)) return new Response(JSON.stringify(['Bohrmaschine', 'Leiter'])) }
async function showCatalog() { const status = document.querySelector('#status') try { const response = await fetchFromServer() const names = await response.json() for (const name of names) { const entry = document.createElement('li') entry.textContent = name document.querySelector('#catalog').append(entry) } status.textContent = `${names.length} Dinge geladen` } catch { status.textContent = 'Der Katalog konnte nicht geladen werden.' } console.log(status.textContent) }
showCatalog()</script>Die Seite zeigt eine halbe Sekunde lang den Hinweis zum Laden, danach die Liste und die Anzahl. Dieses Muster mit drei Zuständen begegnet dir in jeder App, die Daten lädt. In Vue schreibst du es in Teil 7 wieder, dann zeigt die Oberfläche die Zustände von selbst an.
Anfragen abbrechen
Abschnitt betitelt „Anfragen abbrechen“Manchmal braucht es eine Antwort nicht mehr, etwa wenn ein Mitglied weitertippt und eine neue Suche schickt, oder sie kommt zu spät. Mit einem AbortController brichst du eine Anfrage ab.
// Stand-in for fetch, the answer takes one second unless the signal aborts the requestfunction slowFetch(url, { signal }) { return new Promise((resolve, reject) => { const timer = setTimeout(() => resolve(new Response('[]')), 1000) signal.addEventListener('abort', () => { clearTimeout(timer) reject(signal.reason) }) })}
const controller = new AbortController()setTimeout(() => controller.abort(), 300)try { await slowFetch('https://lorraine-commons.example/items', { signal: controller.signal }) console.log('geladen')} catch (error) { console.log(error.name)}
try { await slowFetch('https://lorraine-commons.example/items', { signal: AbortSignal.timeout(300) }) console.log('geladen')} catch (error) { console.log(error.name)}Der Controller hat ein Signal, das du fetch mitgibst. controller.abort() bricht die Anfrage ab, und das Promise lehnt mit einem Fehler namens AbortError ab. Für eine Wartezeit gibt es AbortSignal.timeout(300), das nach 300 Millisekunden von selbst abbricht, mit einem Fehler namens TimeoutError. Echte Anfragen bricht fetch genau so ab, der Stellvertreter im Beispiel ahmt das nach.
Im Code
Abschnitt betitelt „Im Code“Arbeite mit dem Beispiel zu Promises. Schreib auf, was du erwartest, bevor du etwas änderst.
// Stand-ins for slow answers, the promises settle after a few millisecondsfunction answerLater(milliseconds, value) { return new Promise((resolve) => setTimeout(() => resolve(value), milliseconds))}
function failLater(milliseconds, message) { return new Promise((resolve, reject) => setTimeout(() => reject(new Error(message)), milliseconds))}
answerLater(300, 'Katalog geladen') .then((message) => console.log(message)) .finally(() => console.log('fertig'))
failLater(100, 'Server nicht erreichbar') .then(() => console.log('kommt nie')) .catch((error) => console.log(error.message))- Was ändert sich an der Ausgabe, wenn
failLater500 statt 100 Millisekunden wartet? - Entferne das
catcham Schluss. Was meldet der Knopf «Ausführen», und was Node mitnode promise.mjs? - Schreib den ersten Teil mit
asyncundawaitum, in einer Funktion mittryundfinally. - Was gibt
console.log(answerLater(300, 'x'))aus, und warum nichtx?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen die Übersicht, die den Katalog vom Server lädt. Du findest das Projekt im Kursrepo im Ordner exercises/01-javascript/async/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testDie Tests ersetzen fetch durch Stellvertreter und brauchen keinen Server. Im Projekt steckt aber auch ein kleiner Server, der den Katalog unter http://localhost:3000/items ausliefert. Starte ihn in einem Terminal und die Übersicht in einem zweiten.
npm run servernpm run overviewMit ?status=503 am Ende der Adresse in src/load.js antwortet der Server mit einem Fehler, mit ?delay=8000 erst nach acht Sekunden. Beendest du den Server mit Ctrl+C, ist er nicht erreichbar. So probierst du jede Aufgabe auch von Hand aus.
Aufgabe Einstieg
Schreib in src/load.js die Funktion loadItems(url). Sie lädt den Katalog mit fetch von der übergebenen Adresse und liefert die Dinge als Array. Mit dem laufenden Server zeigt npm run overview danach 60 Dinge.
Lösung
Zwei Zeilen genügen, const response = await fetch(url) und return response.json(). Die Funktion braucht dafür async. Sie gibt das Promise von response.json() zurück, und wer loadItems mit await aufruft, bekommt die Dinge. Die vollständige Lösung mit allen drei Stufen steht in der Knobelei.
Aufgabe Vertiefung
Exportiere aus src/load.js eine Fehlerklasse LoadError. loadItems wirft sie mit der Meldung «Der Server antwortet mit dem Status 503.», wenn der Server einen Fehler meldet, mit seinem Status statt 503. Ist der Server nicht erreichbar, lautet die Meldung «Der Server ist nicht erreichbar.».
Lösung
Die Klasse erbt von Error und setzt ihren Namen, wie in der Einheit Fehler behandeln. loadItems fängt den TypeError von fetch mit try und catch ab und wirft stattdessen einen LoadError. Nach dem try prüft sie response.ok und wirft bei einem Fehler des Servers einen LoadError mit dem Status. Die Übersicht gibt dann nur noch error.message aus. Den Code zeigt die Lösung der Knobelei.
Aufgabe Knobelei
loadItems soll nicht ewig warten. Ergänze eine Option timeout in Millisekunden mit dem Standardwert 5000, loadItems(url, { timeout: 1000 }). Antwortet der Server nicht rechtzeitig, wirft die Funktion einen LoadError mit der Meldung «Der Server antwortet zu langsam.».
Lösung
export const CATALOG_URL = 'http://localhost:3000/items'
export class LoadError extends Error { constructor(message, options) { super(message, options) this.name = 'LoadError' }}
// Loads the catalog from the association's server and returns the array of things.// Gives up after timeout milliseconds, every problem ends as a LoadError with a clear message.export async function loadItems(url, { timeout = 5000 } = {}) { let response try { response = await fetch(url, { signal: AbortSignal.timeout(timeout) }) } catch (error) { if (error.name === 'TimeoutError' || error.name === 'AbortError') { throw new LoadError('Der Server antwortet zu langsam.', { cause: error }) } throw new LoadError('Der Server ist nicht erreichbar.', { cause: error }) } if (!response.ok) { throw new LoadError(`Der Server antwortet mit dem Status ${response.status}.`) } return response.json()}AbortSignal.timeout bricht die Anfrage nach der Wartezeit ab, und fetch lehnt mit einem TimeoutError ab. Der catch-Block unterscheidet am Namen des Fehlers, ob die Zeit abgelaufen ist oder der Server fehlt. cause hängt den ursprünglichen Fehler an, so bleibt er für die Fehlersuche erhalten, ohne dass Mitglieder ihn sehen. Die Wartezeit steckt in einem Objekt mit Standardwert, so bleibt loadItems(url) ohne Optionen gültig.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
console.log(loadItems(url)) zeigt Promise { <pending> } statt der Dinge. | await davor schreiben oder mit then weiterarbeiten. |
Bei Status 404 meldet fetch keinen Fehler, und response.json() scheitert an einer Fehlerseite. | Vor dem Lesen response.ok prüfen. |
await in einem Callback von forEach wartet nicht. | for...of mit await verwenden oder die Promises mit map sammeln und mit Promise.all abwarten. |
Ein abgelehntes Promise ohne catch erscheint als Uncaught (in promise) in der Konsole. | await in try und catch setzen oder .catch anhängen. |
response.json() ohne await liefert ein Promise statt der Daten. | await response.json() schreiben. |
Zwei unabhängige Anfragen laufen mit zwei await nacheinander und dauern doppelt so lange. | Beide starten und mit Promise.all auf beide warten. |
| Ein KI-Assistent installiert ein Paket wie axios für eine einfache Anfrage. | fetch ist in Browsern und in Node eingebaut und genügt. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- In welcher Reihenfolge erscheinen Ausgaben aus gewöhnlichem Code, aus
thenund aussetTimeoutmit 0 Millisekunden, und warum? - Warum lehnt
fetchbei Status 503 nicht ab, und woran merkst du den Fehler? - Was ist der Unterschied zwischen zwei
awaitnacheinander undPromise.all? - Wozu dient ein
AbortController?
Antworten
- Zuerst der gewöhnliche Code, dann
then, dann der Timer. Erst wenn der Stapel leer ist, holt die Ereignisschleife Callbacks, und zwar alle Microtasks vor dem nächsten Task. - Für
fetchist jede Antwort ein Erfolg, auch eine mit einem Fehler. Abgelehnt wird nur, wenn keine Antwort kommt. Den Fehler zeigtresponse.ok, das bei 503falseist. - Zwei
awaitnacheinander starten die zweite Anfrage erst, wenn die erste fertig ist.Promise.allwartet auf Anfragen, die schon gleichzeitig laufen. - Er bricht eine laufende Anfrage ab, etwa wenn ihre Antwort niemand mehr braucht oder sie zu lange dauert.
Auftrag
Die Übersicht lädt den Katalog vom Server und meldet Probleme verständlich. Martina Hostettler hat sie dem Vorstand gezeigt, und dort kam eine Frage auf. «Wozu braucht es eine App? Würde nicht eine gewöhnliche Website mit Seiten reichen?» Teil 2 geht dieser Frage nach. Vorher prüfst du mit dem Kompetenzraster, ob du für Teil 2 bereit bist. Wie Vue merkt, dass sich Daten ändern, zeigt der Exkurs Proxy und Getter.
Texte und Abbildungen stehen unter CC BY-SA 4.0, der Code unter der MIT-Lizenz und die Daten des Kurses unter CC0 1.0. Die Schrift der Abbildungen ist Kalam unter der SIL Open Font License 1.1. Die Quellen liegen auf GitHub.