Pisanie narzędzia do odcinania sygnałów od zera: timerzy, argumenty i to
Stwórz funkcję debounce nadającą się do ponownego użycia, krok po kroku; dowiedz się, dlaczego przekazuje argumenty i w takich przypadkach należy zamiast tego użyć throttlingu, oraz jak połączyć ją z AbortController.
Źródła: Debouncing in JavaScript: How to Stop Sending Useless API Requests
Pole wyszukiwania, które wywołuje serwer za każdym razem, gdy wpisuje się literę, zamienia jedno napisane słowo w serię żądań, z których większość zostaje odrzucona. Rozwiązaniem jest mała narzędzie zwane debounce. Tutaj zostanie ono zaimplementowane, każdy wiersz zostanie wyjaśniony, należy wybrać między mechanizmem debounce a throttling oraz połączyć je z anulowaniem żądań, aby proces wyszukiwania funkcjonował poprawnie podczas rzeczywistego wpisywania tekstu.
Jak wpisywanie liter przekształca się w marnowane żądania
Załóżmy, że użytkownik szuka określonego frameworku. Wartość wprowadzana zmienia się po jednej literze na raz:
r
re
rea
reac
react
Jeśli każda zmiana wywołuje żądanie do API, serwer musi obsłużyć pięć żądań na jedno zapytanie. Bardziej precyzyjne wyszukiwanie pogarsza sytuację:
r
re
rea
reac
react
react j
react js
react js d
react js de
react js dev
To oznacza dziesięć żądań, a tylko ostatnie odzwierciedla to, czego chciał użytkownik; pozostałe nadal zużywają przepustowość, czas serwera oraz limity liczby żądań.
Idea stojąca za mechanizmem debounce
Debouncing odkłada wywołanie funkcji do momentu upływu określonego czasu bez dalszych wywołań: pozwól użytkownikowi skończyć, a dopiero wtedy wykonać zadanie.
Załóżmy, że wywołanie wyszukiwania jest opóźnione o 500 ms i użytkownik ponownie wpisuje te same pięć znaków:
r
re
rea
reac
react
Każde naciśnięcie klawisza restartuje licznik, więc nic nie jest wysyłane podczas dalszego pisania. Po 500 ms ciszy wysyłana jest jedna prośba:
react → API request
Pętla decyzyjna przy każdym wywołaniu:
Typing
↓
Wait 500ms
↓
Did user type again?
↓
Yes → Reset timer
↓
No → Execute function
Pole wyszukiwania bez żadnej ochrony
Zacznijmy od zwykłego elementu wejścia:
<input id="search" placeholder="Search..." />
Uzyskajmy do niego referencję w JavaScript:
const searchInput = document.querySelector("#search");
Następnie przymocujmy słuchacz input, który przekazuje wartość do funkcji wyszukiwania, która jedynie loguje to, aby móc policzyć, jak często jest wywoływana:
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});function searchProducts(query) {
console.log("API request:", query);
}
Załóżmy teraz, że ktoś wpisuje nazwę produktu:
iphone
Zdarzenie input jest wyzwalane raz na każdy znak, mniej więcej w takiej kolejności:
i
ip
iph
ipho
iphon
iphone
Dlatego obsługa jest wykonywana sześć razy i zapisywane są sześć żądań:
API request: i
API request: ip
API request: iph
API request: ipho
API request: iphon
API request: iphone
W przypadku rzeczywistego API jest to sześć ruchów tam i z powrotem, podczas gdy użytkownik interesuje się tylko:
iphone
Implementacja funkcji debounce w kilku liniach
To narzędzie to zamknięcie: bierze funkcję i opóźnienie, a następnie zwraca obwolutę, którą należy wywołać zamiast oryginalnej funkcji:
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
Zachowaj jeden timer na każdą funkcję z debounce
let timer;
timer znajduje się w zamknięciu, więc każde wywołanie obwoluty widzi tę samą wartość; dzięki temu jedno wywołanie wie, że poprzednie wciąż czeka. Każde wywołanie debounce otrzymuje swój własny, niezależny timer.
Anuluj kolejne wywołanie
clearTimeout(timer);
Każde wywołanie anuluje bieżące wezwanie, jeśli takowe istnieje. Przekazanie wartości undefined do funkcji clearTimeout przy pierwszym uruchomieniu nie ma żadnych konsekwencji.
Zaplanuj nowe wezwanie
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
Nowy timer uruchamia oryginalną funkcję po upływie określonego czasu. Przy wartości 500 ms szybkie wywołania zachowują się w ten sposób:
Call
↓
500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
Funkcja jest wykonywana tylko raz po upływie pełnych 500 ms bez żadnego innego wywołania, przy czym decydują ostatnie argumenty.
Podłączenie tej funkcji do pola wyszukiwania
Zastąp prosty obsługiwanie wydarzeń wersją z opóźnieniem (to zastępuje wcześniejszą deklarację searchProducts, ponieważ obie używają tego samego nazwy):
const searchProducts = debounce((query) => {
console.log("API request:", query);
}, 500);
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});
Gdy użytkownik wpisuje te pięć znanych liter:
r
re
rea
reac
react
Każde naciśnięcie klawisza anuluje poprzedni timer, aż w końcu upłynie określony czas oczekiwania:
react
↓
500ms
↓
API request
Wynikiem jest jedna żądanie API zamiast pięciu.
Dlaczego wrapper przekazuje argumenty
Dwa elementy wydają się opcjonalne, ale sprawiają, że narzędzie jest wielokrotnie użyteczne. Pierwszym z nich jest parametr rest:
return function (...args)
Drugim elementem jest sposób wywoływania oryginalnej funkcji:
fn.apply(this, args);
Obsługa dowolnej liczby argumentów
Uogólniony mechanizm debounce nie powinien zakładać nic na temat sygnatury funkcji, którą otacza. Może on otaczać funkcję wyszukiwania przyjmującą jeden argument:
const search = debounce((query) => {
console.log(query);
}, 500);
lub funkcję przyjmującą dwa wartości:
const calculate = debounce((a, b) => {
console.log(a + b);
}, 500);
Zbieranie wszystkiego za pomocą składni rest i przekazywanie tego dalej zapewnia, że obie funkcje nadal działają:
...args
Zachowanie kontekstu wywołania
Ta sama linia przekazuje również this:
fn.apply(this, args);
Obwoluta to zwykła function, więc otrzymuje wartość this wywołującego, na przykład obiekt, gdy funkcja z opóźnieniem jest przechowywana jako metoda. Funkcja strzałkowa wewnątrz setTimeout nie ma własnej wartości this, więc przejmuje tę z obwoluty, a apply ją przekazuje dalej. Obsługa wyszukiwania tego nie zauważa, ale wspólny narzędzie, które traci kontekst, powoduje mylące błędy w kodzie opartym na obiektach. Należy pamiętać, że funkcja strzałkowa przekazana jako fn zawsze zachowuje swoją leksykalną wartość this.
Gdzie opóźnienie przynosi korzyści
To rozwiązanie nadaje się do każdego zdarzenia, które występuje seriami szybko, gdy istotny jest tylko końcowy stan.
Sugestie wyszukiwania
Prosty sposób polega na wysyłaniu żądania za każdym razem, gdy naciśnięto klawisz:
User types → API request
Wariant z opóźnieniem najpierw czeka na przerwę:
User types
↓
Wait
↓
User stops
↓
API request
Zmiana rozmiaru okna
Przeciąganie krawędzi okna wywołuje ciągle zdarzenia resize, a bezpośredni słuchacz wykonywa kosztowne operacje za każdym razem:
window.addEventListener("resize", () => {
expensiveCalculation();
});
To może oznaczać setki wykonań na jedno przeciąganie. W wersji opakowanej operacje są wykonywane dopiero po ustabilizowaniu się rozmiaru okna:
const handleResize = debounce(() => {
expensiveCalculation();
}, 300);
window.addEventListener("resize", handleResize);
Autoszczegółowe zapisywanie
W edytorze mechanizm debouncing przekształca ciąg zmian w jeden zapis po tym, jak użytkownik przestanie pisać:
User types
User types
User types
User types
...
User stops
↓
Save document
Uwaga: mechanizm debouncing nie zostanie uruchomiony, dopóki użytkownik będzie pisał bez przerwy, a niezapisany zapis zostanie utracony w przypadku zamknięcia strony. Edytory zazwyczaj ustawiają maksymalny czas oczekiwania lub wykonywają zapis przy zmianie visibilitychange.
Debouncing versus throttling
Ty dwa mechanizmy łatwo pomylić. Debouncing czeka, aż aktywność się zakończy, a następnie wykonywa operację raz:
Typing → Typing → Typing → STOP
↓
Execute
Throttling wykonywa funkcję co najwyżej raz na ustalony interwał, niezależnie od liczby przychodzących zdarzeń:
Event → Execute
↓
100ms
↓
Ignore
↓
100ms
↓
Execute
Różnica pomiędzy wykonywaniem na początku a na końcu dodaje niuanse, ale ta klasa rozróżnień obejmuje większość przypadków.
Debounce nie naprawia przestarzałych odpowiedzi
Debounce ogranicza liczbę rozpoczynanych żądań, a nie to, co dzieje się z tymi już w trakcie przesyłania. Załóżmy, że użytkownik czeka na tyle długo, aż zostanie wysłane żądanie, a następnie udoskonali zapytanie:
User searches: react
↓
Request A
User changes search: react native
↓
Request B
Jeśli żądanie B wróci przed żądaniem A, starsza odpowiedź przyjdzie ostatnia i zastąpi nowsze wyniki. Interfejs użytkownika pokaże wtedy wyniki dla:
react
mimo że wprowadzenie teraz mówi:
react native
Rozwiązaniem jest anulowanie przestarzałych żądań za pomocą AbortController, dodanego jako warstwa nad mechanizmem debounce:
Debouncing
↓
Reduce unnecessary requests
↓
AbortController
↓
Cancel outdated requests
Tę kwestię szczegółowo omówiono w artykule jak naprawić warunki wyścigowe, których debounce nie może rozwiązać.
Łączenie obu warstw
Ten schemat zachowuje controller na poziomie modułu. Za każdym razem, gdy uruchamiana jest funkcja z odczekaniem, anuluje ona poprzednią prośbę, tworzy nowy controller i przekazuje jego signal do fetch. Funkcja encodeURIComponent chroni specjalne znaki, a błąd AbortError jest ignorowany, ponieważ oznacza celowe anulowanie, a nie awarię:
const searchInput = document.querySelector("#search");
let controller;
const searchProducts = debounce(async (query) => {
controller?.abort();
controller = new AbortController();
try {
const response = await fetch(
`/api/products?q=${encodeURIComponent(query)}`,
{
signal: controller.signal
}
);
const data = await response.json();
renderResults(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error(error);
}
}
}, 500);
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});
Odczekanie zapobiega wysyłaniu prośby za każdym naciśnięciem klawisza; AbortController zatrzymuje prośby, których wyniki nie są już istotne.
response.ok przed parsowaniem, ponieważ fetch nie odrzuca zapytań przy błędach statusu HTTP.
Szybki model decyzyjny
Gdy jakiś zdarzenie występuje wielokrotnie, zastanów się, czy musisz reagować na każde z nich. Jeśli nie, decyduje drugie pytanie. Jako dane wejściowe potrzebujesz wartości końcowej, więc użyj mechanizmu debouncing:
Input events
↓
Do I need every event?
↓
No
↓
Do I care about the final state?
↓
Yes
↓
Debounce
W przypadku przewijania chcesz regularnych aktualizacji podczas wykonywania gestu, więc użyj mechanizmu throttling:
Scroll events
↓
Do I need every event?
↓
No
↓
Do I need periodic updates while scrolling?
↓
Yes
↓
Throttle
Podsumowanie
Debouncing służy przede wszystkim do kontrolowania częstotliwości wykonywania kosztownych operacji, a mechanizm ten składa się z dwóch linijek kodu:
clearTimeout(timer);
timer = setTimeout(() => {
fn();
}, delay);
Użyj tego mechanizmu, gdy takie zdarzenia występują szybko i seriami:
input
resize
search
auto-save
Kilka punktów do zapamiętania:
- Funkcja z mechanizmem debouncing musi zostać utworzona tylko raz i być ponownie wykorzystywana; tworzenie nowej funkcji dla każdego zdarzenia (lub przy każdym renderowaniu w React bez użycia
useMemolubuseRef) powoduje, że każde wezwanie ma swój własny timer i uniemożliwia skuteczne działanie mechanizmu. - Przekazywanie
...argsorazthissprawia, że pomocnicza funkcja może być bezpiecznie wykorzystywana wielokrotnie.
AbortController decyduje, czy przestarzałe żądanie może zostać dokończone. Użyj obu rozwiązań do wyszukiwania wspieranego przez sieć.Literatura pokrewna
- Timerzy zliczania do konca bez driftu w React: od setTimeout do czystego CSS — Porównanie funkcji setTimeout, requestAnimationFrame oraz techniki opartej wyłącznie na CSS bez użycia JavaScriptu do tworzenia timerów w React, w tym triku pojedynczego opóźnienia zapewniającego synchronizację cyfr.
- Naprawa sytuacji wyścigowych – debouncing nie może rozwiązać problemów w interfejsach poszukiwania — Dowiedz się, dlaczego sam debouncing nie może zapobiec nadpisywaniu aktualnego stanu interfejsu przez przestarzałe odpowiedzi API, oraz zapoznaj się z czterema praktycznymi rozwiązaniami umożliwiającymi kontrolowanie kolejności zapytań.