Anulowanie przestarzałych zadań w React: AbortController do zmiany śledzenia
Dowiedz się, dlaczego pomijane ścieżki i przestarzałe zapytania nadal wpływają na interfejs użytkownika, jak AbortController anuluje rzeczywisty żądanie oraz w jaki sposób uzupełnia funkcje debounce i throttle w React.
Gdy użytkownik zmieni zdanie szybciej, niż sieć może zareagować, każde żądanie, które już rozpoczęto, nadal jest przetwarzane i z przyjemnością wyświetli swoje wyniki na ekranie. W polu wyszukiwania oznacza to wyniki z zapytania, od którego użytkownik zrezygnował; w odtwarzaczu multimedialnym oznacza to krótki fragment piosenki, którą właśnie przeskoczył. Czekanie lub ograniczanie szybkości nie rozwiązuje tego problemu, ponieważ praca jest już w trakcie wykonywania. Ten artykuł pokazuje, jak AbortController faktycznie anuluje takie prace, jak go integrować z efektami w React oraz jak łączy się on z mechanizmami debounce i throttle, zamiast je zastępować.
Zawody: odpowiedzi przychodzą w niewłaściwej kolejności
Weźmy pole wyszukiwania. Użytkownik wpisuje „ni”, a wysyłany jest pierwszy zapytanie. Następnie wpisuje „ke”, co powoduje wysłanie drugiego zapytania dotyczącego „nike”. Jeśli serwer reaguje wolniej na pierwsze, krótsze zapytanie, jego odpowiedź przychodzi po drugiej i zastępuje poprawne wyniki przestarzałymi.
Odtwarzacz audio stosuje ten sam mechanizm, ale z większymi konsekwencjami. Naciśnięcie utworu rozpoczyna jego ładowanie. Jeśli przed ukończeniem pierwszego utworu naciśniemy inny, rozpoczyna się drugie ładowanie, przez co oba utwory konkurują ze sobą. W takiej sytuacji słuchacz może usłyszeć pominięty utwór, pasek postępów może pokazywać błędną długość, albo oba utwory mogą próbować przejąć kontrolę nad odtwarzaczem.
Tutaj mechanizm debouncing nie będzie pomocny. Debouncing opóźnia rozpoczęcie działania, dopóki dane wejściowe się nie ustabilizują; nie ma wpływu na zapytanie, które już jest w trakcie wykonywania. Brakuje tu możliwości anulowania – sygnału polecającego zadaniu, które już się rozpoczęło, aby je zakończyć.
Co idzie nie tak bez anulowania
fetch, strumienie, ładowanie mediów oraz słuchacze zdarzeń to funkcje, które po rozpoczęciu działają dalej same z siebie. Bez możliwości ich zatrzymania pojawiają się trzy rodzaje problemów:
- Dane przestarzałe dominują. Starsza odpowiedź zostaje zrealizowana po nowszej i zastępuje to, co jest na ekranie lub w odtwarzaczu, który zaczyna grać.
- Marnotrawstwo zasobów. Szerokość pasma, bateria, CPU serwera oraz żądania do CDN są wykorzystywane na treści, których nikt nie zobaczy ani nie usłyszy.
- Interfejs „opętany”. Ikony spinające, które nigdy się nie zatrzymują, fala dźwiękowa poprzedniej piosenki, tytuł zmieniający się dwukrotnie w krótkim czasie.
Klasycznym rozwiązaniem jest umieszczenie takiej ochrony jak if (requestId !== latestId) return wewnątrz każdego callbacka lub ciche ignorowanie wyniku w metodzie .then(). Działa to tylko wtedy, gdy każdy callback pamięta o tej sprawdzeniu, a żądanie nadal zostaje zakończone i pobiera swoje dane. AbortController idzie o krok dalej, anulowując samą operację, a nie tylko zainteresowanie jej wynikiem.
Bazowy wzorzec AbortController
Kontroler udostępnia zmienną signal. Przekazujesz tę zmienną do dowolnej API, która ją akceptuje, a wywołanie metody abort() na kontrolerze poleca wszystkim posiadaczom tego sygnału, aby przestali działać:
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();
Trzy kwestie wymagają uwagi. Po pierwsze, sygnał jest przekazywany w opcjach fetch, dzięki czemu fetch wie, że może zostać anulowany. Po drugie, anulowanie powoduje odrzucenie obietnicy z błędem o nazwie AbortError, nawet jeśli nagłówki już dotarły, a res.json() nadal odczytuje treść. Po trzecie, blok catch traktuje ten błąd jako normalny, oczekiwany scenariusz zakończenia i ponownie rzuca wszystkie pozostałe błędy, więc prawdziwe awarie nie są ignorowane.
Celowa technika opiera się na prostym schemacie życiowym: jeden kontroler na jedną jednostkę intencji. Gdy użytkownik wybiera nową piosenkę, należy anulować stary kontroler i utworzyć nowy do obsługi nowego pliku. Kontroler nie może zostać sformatowany ponownie po anulowaniu, więc jego ponowne użycie w różnych żądaniach natychmiast anulowałoby dalszą pracę.
Jeśli wywołasz abort(reason) z dostosowanym powodem, funkcja fetch odrzuci żądanie z tym powodem zamiast standardowego błędu AbortError. W takim przypadku sprawdzanie wartości controller.signal.aborted jest bardziej niezawodnym sposobem na odróżnienie celowej anulacji od rzeczywistego błędu.
Powiązanie anulacji z efektem React
W React naturalnym miejscem na wywołanie funkcji abort jest czyszczenie efektu. Gdy wartość, która steruje żądaniem, pochodzi z atrybutów props lub stanu, żądanie powinno istnieć dokładnie tak długo, jak ta wartość:
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]);
Gdy zmienia się trackId, React wykonywa czyszczenie z poprzedniej renderizacji przed rozpoczęciem kolejnego efektu, dzięki czemu żądanie dotyczące starej ścieżki jest anulowane, a dopiero wtedy rozpoczyna się żądanie nowej. Gdy odtwarzacz jest demontowany, wykonywane jest to samo czyszczenie, co oznacza, że późna odpowiedź nie może nigdy wywołać funkcji setTrack w komponencie, który już nie istnieje.
Podczas rozwoju w trybie Strict Mode React celowo montuje, czyszczy i ponownie uruchamia efekty raz. Dzięki temu wzorcowi zobaczysz w panelu sieci jedno anulowane żądanie – to właśnie czyszczenie wykonywa swoją pracę, a mechanizm AbortError zapobiega temu, by pojawiło się ono jako błąd.
To samo sygnał może być używane nie tylko z metodą fetch. Akceptują je strumienie, biblioteki obsługujące XHR często też je używają, a funkcja addEventListener przyjmuje opcję signal, która usuwa słuchacz, gdy sygnał zostanie przerwany. Jedna uwaga dotycząca odtwarzaczy: zwykły element <audio> nie obsługuje sygnałów. Aby zapobiec buforowaniu pominiętego pliku, należy również usunąć lub zastąpić jego wartość src podczas czyszczenia.
Debounce, throttle i abort rozwiązują różne problemy
Te trzy narzędzia często pojawiają się razem wokół pól wyszukiwania i elementów sterujących odtwarzaczem, więc łatwo je pomylić. Każde z nich działa w innym momencie:
- Debounce czeka, aż użytkownik zrobi przerwę, a następnie wykonuje działanie tylko raz. Szybkie wpisanie „n-i-k-e” może spowodować wysłanie jednej prośby po ostatnim naciśnięciu klawisza.
Innymi słowy, mechanizmy debounce i throttle decydują, kiedy można rozpocząć nowe zadanie, natomiast abort określa, czy zadanie już uruchomione może być kontynuowane.
Pole wyszukiwania, które reaguje szybko, zazwyczaj wykorzystuje oba te mechanizmy. Debounce zapobiega wysyłaniu żądania za każdym razem, gdy naciśnięto klawisz, a abort gwarantuje, że żądanie już wysłane nie może powrócić później i przepisać nowszych wyników. Specjalny artykuł na temat warunków konkurencyjnych, których debounce nie może rozwiązać w interfejsach wyszukiwania szczegółowo omawia ten przypadek.
Gracz postępuje według tej samej struktury. Można ograniczyć funkcję przycisku „następny”, aby zaniepokojony użytkownik nie mógł uruchomić dwudziestu załadunków w ciągu 200 ms, ale takie ograniczenie niczego nie anuluje – jedynie rozrzedza kolejne operacje. Nadal trzeba przerwać załadunek, który już się rozpoczął.
Każda z tych metod sama w sobie pozostawia luki: mechanizm debounce nadal pozwala na to, by wolna, wczesna prośba dotarła późno, a sama funkcja przerwania nadal zalewa serwer.
Analiza sytuacji podczas szybkiego przewijania listy odtwarzania
Rozważmy, co dzieje się w odtwarzaczu audio, gdy ktoś przewija listę szybciej, niż sieć jest w stanie za tym nadążyć.
Użytkownik kliknie utwór A. Aplikacja żąda metadanych, być może podpisanej adresacji URL pliku, obrazka okładkowego oraz wykresu fal, a dźwięk zaczyna się buforować. Zanim cokolwiek to zakończy, użytkownik kliknie utwór B, a potem C.
Bez możliwości przerwania wszystko, co zostało rozpoczęte dla utworu A, nadal przychodzi:
- Metadane utworu A docierają i ustawiają tytuł.
Po anulowaniu operacji, kliknięcie w B przerywa wszystko, co zostało zainicjowane w imieniu A. Jedynymi elementami, które mogą edytować dźwięk, tytuł oraz wykres falowy, są te należące do utworu wybranego w danym momencie. Ponowne kliknięcie „Pomijaj” anuluje działanie B, a C nadal się odtwarza. Po zamknięciu odtwarzacza odbywa się czyszczenie, a nic już nie jest zapisywane do elementu, który przestał istnieć.
Dziel się jednym kontrolerem we wszystkich żądaniach dotyczących wyboru, a pojedyncze wywołanie abort() niszczy całą grupę.
Ostatecznym efektem jest to, że interfejs użytkownika odzwierciedla wyłącznie aktualną intencję użytkownika.
To samo wzorce w większych aplikacjach
Wielkie aplikacje stosują tę zasadę wszędzie:
- Typeahead i filtry. Nowe zapytanie anuluje poprzednie żądanie, co odpowiada sytuacji z listą odtwarzania, gdzie wyniki wyszukiwania zastępują piosenki.
- Routing po stronie klienta. Gdy użytkownik opuszcza stronę przed powrotem jej danych, frameworki i biblioteki takie jak Next.js, Remix, TanStack Query oraz SWR mogą przerwać bieżące operacje nawigacyjne; w gruncie rzeczy nadal chodzi o sygnał anulowania. Sprawdź dokumentację każdej z tych bibliotek, aby dowiedzieć się dokładnie, kiedy są one anulowane i czy przekazują sygnał do narzędzia fetch.
controller.abort() znajdującej się za tym przyciskiem.Główne wnioski
- Funkcje debounce i throttle kontrolują moment rozpoczęcia pracy; tylko możliwość anulowania decyduje o tym, czy rozpoczęta praca zostanie dokończona.
- Należy utworzyć jeden obiekt
AbortControllerna każdą jednostkę zadania, przekazywać jegosignalwe wszystkich miejscach, gdzie odbywa się praca, oraz zastępować go nowym obiektem zamiast go ponownie używać. AbortErrornależy traktować jako normalny scenariusz zakończenia pracy, a inne błędy należy ponownie rzucić.
Anulowanie żądań samo w sobie nie sprawi, że interfejs stanie się inteligentniejszy, ale gwarantuje, że żądania z wczoraj nie będą mogły kolidować z tymi z dzisiaj. Gdy już raz zobaczysz, jak takie konflikty objawiają się na ekranie lub przez głośnik, umieszczanie sygnału przy każdym żądaniu, które może aktualizować interfejs, staje się nawykiem wartym utrzymania.
Literatura pokrewna
- Optimistyczne interfejsy bez niezgodności: Zdjęcia stanu, natychmiastowe aktualizacje, cofanie zmian w przypadku błędu, anulowanie przestarzałych prośb oraz kiedy nie należy stosować tego podejścia. — Dowiedz się, jak tworzyć aktualizacje optymistyczne, które pozostają poprawne: utrzymywanie stanu w formie zdjęcia, natychmiastowe aktualizacje, cofanie zmian przy awarii, anulowanie przestarzałych żądań oraz rozpoznawanie sytuacji, gdy nie należy stosować tego wzorca.
- Naprawianie sytuacji wyścigowych – dlaczego debouncing sam w sobie nie może zapobiec nadpisywaniu nowego stanu interfejsu przez przestarzałe odpowiedzi API oraz cztery praktyczne rozwiązania umożliwiające zapewnienie właściwej kolejności żądań. — Dowiedz się, dlaczego sam debouncing nie może zapobiec temu, by przestarzałe odpowiedzi API nadpisywały nowy stan interfejsu, oraz zapoznaj się z czterema praktycznymi metodami na zapewnienie właściwej kolejności wysyłanych żądań.