Pole wyszukiwania, które wysyła zapytanie przy każdym naciśnięciu klawisza, wysyła pięć zapytań dla “redis”, a cztery z nich są już nieaktualne, zanim wróci odpowiedź. Handler scroll, który przelicza layout, odpala się setki razy na sekundę i powoduje szarpanie strony. W obu przypadkach ten sam problem: zdarzenie DOM odpala się dużo częściej, niż handler musi się wykonać. Debounce i throttle rozwiązują to na dwa różne sposoby.

Co robi debounce, a co robi throttle

Debounce czeka, aż zdarzenie przestanie się odpalać na ustalony czas, i wtedy wykonuje się raz. Każde nowe wywołanie resetuje zegar. Jeśli zdarzenie nigdy się nie zatrzymuje, funkcja nigdy się nie wykonuje. Pole wyszukiwania to typowy przypadek: czekaj, aż użytkownik zrobi przerwę w pisaniu, a potem wyślij zapytanie z tym, co wpisał na końcu.

Throttle ogranicza, jak często wywołanie może się wykonać. Podczas serii zdarzeń wciąż się wykonuje, co najwyżej raz na interwał. Użyj go dla scroll, gdzie strona ma dalej się aktualizować, kiedy użytkownik scrolluje, tylko nie przy każdym pojedynczym zdarzeniu.

DebounceThrottle
Wykonuje się podczas seriiNie, dopóki się nie skończyTak, co najwyżej raz na interwał
Co dostajeszOstatnie wywołanie z seriiRównomierną próbkę przez całą serię
Typowe użyciePole wyszukiwania, walidacja formularza, zapis przy pisaniuscroll, resize, mousemove, drag
Jeśli seria nigdy się nie kończyNigdy się nie wykonujeDziała dalej według harmonogramu

Obie to wrappery. Przekazujesz swój callback i opóźnienie, a w zamian dostajesz funkcję z tą samą sygnaturą, którą podłączasz do zdarzenia zamiast oryginału.

Jak napisać funkcję debounce

1
2
3
4
5
6
7
function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

Każde wywołanie anuluje czekający setTimeout i planuje nowy, więc tylko ostatnie wywołanie z serii żyje dość długo, by się odpalić. Timing podlega regułom setTimeout opisanym w jak event loop w JavaScript planuje callbacki: callback nigdy nie wykonuje się przed delay i może wykonać się później, jeśli call stack jest zajęty, gdy timer wygasa.

Podłączone do pola wyszukiwania:

1
2
3
4
5
6
7
const input = document.querySelector("#search");
const search = debounce((value) => {
  console.log("searching for:", value);
  fetch(`/api/search?q=${encodeURIComponent(value)}`);
}, 300);

input.addEventListener("input", (e) => search(e.target.value));

Wpisz “redis” w normalnym tempie, a konsola pokaże jedną linię, searching for: redis, jakieś 300ms po ostatnim naciśnięciu klawisza. Nie pięć linii dla pięciu liter.

Jak napisać funkcję throttle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

To wersja leading-edge. Pierwsze wywołanie w oknie wykonuje się od razu; wszystko po nim jest odrzucane, aż minie interval milisekund.

1
2
3
4
5
const onScroll = throttle(() => {
  console.log("scrollY:", window.scrollY);
}, 200);

window.addEventListener("scroll", onScroll);

Scrolluj bez przerwy przez dwie sekundy i dostajesz jakieś dziesięć równomiernie rozłożonych linii logu, a nie setki zdarzeń scroll, które przeglądarka faktycznie odpala.

Opcje debounce i throttle w lodash: leading, trailing, maxWait

Każda z funkcji napisanych ręcznie wyżej zakłada jedno konkretne zachowanie. Debounce odpala się tylko na trailing edge, po okresie ciszy. Throttle odpala się tylko na leading edge, w momencie, gdy okno się otwiera, więc ostatnie wywołanie z serii gubi się, jeśli wypadnie w środku okna. Dla scroll to ma znaczenie: ostatnia pozycja, w której użytkownik się zatrzymał, może nigdy nie zostać obsłużona.

_.debounce(fn, wait, options) i _.throttle(fn, wait, options) z lodash pozwalają skonfigurować obie krawędzie przez booleany leading i trailing, a wartości domyślne się różnią. Debounce ma domyślnie leading: false, trailing: true: czekaj, potem odpal. Throttle ma domyślnie obie true: odpal natychmiast, potem jeszcze raz na końcu serii, która trwała dłużej niż jeden interwał. Wewnętrznie throttle w lodash to jego własny debounce z maxWait ustawionym na interwał, więc te dwa są bliżej siebie, niż sugerują nazwy.

maxWait to opcja debounce, która ogranicza, jak długo można odkładać pierwsze wywołanie. Bez niej ktoś, kto pisze równomiernie z przerwami krótszymi niż opóźnienie, nigdy nie uruchomi handlera, nawet po dziesięciu sekundach aktywności. Z maxWait: 2000 odpala się co najmniej co dwie sekundy.

Debounce tylko-leading obejmuje przypadek podwójnego wysłania. _.debounce(sendMail, 300, { leading: true, trailing: false }) odpala się przy pierwszym kliknięciu i ignoruje kolejne, aż minie 300ms bez żadnego kliknięcia. Pierwsze kliknięcie się liczy; pozostałe są odrzucane, nie odkładane.

Które zdarzenia wymagają debounce, a które throttle

ZdarzenieWybórDlaczego
Wyszukiwanie podczas pisaniaDebounceLiczy się tylko ostatnie zapytanie; wcześniejsze naciśnięcia generują zapytania, które i tak byś odrzucił
resize oknaDebounce lub throttleDebounce, jeśli liczy się tylko końcowy rozmiar (przelicz layout raz); throttle, jeśli UI ma widocznie śledzić zmianę rozmiaru
scrollThrottleStrona ma reagować przez cały czas scrollowania, nie tylko po tym, jak użytkownik się zatrzyma
mousemove (drag, tooltip śledzący kursor)Throttle, przez requestAnimationFrameAktualizacje szybsze niż częstotliwość odświeżania ekranu to zmarnowana praca
Kliknięcie przycisku (unikanie podwójnego wysłania)Debounce tylko-leading lub wyłączenie po kliknięciuPierwsze kliknięcie musi się liczyć; pozostałe trzeba ignorować, nie odkładać
Autozapis przy każdym naciśnięciu klawiszaDebounce z maxWaitZapisuj po przerwie, ale gwarantuj zapis podczas długiego, ciągłego pisania

Kiedy handler odpowiada za coś wizualnego (drag, tooltip śledzący kursor, animację powiązaną ze scrollem), zamień Date.now na requestAnimationFrame:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function rafThrottle(fn) {
  let scheduled = false;
  return function (...args) {
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      fn.apply(this, args);
      scheduled = false;
    });
  };
}

Częstotliwość wywołań teraz podąża za tempem rysowania przeglądarki, a nie za jakąś dowolnie wybraną liczbą milisekund. Aktualizacja wykonuje się raz na ramkę, chwilę przed jej narysowaniem, więc nie może działać częściej, niż ktokolwiek zdołałby to zobaczyć, i nie rozjeżdża się z renderowaniem, gdy główny wątek jest obciążony.

Częste błędy z debounce i throttle

Tworzenie funkcji debounced wewnątrz handlera. W React onChange={(e) => debounce(handleSearch, 300)(e.target.value)} wywołuje debounce() przy każdym naciśnięciu klawisza, a każde wywołanie zwraca nowy wrapper z własnym, pustym timerem. Nigdy nie ma wcześniejszego timera do anulowania, więc każde naciśnięcie klawisza i tak wysyła zapytanie, tylko z 300ms opóźnieniem. Twórz funkcję debounced jeden raz (useMemo, useRef, albo zakres modułu poza komponentem) i zawsze wywołuj tę samą instancję.

Brak anulowania przy odmontowaniu. Autozapis debounced, który odpala się po tym, jak użytkownik przeszedł gdzie indziej, zapisuje do komponentu, który już nie istnieje, albo zapisuje z przestarzałej closure. Funkcje debounced i throttled z lodash mają już metodę .cancel(); do ręcznie napisanego debounce dodaj taką, która wywołuje clearTimeout(timer). Wywołuj ją, gdy komponent się odmontowuje albo strona się zamyka.

Testowanie na prawdziwych timerach. Czekanie na debounce przez await new Promise(r => setTimeout(r, 300)) działa, ale dodaj to do każdego testu związanego z czasem i zestaw testów robi się tak wolny, że ludzie przestają go odpalać lokalnie. Używaj fałszywych timerów: vi.useFakeTimers() w Vitest albo jest.useFakeTimers() w Jest, a potem przesuwaj zegar przez vi.advanceTimersByTime(300). Fałszywy timer to test double dla zegara, nie dla twojego kodu — to rozróżnienie opisane w testach jednostkowych vs integracyjnych vs end-to-end.

Stosowanie debounce do czegoś, co ma wyglądać na żywo. Pasek postępu albo uchwyt do przeciągania, który rusza się tylko wtedy, gdy użytkownik przestaje, wygląda na zepsuty. Jeśli wymaganie to “aktualizuj płynnie, dopóki to trwa”, potrzebujesz throttle.

Debounce czy throttle domyślnie?

Używaj debounce, kiedy liczy się tylko stan końcowy: pola wyszukiwania, autozapis, walidacja pola po tym, jak użytkownik przestanie pisać. Używaj throttle, kiedy użytkownik oczekuje, że zobaczy postęp, podczas gdy coś trwa, jak scroll, resize czy drag, i przechodź na wersję z requestAnimationFrame, gdy wynikiem jest aktualizacja wizualna.

W kodzie produkcyjnym importuj debounce i throttle z utrzymywanej biblioteki (lodash, albo hooka jak use-debounce w React), zamiast wysyłać wersje z góry. Powstały po to, by pokazać mechanizm. Wersje z biblioteki już obsługują anulowanie, maxWait, wywołania trailing i wiązanie this, czyli właśnie to, gdzie ręcznie napisana kopia najczęściej się łamie.

Powiązane artykuły