// apis · Web Platform Advent #20
AbortController: Fetches, Zeitlimits und Event-Listener abbrechen
Ein einziger Controller bricht einen Fetch, ein Zeitlimit und einen ganzen Stapel Event-Listener ab. Wie AbortController und AbortSignal funktionieren, warum ein manueller Abbruch und ein Zeitlimit unterschiedliche Fehler werfen, und der Aufräum-Trick, den die meisten übersehen.
Eine asynchrone Operation zu starten ist leicht. Eine zu stoppen, die dich nicht mehr interessiert, ist der Teil, den der meiste Code auslässt, und genau daher kommen Speicherlecks, Race Conditions und veraltete Ergebnisse. AbortController ist die eine Antwort des Browsers auf all das.
Ein Controller, ein Signal
MDN definiert es unmissverständlich: Die Schnittstelle AbortController repräsentiert ein Controller-Objekt, mit dem du eine oder mehrere Web-Anfragen nach Bedarf abbrechen kannst. Die Form ist immer dieselbe. Du erzeugst einen Controller, liest sein signal und gibst dieses Signal an das weiter, was du gerade startest.
Die Eigenschaft signal gibt eine AbortSignal-Objektinstanz zurück, die verwendet werden kann, um mit einer asynchronen Operation zu kommunizieren oder sie abzubrechen. Den Controller behältst du; das Signal ist das, was du weggibst.
const controller = new AbortController();
const res = await fetch('/api/articles', { signal: controller.signal });
// an anderer Stelle, später:
controller.abort(); Achte auf die Richtung. Der Code, der die Arbeit macht, entscheidet nie selbst aufzuhören; er willigt nur ein zuzuhören. Diese Trennung ist es, die es einem einzigen Aufrufer erlaubt, viele Operationen auf einmal abzubrechen.
Die zwei Fehler, und warum das zählt
Das ist der Teil, den man sorgfältig lesen sollte, denn derselbe Abbruch kann zwei verschiedene Dinge werfen.
Wenn du abort() selbst aufrufst, wird das Promise von fetch() mit einer DOMException namens AbortError abgelehnt. Kam das Signal von AbortSignal.timeout(), wird das Promise von fetch() stattdessen mit einer TimeoutError-DOMException abgelehnt.
Die gewohnte Prüfung ist also unvollständig:
try {
const res = await fetch(url, { signal });
} catch (err) {
if (err.name === 'AbortError') {
// die Nutzerin oder unser eigener Code hat abgebrochen
} else if (err.name === 'TimeoutError') {
// die Frist ist abgelaufen - eine ganz andere Situation
} else {
throw err; // ein echter Netzwerk- oder Parsing-Fehler
}
} Beides zusammenzuwerfen kostet echte Information. Wenn jemand weiternavigiert, ist das kein Problem; eine Anfrage, der die Zeit ausgegangen ist, wahrscheinlich schon, und sie ist es wert, gemeldet oder wiederholt zu werden. Code, der nur nach AbortError schaut, behandelt jedes Zeitlimit als stilles Nicht-Ereignis.
Zeitlimits ohne Drumherum
Das Muster, das die meisten immer noch schreiben, kombiniert einen Controller mit einem setTimeout und einem clearTimeout in einem finally-Block. Es funktioniert, und es ist nicht mehr nötig.
AbortSignal.timeout() gibt eine AbortSignal-Instanz zurück, die nach einer angegebenen Zeit automatisch abbricht. Kein Controller, kein Timer zum Aufräumen, nichts, was lecken kann:
// Früher: drei bewegliche Teile
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
// Jetzt: eines
const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); Achte bei diesem Wechsel auf den Fehlernamen. Die alte Version warf AbortError, die neue wirft TimeoutError, jeder bereits vorhandene catch-Block muss also angepasst werden.
Der Trick, den die meisten übersehen: Event-Listener
AbortSignal ist keine Fetch-Funktion. addEventListener nimmt in seinen Optionen ein signal entgegen, und wenn dieses Signal abbricht, wird der Listener für dich entfernt. Das ersetzt ganz nebenbei den mühsamsten Aufräumcode der Frontend-Arbeit.
Vergleiche das mit dem Entfernen von Listenern per Hand, das eine Referenz auf jede übergebene Funktion voraussetzt:
// Vorher: du musst jeden Handler behalten, um ihn zu entfernen
function onScroll() { /* ... */ }
function onResize() { /* ... */ }
window.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
function teardown() {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
} Mit einem Signal dürfen die Handler wieder inline stehen, und ein einziger Aufruf entfernt sie alle:
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('scroll', () => { /* ... */ }, { signal });
window.addEventListener('resize', () => { /* ... */ }, { signal });
document.addEventListener('keydown', () => { /* ... */ }, { signal });
// ein Aufruf entfernt alle drei
controller.abort(); Derselbe Controller kann außerdem das Signal für einen laufenden fetch halten. Eine Ansicht abzubauen wird damit zu einem einzigen abort(), das die Anfrage abbricht und alle Listener zugleich löst.
Signale kombinieren und den Zustand auslesen
AbortSignal.any() gibt ein AbortSignal zurück, das abbricht, sobald eines der übergebenen Abbruchsignale abbricht. So bekommst du eine Frist und einen manuellen Abbruch für dieselbe Operation:
const controller = new AbortController();
const signal = AbortSignal.any([
controller.signal, // die Nutzerin hat auf Abbrechen gedrückt
AbortSignal.timeout(10000), // oder es sind zehn Sekunden vergangen
]);
const res = await fetch(url, { signal }); Ein Signal verrät dir auch, woran es ist. aborted ist ein Boolean, der angibt, ob die Anfrage oder Anfragen, mit denen das Signal kommuniziert, abgebrochen sind, und reason ist ein JavaScript-Wert, der den Abbruchgrund liefert, sobald das Signal abgebrochen hat. Einen eigenen Grund an abort() zu übergeben ist der Weg, die Ursachen später zu unterscheiden:
controller.abort(new Error('User navigated away'));
// später
if (signal.aborted) console.log(signal.reason.message); Es gibt zudem ein abort-Ereignis, das ausgelöst wird, wenn die asynchronen Operationen, mit denen das Signal kommuniziert, abgebrochen werden. Damit kann auch dein eigener lang laufender Code mitspielen und nicht nur die eingebauten APIs.
Die eigene Funktion abbrechbar machen
Alles, was ein Signal entgegennimmt, gehört zum selben System. Das Muster: vor dem Start aborted prüfen und während der Ausführung auf das abort-Ereignis hören:
function wait(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(id);
reject(signal.reason);
}, { once: true });
});
} Jetzt lässt sich dein eigener Helfer von demselben Controller abbrechen, der auch deine Fetches abbricht, und genau darum gibt es einen Mechanismus statt mehrerer.
Kurzreferenz
| Ziel | Aufruf |
|---|---|
| Manuell abbrechen | controller.abort(), wirft AbortError |
| Eine Frist ergänzen | AbortSignal.timeout(ms), wirft TimeoutError |
| Eines von beiden | AbortSignal.any([a, b]) |
| Listener automatisch entfernen | addEventListener(type, fn, { signal }) |
| Zustand prüfen | signal.aborted |
| Den Grund herausfinden | signal.reason |
| Auf den Abbruch reagieren | das abort-Ereignis |
AbortController ist eine kleine API, die unauffällig eine große Klasse von Aufräumproblemen löst. Gib ein Signal an deine Anfragen, deine Listener und deine eigenen asynchronen Helfer, und stoppe sie dann alle mit einem einzigen Aufruf. Denk nur daran, welchen Fehler du gerade abfängst, denn ein manueller Abbruch und eine abgelaufene Frist sind nicht dasselbe Ereignis.
Zur Anfrageseite davon siehe unseren Leitfaden zur Fetch-API in JavaScript.
Häufige Fragen
- Wofür ist AbortController da?
- MDN beschreibt ihn als ein Controller-Objekt, mit dem du eine oder mehrere Web-Anfragen nach Bedarf abbrechen kannst. Du erzeugst einen Controller, gibst sein signal an das weiter, was du gestartet hast, und rufst abort() auf, wenn es aufhören soll. Das ist nicht auf fetch beschränkt: Alles, was ein AbortSignal entgegennimmt, lässt sich auf dieselbe Weise abbrechen.
- Welchen Fehler wirft ein abgebrochener Fetch?
- Das hängt davon ab, wie er abgebrochen wurde, und genau daran scheitern viele. Wenn du AbortController.abort() selbst aufrufst, wird das Promise des Fetch mit einer DOMException namens AbortError abgelehnt. Kam das Signal von AbortSignal.timeout(), wird das Promise stattdessen mit einer TimeoutError-DOMException abgelehnt. Code, der nur auf AbortError prüft, übergeht den Zeitlimit-Fall stillschweigend.
- Wie ergänze ich einen Fetch um ein Zeitlimit?
- AbortSignal.timeout() gibt eine AbortSignal-Instanz zurück, die nach einer angegebenen Zeit automatisch abbricht. Du übergibst also AbortSignal.timeout(5000) als Signal und brauchst weder Controller noch setTimeout noch clearTimeout. Denk daran, dass es mit TimeoutError abgelehnt wird, nicht mit AbortError.
- Kann ein Controller mehrere Dinge gleichzeitig abbrechen?
- Ja, und genau darum geht es. Ein Signal lässt sich an beliebig viele Operationen weitergeben, sodass ein einziger abort()-Aufruf jeden Fetch stoppt und jeden Event-Listener entfernt, der es erhalten hat. Das ist die sauberste Aufräummöglichkeit, wenn eine Ansicht abgebaut wird.
- Kann ich ein Zeitlimit mit einem manuellen Abbruch kombinieren?
- AbortSignal.any() gibt ein AbortSignal zurück, das abbricht, sobald eines der übergebenen Abbruchsignale abbricht. Übergib ihm das Signal deines Controllers und ein Zeitlimit-Signal, und die Operation stoppt bei dem, was zuerst eintritt.