Zum Inhalt
wannacode

Daten von einer API in einer Komponente

Kurz gesagt: Daten von einer API werden in einer Vue-Komponente aus watch geladen, mit dem Zustand „lädt“, „Fehler“ oder „fertig“, und ein AbortController im Aufräumen stoppt die veraltete Anfrage.

Problem

Äpfel unter „Getränke“

Der Katalog holt die Produkte der Kategorie von der API. Hier antwortet der Server für „Obst und Gemüse“ in 1,5 Sekunden und für die anderen Kategorien in 0,3.

Vue

Klick auf „Obst und Gemüse“ und gleich danach auf „Getränke“. Die Getränke erscheinen. Eine Sekunde später nehmen Äpfel, Bananen und Tomaten ihren Platz ein, während die Überschrift und der gedrückte Button weiter „Getränke“ sagen. Der Kunde sieht die falschen Produkte und weiß nicht, warum.

Die Idee

Eine Anfrage ist ein Effekt

Die Anfrage verlässt die Komponente in Richtung Server. Damit ist sie eine Nebenwirkung, und ihr Platz ist im Effekt, mit der Kategorie als Abhängigkeit: Jede neue Kategorie schickt eine neue Anfrage.

Die Zustände sind die aus der Lektion über fetch in der Seite: lädt, Fehler, fertig. Hier sind sie ein einziger Zustand, status, und die Komponente zeichnet den passenden Zweig, wie in der Lektion über bedingtes Zeichnen.

watch(() => props.categoryId, async (categoryId) => {
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
status.value = 'error';
}
}, { immediate: true });

Die Funktion von watch darf async sein. { immediate: true } lädt die erste Kategorie, sobald die Komponente erstellt ist, und danach schickt jede Änderung von categoryId eine neue Anfrage.

status wird bei jedem Laden zu 'loading', nicht nur beim ersten Mal. Sonst bleibt bei einem Wechsel die alte Liste unter der neuen Überschrift stehen, bis die Antwort kommt.

Probier es aus

Lade die Kategorie

Catalog hat die Zustände products und status, aber nichts füllt sie. Lade die Produkte von categoryId von /api/products?category=… und zeige bei jedem Laden „Wird geladen…“. Die Prüfung schaut auf die Milchprodukte (5 Produkte), klickt dann auf „Getränke“ und wartet auf „Wird geladen…“ und 4 Produkte.

Vue

Die Idee

Der Wettlauf

Jeder Klick ändert die Kategorie und führt den Effekt noch einmal aus. Jeder Lauf schickt seine eigene Anfrage und wartet auf seine eigene Antwort. Keiner weiß von den anderen.

Antworten kommen nicht in der Reihenfolge der Anfragen. Der Server sucht das Obst vielleicht länger, das Netz bremst eine von beiden. Wenn der Kunde schnell wechselt, sind also zwei Anfragen gleichzeitig unterwegs. Die, die zuletzt antwortet, gewinnt, nicht die, die zuletzt losging.

Das heißt Wettlaufbedingung (Race Condition). Sie zeigt sich nicht, solange der Server schnell und gleichmäßig ist. Sie zeigt sich in einem langsamen Netz, auf einem Handy, bei einem ausgelasteten Server.

Sieh es dir an

Zwei Anfragen unterwegs

Die Balken wachsen, solange eine Anfrage unterwegs ist. Zuerst ohne Aufräumen, dann mit.

watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const url = `/api/products?category=${categoryId}`;
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
if (e.name === 'AbortError') return;
status.value = 'error';
}
}, { immediate: true });
Schritt für Schritt, in Worten
  1. Der Kunde klickt auf „Obst und Gemüse“. Die Kategorie ändert sich, der Effekt läuft und schickt eine Anfrage. Diese ist langsam.
  2. Bevor sie antwortet, klickt der Kunde auf „Getränke“. Der Effekt läuft wieder und schickt eine zweite Anfrage. Jetzt sind zwei Anfragen gleichzeitig unterwegs.
  3. Die schnelle Anfrage antwortet zuerst: Die Liste zeigt die Getränke.
  4. Angenommen, das Aufräumen fehlt. Die langsame Anfrage antwortet zuletzt, ihr Lauf setzt ebenfalls die Produkte, und unter „Getränke“ steht Obst.
  5. Jetzt mit dem Aufräumen. Jeder Lauf des Effekts erzeugt seinen eigenen AbortController und gibt dessen signal an fetch weiter.
  6. Die neue Kategorie ruft das Aufräumen des alten Laufs auf: abort() stoppt die langsame Anfrage. Ihr fetch wirft AbortError, und catch überspringt ihn: Das ist kein Fehler.
  7. Nur die Anfrage für die Getränke antwortet. Liste und Überschrift passen zusammen.
  8. Die Regel: Eine Anfrage in einem Effekt hat im Aufräumen einen Abbruch. Der letzte Lauf gewinnt, weil alle davor gestoppt wurden.

Ohne Aufräumen zeigt der Bildschirm die letzte Antwort. Mit Aufräumen wird die alte Anfrage gestoppt, bevor sie antwortet, und die letzte Antwort gehört immer zur letzten Kategorie.

Die Idee

Abbrechen im Aufräumen

Der Effekt hat schon einen Platz für „stopp das Alte“: das Aufräumen. Der Browser hat ein Werkzeug, um eine Anfrage zu stoppen: AbortController.

  1. Jeder Lauf des Effekts erzeugt seinen eigenen controller = new AbortController().
  2. Er gibt sein controller.signal an fetch weiter: fetch(url, { signal: controller.signal }).
  3. Das Aufräumen ruft controller.abort() auf. Die Anfrage stoppt, und await fetch(…) wirft einen Fehler namens AbortError.
  4. catch überspringt diesen Fehler: if (e.name === 'AbortError') return;. Die Anfrage ist nicht fehlgeschlagen; der Code hat sie gestoppt.
watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const response = await fetch(url, { signal: controller.signal });
…
} catch (e) {
if (e.name === 'AbortError') return;
status.value = 'error';
}
}, { immediate: true });

onCleanup steht vor dem ersten await. Steht es danach, wird das Aufräumen erst festgehalten, wenn die Antwort schon da ist, und es gibt nichts mehr zu stoppen.

Das Aufräumen läuft auch, wenn die Komponente verschwindet. Der Kunde hat den Katalog verlassen: Die Anfrage stoppt, und niemand setzt Produkte in einer Komponente, die es nicht mehr gibt.

Probier es aus

Stoppe die alte Anfrage

Sorge dafür, dass die alte Anfrage stoppt, wenn der Kunde schnell wechselt. Die Prüfung klickt auf „Obst und Gemüse“ und gleich danach auf „Getränke“, wartet auf „Wird geladen…“ und die Getränke, wartet die langsame Antwort ab und stellt sicher, dass keine Äpfel dastehen und keine Fehlermeldung erschienen ist.

Vue

Probier es aus

Finde den Fehler

Der Kunde wechselt schnell und sieht kurz „Die Produkte wurden nicht geladen.“, dann kommen die Produkte. Der Server hat keinen Fehler zurückgegeben. Welche Zeile ist schuld?

Wähle die Zeile mit dem Fehler.

Probier es aus

Fülle die Lücke

Was stoppt die alte Anfrage?

watch(() => props.categoryId, (categoryId, oldId, onCleanup) => {
  const controller = new AbortController();
  onCleanup(() => controller.());
  fetch(url, { signal: controller.signal }).then(show);
}, { immediate: true });

Die Idee

Fehler und „Noch einmal versuchen“

Manchmal antwortet der Server mit 500. Die Prüfung von response.ok wirft, catch setzt 'error', und die Komponente zeigt die Meldung mit role="alert". Der Kunde soll es noch einmal versuchen können, ohne die Seite neu zu laden.

Noch einmal versuchen heißt dasselbe Laden. Das macht der Effekt schon, wenn sich eine Abhängigkeit ändert. Also ändert „Noch einmal versuchen“ eine Abhängigkeit: eine Zahl attempt, die nur die Versuche zählt.

const attempt = ref(0);
watch([() => props.categoryId, attempt], async ([categoryId], old, onCleanup) => {
// … dasselbe Laden
}, { immediate: true });
<button type="button" @click="attempt++">Noch einmal versuchen</button>

Die Quelle ist ein Array: watch läuft, wenn sich eins von beiden ändert. Die Funktion bekommt ein Array mit den Werten.

Der neue Lauf setzt 'loading', deshalb verschwinden die Meldung und der Button, solange die Anfrage unterwegs ist.

Genauer, für Neugierige

Daten-Bibliotheken. In einem echten Projekt wird das Laden selten in jeder Komponente von Hand geschrieben. Es gibt Bibliotheken, die das und mehr erledigen: Sie halten die Antworten in einem Cache, schicken nicht zwei gleiche Anfragen, versuchen es von selbst noch einmal und aktualisieren die Daten, wenn der Kunde zum Tab zurückkommt. Für Vue sind das useFetch aus VueUse, Pinia Colada und TanStack Query für Vue. Sie sind nicht in der Sandbox, und der Kurs hat keine Lektionen dazu. Im Inneren tun sie dasselbe wie hier: eine Anfrage in einem Effekt, Zustände, ein Abbruch.

Ein Flag statt eines Abbruchs. Statt eines AbortController kann das Aufräumen ein Flag setzen: let ignore = false; im Effekt, ignore = true im Aufräumen und if (!ignore) vor dem Setzen der Produkte. Die alte Anfrage erreicht den Server trotzdem, aber ihre Antwort wird verworfen. Ein Abbruch stoppt die Anfrage selbst. Beide Wege lösen den Wettlauf.

Vue und der Abbruch. Ein Watcher aus <script setup> stoppt von selbst, wenn die Komponente verschwindet, und ruft das letzte onCleanup auf. Eine Anfrage, die noch unterwegs ist, wird also auch abgebrochen, wenn der Kunde die Seite verlässt.

Übung

Noch einmal versuchen

Hier bekommt die erste Anfrage für die Milchprodukte eine 500. Der Katalog zeigt „Die Produkte wurden nicht geladen.“, aber es gibt keine Möglichkeit, es noch einmal zu versuchen. Füge einen Button „Noch einmal versuchen“ hinzu, der das Laden erneut ausführt. Der Abbruch beim schnellen Wechseln muss bleiben. Die Prüfung wartet auf den Fehler, klickt auf „Noch einmal versuchen“, wartet auf „Wird geladen…“ und 5 Produkte und wechselt dann schnell zu Obst und Getränken.

Vue

Test

Teste dich

1. Der Kunde klickt auf „Obst und Gemüse“, dann auf „Getränke“. Die Antworten kommen in umgekehrter Reihenfolge. Was zeigt Code ohne Aufräumen?
2. Warum hat der Watcher der Kategorie { immediate: true }?
3. Warum überspringt catch den AbortError, statt „Die Produkte wurden nicht geladen.“ anzuzeigen?
4. Wie schickt „Noch einmal versuchen“ die Anfrage erneut?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·