Startseite / Artikel / Superseded Work in React löschen: AbortController für den Wechsel der Spur

Superseded Work in React löschen: AbortController für den Wechsel der Spur

Erfahren Sie, warum übersprangene Spuren und veraltete Abfragen weiterhin in Ihre Benutzeroberfläche geschrieben werden, wie AbortController die eigentliche Anfrage stört und wie er debounce sowie throttle in React ergänzt.

1747 Wörter

Wenn ein Benutzer seine Meinung schneller ändert, als das Netzwerk reagieren kann, läuft jede bereits gestartete Anfrage weiter und schreibt ihr Ergebnis gerne auf den Bildschirm. In einem Suchfeld bedeutet das Ergebnisse für eine Abfrage, die der Benutzer aufgegeben hat; in einem Mediaplayer bedeutet es einen kurzen Abspielvorgang des Liedes, das er gerade übersprungen hat. Warten oder Rate-Limiting löst dieses Problem nicht, denn die Arbeit ist bereits im Gange. Dieser Artikel zeigt, wie AbortController diese Arbeit tatsächlich abbricht, wie man ihn in einen React-Effect integriert und wie er sich neben Debounce- und Throttle-Funktionen einfügt, anstatt sie zu ersetzen.

Der Wettlauf: Antworten kommen in der falschen Reihenfolge an

Nehmen wir ein Suchfeld. Der Benutzer tippt „ni“, und es wird eine Anfrage gesendet. Er tippt „ke“, und es folgt eine zweite Anfrage für „nike“. Wenn der Server bei der ersten, kürzeren Abfrage langsamer ist, kommt seine Antwort nach der zweiten und überschreibt die korrekten Ergebnisse mit veralteten.

Ein Audio-Player folgt demselben Muster, nur mit höheren Konsequenzen. Das Antippen eines Tracks startet dessen Laden. Wenn vor dem Ende des Ladens ein anderer Track angeklickt wird, beginnt das Laden erneut, sodass nun beide miteinander konkurrieren. Für einen Moment könnte der Zuhörer den übersprungenen Track hören, die Ladeanzeige könnte eine falsche Dauer anzeigen, oder beide Tracks könnten versuchen, die Kontrolle über den Player zu übernehmen.

Debouncing würde hier nicht helfen. Debounce verzögert den Start der Verarbeitung, bis die Eingabe stabil ist; es ändert nichts an einer bereits laufenden Anfrage. Was fehlt, ist die Möglichkeit zur Stoppung: dem bereits begonnenen Vorgang mitzuteilen, dass er abgebrochen werden soll.

Was schiefgeht, wenn keine Stornierung erfolgt

fetch, Streams, das Laden von Medien sowie Event-Listener stellen alle Aufgaben dar, die einmal gestartet weiterlaufen. Ohne Möglichkeit, sie zu stoppen, treten in der Regel drei Arten von Schäden auf:

  • Veraltete Daten dominieren. Eine ältere Antwort wird nach einer neueren verarbeitet und ersetzt das, was auf dem Bildschirm angezeigt wird – oder in einem Player beginnt das alte Material abzuspielen.
  • Vergeblich genutzte Ressourcen. Bandbreite, Akkulaufzeit, Server-CPU sowie CDN-Anfragen werden für Inhalte aufgewendet, die niemand sehen oder hören wird.
  • Eine störende Benutzeroberfläche. Immer laufende Ladeindikatoren, eine Wellenform aus dem vorherigen Song oder ein Titel, der in kurzer Folge zweimal ändert.

Die klassische Workaround-Lösung besteht darin, in jedem Callback eine Prüfung wie if (requestId !== latestId) return einzufügen oder das Ergebnis in einem .then()-Aufruf einfach zu ignorieren. Dies funktioniert nur, wenn jeder Callback an diese Prüfung denkt und die Anfrage dennoch abgeschlossen wird, um ihre Bytes herunterzuladen. AbortController geht einen Schritt weiter, indem er die Operation selbst stört, anstatt lediglich das Interesse am Ergebnis zu beenden.

Das grundlegende AbortController-Muster

Ein Controller stellt ein signal zur Verfügung. Sie übergeben dieses Signal an jede API, die eines akzeptiert, und durch Aufruf von abort() auf dem Controller wird jedem Träger des Signals mitgeteilt, aufzuhören:

const controller = new AbortController();

fetch("/api/tracks/123", { signal: controller.signal })
  .then((res) => res.json())
  .then((track) => loadIntoPlayer(track))
  .catch((err) => {
    if (err.name === "AbortError") {
      // expected. they picked a different song.
      return;
    }
    throw err;
  });

// they skipped, or left the page, or closed the player
controller.abort();

Drei Aspekte verdienen besondere Aufmerksamkeit. Erstens wird das Signal über die Optionen von fetch übergeben, wodurch fetch erfährt, dass es abgebrochen werden kann. Zweitens führt ein Abbruch dazu, dass die Promise mit einem Fehler vom Typ AbortError abgelehnt wird, selbst wenn die Header bereits eingetroffen sind und res.json() noch den Inhalt liest. Drittens behandelt der catch-Block diesen Fehler als normales, erwartetes Szenario und wirft alle anderen Fehler erneut aus, sodass echte Fehlschläge nicht unterdrückt werden.

Die gesamte Technik basiert auf einem einfachen Lebenszyklus: Ein Controller pro Aufgabenbereich. Wenn der Benutzer ein neues Lied auswählt, wird der alte Controller abgebrochen und ein neuer für die neue Ladeoperation erstellt. Ein Controller kann nach einem Abbruch nicht zurückgesetzt werden, weshalb seine Wiederverwendung in verschiedenen Anfragen die zukünftigen Abläufe sofort abbrechen würde.

Falls Sie abort(reason) mit einem benutzerdefinierten Grund aufrufen, lehnt fetch diesen Grund anstelle des Standardwertes AbortError ab. In diesem Fall ist das Überprüfen von controller.signal.aborted eine zuverlässigere Methode, um zwischen einer bewussten Abbruchaktion und einem echten Fehler zu unterscheiden.

Verknüpfung des Abbruchs mit einem React-Effekt

In React liegt der natürliche Ort für den Abbruchaufruf in der Effekt-Reinigung. Wenn der Wert, der eine Anfrage auslöst, von Props oder State stammt, sollte die Anfrage genau so lange bestehen wie dieser Wert:

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/tracks/${trackId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setTrack)
    .catch((err) => {
      if (err.name === "AbortError") return;
      setError(err);
    });

  return () => controller.abort();
}, [trackId]);

Wenn sich trackId ändert, führt React die Aufräumarbeiten aus der vorherigen Renderung aus, bevor er den nächsten Effekt startet. Dadurch wird die noch laufende Anfrage für das alte Lied abgebrochen, und erst danach beginnt die neue Anfrage. Wenn der Player deinstalliert wird, laufen dieselben Aufräumarbeiten – das bedeutet, dass eine späte Antwort niemals setTrack auf einem nicht mehr existierenden Komponenten aufrufen kann.

Im Entwicklungsmodus mit Strict Mode montiert, führt React die Effekte absichtlich einmal auf, kümmert sich um die Aufräumarbeiten und führt sie erneut aus. Mit diesem Muster wird man im Netzwerk-Panel eine abgebrochene Anfrage sehen; das ist die Aufräumararbeit, die ihre Aufgabe erfüllt, und der AbortError-Schutz verhindert, dass dies als Fehler angezeigt wird.

Derselbe Signal kann mehr als nur fetch steuern. Streams akzeptieren ihn, Bibliotheken, die XHR umhüllen, tun das oft ebenfalls, und addEventListener nimmt eine signal-Option entgegen, die den Zuhörer entfernt, wenn das Signal abgebrochen wird. Eine Einschränkung für Player: Ein einfaches <audio>-Element nimmt kein Signal entgegen. Um zu verhindern, dass es ein übersprungenes File weiter buffernd speichert, muss man in der Aufräumfunktion auch seinen src-Wert löschen oder ersetzen.

Debounce, Throttle und Abort lösen unterschiedliche Probleme

Diese drei Werkzeuge tauchen häufig gemeinsam bei Suchfeldern und Player-Steuerelementen auf, wodurch sie leicht verwechselt werden können. Jedes wirkt zu einem anderen Zeitpunkt:

  • Debounce wartet, bis der Benutzer pausiert, und führt anschließend die Aktion einmal aus. Schnelles Eingeben von „n-i-k-e“ kann nach der letzten Tasteneingabe nur eine einzige Anfrage auslösen.
  • Throttle erlaubt die Aktion höchstens einmal pro Zeitfenster. Er eignet sich für Scrollen, Anpassung der Größe sowie wiederholte „Weiterblättern“-Befehle: Die Aufgabe wird weiterhin ausgeführt, nur nicht bei jedem Ereignis.
  • Abort stoppt Arbeiten, die bereits begonnen haben.
  • Anders ausgedrückt: Debounce und Throttle entscheiden, wann eine neue Aufgabe gestartet werden darf, während Abort festlegt, ob bereits begonnene Arbeiten fortgesetzt werden dürfen.

    Eine Suchleiste, die reaktiv wirkt, verwendet in der Regel beide Mechanismen. Debounce verhindert, dass pro Tastendruck eine Anfrage gesendet wird, und Abort stellt sicher, dass bereits gesendete Anfragen nicht später zurückkehren und neuere Ergebnisse überschreiben können. Der spezielle Artikel zu Rennbedingungen, die Debounce in Suchoberflächen nicht lösen kann geht diesem Fall ausführlich nach.

    Ein Spieler folgt derselben Vorgehensweise. Man kann die „Next“-Schaltfläche einschränken, damit ein ungeduldiger Benutzer nicht innerhalb von 200 ms zwanzig Ladevorgänge auslösen kann, doch solche Einschränkungen heben nichts auf; sie verteilen lediglich die neuen Aufgaben im Zeitabstand. Den bereits begonnenen Ladevorgang muss man dennoch abbrechen.

    Jeder dieser Schritte hinterlässt eine Lücke: Die Verzögerungsfunktion lässt weiterhin einen langsam eingehenden Anfragen zu einem späten Zeitpunkt eintreffen, und ein reines Abbrechen überflutet den Server weiterhin.

    Durch eine Playlist-Rennsituation hindurch

    Betrachten Sie, was in einem Audio-Player passiert, wenn jemand durch eine Playlist navigiert, schneller als das Netzwerk mithalten kann.

    Der Benutzer tippt auf Titel A. Die App beantragt Metadaten, möglicherweise eine signierte URL für die Datei, ein Albumcover sowie eine Wellenform, und das Audio beginnt mit dem Buffern. Bevor all das abgeschlossen ist, tippt der Benutzer auf Titel B und anschließend auf Titel C.

    Ohne Abbruch kommen weiterhin alle für Titel A gestarteten Vorgänge an:

    • Die Metadaten von A erreichen die App und legen den Titel fest.
  • Aus dem Audio von A wird ein Clip angehängt, wodurch kurzzeitig das falsche Lied abgespielt wird.
  • Die angezeigte Dauer wechselt von der Länge von A zu der von B und schließlich zu der von C.
  • Auf Mobilgeräten werden zwei vollständige Dateien heruntergeladen, die niemals abgespielt werden.
  • Die Analysefunktionen könnten einen Abspielvorgang für A erfassen, weil die Anfrage erfolgreich war, obwohl das Lied nie gehört wurde.
  • Falls der Benutzer mitten während des Ladens wegklickt, beziehen sich die Zustandsaktualisierungen weiterhin auf einen nicht mehr aktiven Player.
  • Durch Stornierung beendet das Tippen auf B alles, was im Namen von A ausgelöst wurde. Die einzigen Reaktionen, die das Audioelement, den Titel sowie die Wellenform bearbeiten dürfen, gehören dem jeweils ausgewählten Track. Wenn erneut „Überspringen“ gewählt wird, wird die Verarbeitung von B gestoppt, während C weiterläuft. Wenn der Player geschlossen wird, findet eine Aufräumarbeit statt, und es bleibt nichts übrig, das auf einen nicht mehr vorhandenen Komponenten schreibt.

    Teilen Sie einen Controller für jede Anfrage zur Auswahl, und ein einziger abort() zerstört die gesamte Gruppe.

    Das Ergebnis ist, dass die Benutzeroberfläche stets nur die aktuelle Absicht des Benutzers widerspiegelt.

    Das gleiche Muster in größeren Anwendungen

    Große Anwendungen wenden dieses Konzept überall an:

    • Typeahead- und Filterfunktionen. Eine neue Abfrage storniert die vorherige Anfrage – das entspricht dem Wettlauf um Playlists mit Suchergebnissen anstelle von Liedern.
    • Klientenseitiges Routing. Wenn ein Benutzer eine Seite verlässt, bevor ihre Daten zurückgegeben werden, können Frameworks und Datenbibliotheken wie Next.js, Remix, TanStack Query und SWR die ausstehenden Navigationsaufgaben abbrechen; im Grunde handelt es sich dabei immer um ein Abbruchsignal. Prüfen Sie die Dokumentation jeder Bibliothek, um genau zu erfahren, wann sie abbricht und ob sie ein Signal an Ihren Fetcher weiterleitet.
  • Dashboards. Das Ändern des Zeitraums kann dazu führen, dass zehn Widgets neu geladen werden. Wenn man den Zeitraum zwei Sekunden später erneut ändert, ohne die erste Ladeoperation abzubrechen, können auf dem Bildschirm Werte aus zwei verschiedenen Zeitraumbereichen angezeigt werden.
  • Jedes Element mit einer Abbrechungstaste. Hochläden, Exportieren sowie das „Erstellen einstellen“ in einer Chat-Oberfläche sind allesamt Aufrufe an controller.abort() hinter der jeweiligen Taste.
  • Wichtige Erkenntnisse

    • Debounce- und Throttling-Mechanismen steuern, wann eine Aufgabe startet; nur die Abbruchfunktion bestimmt, ob bereits begonnene Arbeiten abgeschlossen werden.
    • Erstellen Sie einen AbortController pro Aufgabe, übergeben Sie seinen signal an alle Stellen, an denen die Aufgabe ausgeführt wird, und ersetzen Sie ihn statt ihn erneut zu verwenden.
    • Betrachten Sie AbortError als normales Beendigungsereignis und werfen Sie andere Fehler erneut aus.
    • In React sollte man im Effect-Cleanup abbrechen, damit Änderungen an Eingabefeldern oder das Entfernen der Komponente angehende Anfragen automatisch stornieren.
    • Man sollte bedenken, was ein Signal nicht erreicht – beispielsweise die eigene Ladeaktion eines Medienelements – und diese explizit bereinigen.

    Durch das Stornieren wird eine Benutzeroberfläche nicht automatisch intelligenter, doch es garantiert, dass gestrige Anfragen nicht mit heutigen in Konflikt geraten können. Sobald man gesehen hat, wie ein solcher Konflikt auf dem Bildschirm oder über Lautsprecher zum Tragen kommt, wird es zur guten Gewohnheit, jedem Antrag, der die UI aktualisieren darf, ein Signal zuzuweisen.