Debounce

Cover Image for Debounce
Krzysztof
Krzysztof

Dzisiaj pora na temat optymalizacyjny. Debounce to technika, obok, której nie można przejść obojętnie. W większości projektów istnieje niejedno miejsce, w którym można ją zastosować

Możesz zapytać, po co mi to?

Odbijemy piłeczkę, odpowiadając pytaniami, na pytanie!

Czy zdarzyło Ci się kiedyś:

  • Zawiesić aplikację scrollując w dół?
  • Nadmiernie obciążyć API podczas wpisywania liter do pola tekstowego?
  • Mieć obawy, że przeglądarka zaraz wybuchnie podczas zmiany rozmiaru okna?
  • Wywoływać funkcję nadmiarowo, mimo że sytuacja tego nie wymagała?

Jeśli na którekolwiek z powyższych pytań odpowiedź brzmi: TAK, to ten artykuł jest dla Ciebie.

Czym jest debounce

W krótkich słowach jest to technika zapobiegająca nadmiarowemu wywołaniu danej funkcji. Jej magia polega na tym, że tworzy timer, który jest ustawiany na wartość początkową za każdym razem, gdy nastąpi nowe wywołanie „debouncowanej” funkcji. Dopiero gdy timer dojdzie do zera, debounce uruchamia jedynie ostatnie wywołanie funkcji, a pozostałe ignoruje.

Żeby lepiej zrozumieć idee, spójrz, co krok po kroku robi debounce:

  1. Deklaruje zmienną, w której przechowywana będzie referencja do timera
  2. Zwraca nową funkcję, która na starcie tworzy timer lub ustawia jego wartość początkową (resetuje go), jeśli już istnieje
  3. Do zmiennej stworzonej w pierwszym kroku przypisuje timer z kroku drugiego (wyzerowany)
  4. Jeśli czas na timerze dobiegnie końca, to wywołuje callback i kończymy zabawę. Natomiast, jeżeli przed upływem czasu coś ponownie wywoła debouncowaną funkcję, to timer zresetuje się i krok czwarty może w ten sposób trwać w nieskończoność

Zbyt skomplikowane?

Spokojnie, przeanalizujmy prosty przykład Reactowego komponentu kontrolowanego, żeby lepiej Ci to zwizualizować:

const ControlledInput = () => { const [inputValue, setInputValue] = useState(''); const [suggestions, setSuggestions] = useState([]); const handleInputChange = async (event) => { setInputValue(event.target.value); // Imagine very heavy request const response = await fetch('http://localhost:3000/suggestions'); const data = await response.json(); const filteredSuggestions = data.filter(({ content }) => content.includes(event.target.value)); setSuggestions(filteredSuggestions); }; return ( <> <h2>ControlledInput</h2> <input onChange={handleInputChange} value={inputValue} /> <br /> {inputValue.length !== 0 && suggestions.length === 0 && <>No Matches</>} {inputValue.length !== 0 && suggestions.map(({ id, content }) => <span key={id}>{content}</span>)} </> ); }; export default ControlledInput;

Za każdym razem, gdy użytkownik wpisze cokolwiek do inputa nasza funkcja handleInputChange wywoła się, a co za tym idzie, wykona request HTTP do API.

Każde takie wywołanie będzie musiało zostać przeprocesowane przez backend. Co więcej, jeżeli pod spodem będą wykonywane jakieś dłuższe operacje to problemy wydajnościowe gwarantowane.

Tutaj na ratunek przychodzi nam debounce. Możemy wykorzystać gotową implementację z biblioteki takiej jak lodash lub napisać własną

Niezależnie, którą opcję wybierzemy, cel jest taki sam: ograniczyć ilość wywołań naszego handleInputChange

Przykładowa implementacja

Spójrz, jak może wyglądać przykładowa implementacja funkcji debounce:

function debounce(callback, delay = 300) { let timeout return (...args) => { clearTimeout(timeout) timeout = setTimeout(() => { callback(...args) }, delay) } }

Najpierw deklarujemy nową funkcję z dwoma argumentami. Pierwszy to callback, czyli kawałek kodu, którego wywołania będziemy ograniczać, a drugi to czas działania całego debounca wyrażony w milisekundach (warto wrzucić tutaj jakąś wartość domyślną np. 300).

W kolejnym kroku tworzymy zmienną, do której przypiszemy nasz timer odliczający czas, a następnie zwracamy arrow function wewnątrz, której dzieje się cała „magia”.

Zwracana funkcja na samym starcie sprawdzi, czy istnieje jakiś timer przypisany do zmiennej w otaczającym ją zakresie. Jeśli tak to go zresetuje (jest to możliwe dzięki mechanizmowi closure). Jeśli nie to go utworzy i ustawi mu wartość do odliczania przekazaną, jako drugi argument. Jeśli timer odliczy do końca, to nastąpi wywołanie callbacka.

Cały myk polega jednak na tym, że za każdym razem, gdy użytkownik dopisze nowy znak do inputa, to debounce sprawi, że timer się zresetuje i tym samym zapobiegnie uruchomieniu handleInputChange. Jedyna szansa na zakończenie tego cyklu to poczekanie aż upłynie cały czas na timerze

Warto znać, jakie mechanizmy stoją za tą techniką, ale zdecydowanie nie jesteśmy zwolennikami "wynajdywania koła na nowo", więc polecamy Ci wykorzystać gotowego debounca z biblioteki lodash, o której już wcześniej wspominaliśmy. Z ciekawości możesz rzucić okiem jak wygląda zaawansowana implementacja w samej bibliotece pod tym linkiem

Teorię już znasz, pora na praktykę. Wykorzystajmy debounca na przykładzie handleInputChange

const handleInputChange = debounce(async (event) => { setInputValue(event.target.value); // Imagine very heavy request const response = await fetch('http://localhost:3000/suggestions'); const data = await response.json(); const filteredSuggestions = data.filter(({ content }) => content.includes(event.target.value)); setSuggestions(filteredSuggestions); }, 400);

Efekt będzie taki, że call do api wykona się jedynie wtedy, gdy użytkownik skończy pisać i przez 400 milisekund nie zmieni wartości inputa

W wyniku tego nasza aplikacja nie będzie zasypywać API nadmiarowymi requestami i ogólna wydajność wzrośnie.

Jest tutaj jednak bardzo duży kłopot. Zauważ, że handleInputChange oprócz wysyłania request’a obsługuje także stan (inputValue). W wyniku ograniczenia wywołań całej funkcji nasz stan inputa nie będzie działał (możesz to zobaczyć, uruchamiając repozytorium)

Intuicja może podpowiadać, żeby rozdzielić handler na dwie osobne funkcje, ale nie jest to takie proste…

Spójrz na to rozwiązanie:

const fetchSuggestions = debounce(async () => { // Imagine very heavy request const response = await fetch('http://localhost:3000/suggestions'); const data = await response.json(); const filteredSuggestions = data.filter(({ content }) => content.includes(inputValue)); setSuggestions(filteredSuggestions); }, 400); const handleInputChange = async (event) => { setInputValue(event.target.value); await fetchSuggestions(); };

Pozornie wygląda w porządku, ale niestety tutaj mamy inny problem. Wpisywanie wartości do inputa naprawiliśmy, ale za to funkcjonowanie debounce całkowicie się popsuło. Winowajcą jest oczywiście mechanizm rerenderowania w React. Każda aktualizacja stanu (w tym przypadku każda zmiana wartości inputa) wyrenderuje na nowo komponent i tym samym stworzy nową funkcję debounce z nowym timerem, callbackiem itp.

Jak to obejść?

Jeżeli nie używasz stanu ani propsów to jesteś w stanie „wyrzucić” te funkcje poza komponent, ale będzie to raczej rzadka możliwość.

Logiczną próbą może być także wykorzystanie hooków useMemo i useCallback (temat na osobny artykuł), ale to również nie zadziała, gdy wewnątrz nich zechcemy użyć aktualnego stanu. Wtedy wrócimy do punktu wyjścia, bo wraz z nowym stanem funkcje będą tworzyć się od nowa…

Na ratunek przychodzi nam useRef()

Sam hook useRef to również temat na osobny artykuł, ale w paru słowach można opisać, że ustawienie nowej wartości w hooku useRef nie powoduje, ponownego renderu komponentu co w naszej sytuacji jest bardzo przydatne.

Zobacz, jak wygląda działający przykład:

const ComponentWithOwnDebounce = () => { const [inputValue, setInputValue] = useState(''); const [suggestions, setSuggestions] = useState([]); const fetchSuggestions = async () => { // Imagine very heavy request const response = await fetch('http://localhost:3000/suggestions'); const data = await response.json(); const filteredSuggestions = data.filter(({ content }) => content.includes(inputValue)); setSuggestions(filteredSuggestions); }; const ref = useRef(fetchSuggestions); useEffect(() => { ref.current = fetchSuggestions; }, [inputValue]); const debouncedCallback = useMemo(() => { const latestFetchSuggestionsFunction = () => { ref.current?.(); }; return debounce(latestFetchSuggestionsFunction, 400); }, []); const handleInputChange = async (event) => { setInputValue(event.target.value); debouncedCallback(); }; return ( <> <h2>Own Debounce Example</h2> <input onChange={handleInputChange} /> <br /> {inputValue.length !== 0 && suggestions.length === 0 && <>No Matches</>} {inputValue.length !== 0 && suggestions.map(({ id, content }) => <span key={id}>{content}</span>)} </> ); }; export default ComponentWithOwnDebounce;

Możesz zapytać: Co tu się wydarzyło?!

  • Do ref.current przypisujemy nie zdebouncowaną ciągle tworzoną na nowo przy każdym renderze funkcję fetchSuggestions
  • Aktualizujemy ją po każdej zmianie wartości inputa (w useEffect)
  • Za pomocą useMemo tworzymy zdebouncowaną funkcję tylko 1 raz
  • Przekazujemy do niej funkcję, która ma dostęp do ref.current (w niej będzie przechowywana referencja do funkcji fetchSuggestions z najnowszym stanem)

Wszystko jest możliwe dzięki mechanizmowi closure w JavaScript. Całość już działa, ale musisz przyznać, że ilość kodu jest za duża i nie wygląda to dobrze. Stwórzmy osobnego hooka, tak aby łatwiej było go w przyszłości reużyć:

export const useDebounce = (callback, timeout = 300) => { const ref = useRef(callback); useEffect(() => { ref.current = callback; }, [callback]); return useMemo(() => { const latestCallbackFunction = () => { ref.current?.(); }; return debounce(latestCallbackFunction, timeout); }, []); };

Wygląda o wiele zgrabniej, użycie w komponencie też jest całkiem przystępne:

const ComponentWithHookDebounce = () => { const [inputValue, setInputValue] = useState(''); const [suggestions, setSuggestions] = useState([]); const fetchSuggestions = async () => { // Imagine very heavy request const response = await fetch('http://localhost:3000/suggestions'); const data = await response.json(); const filteredSuggestions = data.filter(({ content }) => content.includes(inputValue)); setSuggestions(filteredSuggestions); }; const debouncedCallback = useDebounce(async () => { await fetchSuggestions(); }, 400); const handleInputChange = (event) => { setInputValue(event.target.value); debouncedCallback(); }; return ( <> <h2>Hook Debounce Example</h2> <input onChange={handleInputChange} /> <br /> {inputValue.length !== 0 && suggestions.length === 0 && <>No Matches</>} {inputValue.length !== 0 && suggestions.map(({ id, content }) => <span key={id}>{content}</span>)} </> ); }; export default ComponentWithHookDebounce;

Podsumowanie

Z pewnością warto mieć na uwadze, jakie możliwości oferuje nam debounce. To czy zrobisz z tego użytek, w swoim projekcie zależy tylko od Ciebie. Mamy nadzieję, że tym artykułem przekonaliśmy Cię do dodania debounce’a do swojego arsenału programisty.

Dodatkowo łap link do repozytorium z całym kodem, który możesz sobie przeklikać, żeby lepiej zrozumieć temat