• Post last modified:06/04/2020
  • Post Category:React
  • Post Comments:0 Komentarzy

Ostatnia część krótkiej serii (1) (2) wpisów dotyczących poprawy wydajności aplikacji tworzonych przy użyciu Reacta. Tym razem skupimy się na wydajnym obsłużeniu eventów klikania, wpisywania, scrollowania, rozszerzania, przesuwania, itp.
Debounce / throttle / windowing – te trzy tematy czekają dzisiaj na omówienie.

Debounce

Zacznę od wyjaśnienia czym w ogóle jest debounce. Otóż jest to technika, która pozwala nam na „zgrupowanie” wielu występujących jeden po drugim wywołań tej samej funkcji w jedno wywołanie. Przykładem może być tutaj event onChange na elemencie typu input. Zamiast wywoływać go za każdym razem gdy użytkownik wpisze znak, wywołajmy ten event w momencie gdy użytkownik wpisze cały tekst. Kolejnym przykładem może być obsłużenie eventu onScroll. Pojedyncze przeciągnięcie palcem po kółku myszki może spowodować wywołanie funkcji przypisanej do tego eventu nawet 20 razy. Dużo lepiej byłoby wykonać tą funkcję raz – w momencie gdy użytkownik skończy scrollować. Analogicznie ma się sprawa w przypadku zmiany szerokości okna przeglądarki, chociaż nie jest to zbyt częsty przypadek.

Świetnym zobrazowaniem tego w jaki sposób działa debounce w praktyce jest codepen stworzony przez Davida Corbacho. Zaznaczony na niebiesko obszar reaguje na eventy ‚click’ oraz ‚mouse-move’. Pokazuje nam jak często wykonałaby się funkcja związana z tymi eventami w wersji bez- oraz z użyciem debounce:

Jak używać debounce

Najefektywniejszym sposobem na użycie debounce jest skorzystanie z gotowej implementacji tej funkcjonalności w bibliotekach lodash bądź underscore. W obydwu przypadkach możemy zdefiniować czas jaki musi upłynąć od ostatniego eventu, aby wywołać daną funkcję. Do tego czasu nasłuchujemy na eventy, ale ich nie obsługujemy.

W powyższym przypadku nasz handler zostanie wywołany dopiero gdy użytkownik przestanie generować nowe eventy. Może to być na przykład skończenie scrollowania. Przy pomocy odpowiedniej flagi (leading w lodash immediate w underscore) naszą funkcję możemy również wywołać podczas pierwszego wystąpienia określonego eventu i wstrzymywać się odpowiednio długo z kolejnymi wywołaniami.

Debounce z React

Pierwsza rzecz o której należy pamiętać podczas używania debounce z Reactem jest fakt, iż React korzysta z tzw. SyntheticEvent, czyli wrappera na natywne eventy, dzięki czemu jesteśmy pewni iż eventy będą obsługiwane w taki sam sposób na wszystkich przeglądarkach. Ze względów wydajnościowych SyntheticEvent jest czyszczony (‚nullified’) za każdym razem gdy tylko zostanie wywołana funkcja obsługująca dany event. Tak więc nie jesteśmy w stanie odwołać się do eventu w sposób asynchroniczny:

// z dokumentacji Reacta - https://reactjs.org/docs/events.html

function onClick(event) {
  console.log(event); // => nullified object.
  console.log(event.type); // => "click"
  const eventType = event.type; // => "click" (przypisanie eventu do zmiennej w celu odwołania się do niego w kodzie asynchronicznym)

  setTimeout(function() {
    console.log(event.type); // => null () // Poniewaź SyntheticEvent został wyczyszczony po wykonaniu funkcji onClick
    console.log(eventType); // => "click" // odwołanie do zmiennej - ta jest ciągle dostępna asynchronicznie
  }, 0);

  // Won't work. this.state.clickEvent will only contain null values.
  this.setState({clickEvent: event});

  // You can still export event properties.
  this.setState({eventType: event.type});
}

Debounce jest wywoływany asynchronicznie, więc napisanie handlera w taki sposób jak poniżej nie zadziała prawidłowo:

// w momencie wywołania 'setState' wartość 'e' będzie będzie wynosiła 'null'
handleChange = debounce((e) => {
  this.setState({ text: e.target.value })
}, 500);

<input onChange={handleChange} />

Jak zrobic to prawidłowo? Pokażę to w przykładzie.

Kolejna rzecz którą warto mieć na uwadze, to odmontowywanie komponentu – debounce jak już wspomniałem jest wywoływany asynchronicznie, więc pamiętajmy o tym, aby w przypadku odmontowywania komponentu zatrzymać wszelkie uruchomione funkcje debounce – możemy to zrobić korzystając z wbudowanej w lodash/inderscore metody .cancel():

class MyComponent extends Component {
  // ...
  handleChange = debounce((text) => {
    this.setState({ text });
  }, 500);
  componentWillUmount() {
    this.handleChange.cancel(); // Prawidłowe zatrzymanie metody handleChange
  }
  render() {
    // ....
  }
}

// przy użyciu Hooks i  useEffect, metodę .cancel() uruchamiamy
// w zwracanej funkcji - to ona odpowiada za "czyszczenie" po odmontowanym komponencie

Przykład 1 – debounce

W poniższym przykładzie możemy zobaczyć różnice w działaniu aplikacji z oraz bez użycia debounce. W przypadku aplikacji bez debounce klikanie w przycisk powoduje ciągłe montowanie/odmontowywanie komponentu zawierającego inputa do wpisywania hasła. Dodatkowo podczas wpisywania hasła, jego walidacja odbywa się po każdym wpisanym znaku. Powoduje to nadmiarowe wykonywanie renderów komponentu zawierającego input.

Dla porównania ta sama aplikacja została „wyposażona” w debounce w dwóch miejscach. Na przycisku pokazującym/chowającym komponent z polem do wpisania hasła. Teraz komponent wyrenderuje się dopiero wtedy gdy użytkownik przestanie klikać w przycisk. Na samym inpucie z hasłem walidujemy jego długość dopiero gdy użytkownik skończy pisać tekst. Bardzo mocno redukuje to nam również liczbę renderów komponentu.

Cała aplikacja znajduje się w pliku index.js. Pokazałem tam dwa sposoby na poradzenie sobie z opisywaną wcześniej obsługą eventów w React. Pierwszy zakłada przekazywanie do handlera wartości eventu (event.target.value) zamiast całego eventu (linie 50-56 oraz 80, w tej chwili zakomentowane). Drugie podejście wykorzystuje metodę e.persist(), która to „wyłącza” domyślne nullowanie eventu dla SyntheticEvents (linie 61-72 oraz 81 – z tej konfiguracji korzysta teraz przykład).

Dodatkowo w liniach 51 oraz 63 widzimy jak prawidłowo wygasić uruchomiony już debounce na odmontowanym elemencie.

Throttle

Throttle jest mechanizmem bardzo podobnym do debounce. W tym przypadku również zabezpieczamy się przed wielokrotnym wywołaniem tej samej funkcji w bardzo krótkim odstępie czasu. Dla przypomnienia – w przypadku debounce wywoływaliśmy naszą funkcję albo podczas pierwszego wystąpienia eventu i blokowaliśmy jej kolejne wywołania przez określony czas (flaga leading), albo od razu blokowaliśmy jej wywołania do momentu upłynięcia odpowiedniego czasu od ostatniego eventu (brak flagi leading). W takim podejściu mogliśmy wykonywać jakąś akcję (np. clickanie, scrollowanie) bardzo szybko przez długi czas i wywołać obsługę tej akcji tylko raz.

W przypadku throttle obsługa eventu jest nieco inna – tutaj nie pozwalamy na wykonanie funkcji częściej niż określona liczba milisekund. Jesteśmy więc pewni, iż niezależnie od tego jak szybko ktoś wykonuje akcje, nasz handler będzie wykonywał się regularnie. Co najmniej raz na każde „X” milisekud.

Implementację throttle, podobnie jak debounce, znajdziemy zarówno w underscore jak i lodash. Najpopularniejszym przypadkiem użycia throttle jest implementacja tzw. „Infinite scrolling”, czyli doładowywanie i dołączanie kolejnych elementów do DOM w momencie gdy użytkownik scrolluje stronę i zbliża się do jej końca.

W celu zobrazowania tego efektu ponownie posłużę się przykładem przygotowanym przez Davida Corbacho:

Dlaczego w takim przypadku debounce nie byłby dobrym rozwiązaniem? Otóż używając throttle sprawdzamy co kilkaset milisekund (300ms w powyższym przykładzie) pozycję scrolla na stronie. Jeżeli jest wystarczająco blisko końca dołączamy kolejne elementy. Nie interesuje nas za bardzo jak szybko i często ktoś scrolluje. W przypadku debounce moglibyśmy mieć sytuację w której szybko scrollujący użytkowik mógłby dojechać do końca dokumentu, wtedy dopiero przestać scrollować. W tym momencie odpaliła by się nasza funkcja odpowiedzialna za zbadanie pozycji scrolla i dołączanie kolejnych elementów do DOM. Wyglądałoby to bardzo źle dla przeglądającego.

Oczywiście scroll nie jest jedynym miejscem gdzie możemy użyć throttle – innym przykładami może być click. Możemy w ten sposób obronić się przed bardzo agresywnym i niecierpliwymi użytkownikami albo zapytania API, gdzie z kolei możemy ograniczyć liczbę zapytań do serwera.

Przykład 2 – throttle vs debounce

W drugim przykładzie w bardzo prosty sposób postarałem się zobrazować jaka jest różnica w obsłudze eventu „mouse move” w przypadku użycia debounce, throttle jak i bez żadnego z wymienionych mechanizmów.

Windowing

Windowing jest podejściem, które bardzo mocno pomaga nam w przypadku renderowania bardzo dużych (długich) list. Umieszczenie w DOM listy kilku tysięcy elementów np. li spowoduje zajęcie dużej ilości pamięci przez przeglądarkę co może przełożyć się na „skaczące” przewijanie a także powolne re-renderowanie całej listy.

Jednym z rozwiązań tego problemu jest umieszczanie w DOM tylko tych elementów, które są aktualnie widoczne na ekranie użytkownika. Czyli w przypadku naszej listy obserwowanie pozycji scrolla i montowanie w DOM tego co powinno być widoczne i odmontowywanie elementów których użytkownik i tak nie jest w stanie zobaczyć.

Tematem tym zajął się jakiś czas temu Brian Vaughn i przygotował dla nas dwie biblioteki:

Obydwa rozwiązania sprawdzają się bardzo dobrze, jednak cytując Briana:

„If react-window provides the functionality your project needs, I would strongly recommend using it instead of react-virtualized.”

To w jaki sposób działa windowing możemy podejrzeć na demo stronie stworzonej dla react-window.

W powyższym przykładzie renderujemy elementy listy, które mamy już dostępne w pamięci. W przypadku rzeczywistego zastosowania zapewne kolejne elementy listy będziemy pobierać w trakcie scrollowania jej przez użytkownika. Hmm… czy znamy jakiś sposób na to, aby wykonywać zapytania do API w momencie gdy użytkownik będzie scrollował listę i zbliżał się do jej końca? 🙂

Produkcyjne wykorzystanie tego mechanizmu możemy również zaobserwować na Facebooku podczas przeglądania naszego głównego „walla”. Tak na marginesie Brian Vaughn jest członkiem głównego zespołu rozwijającego Reacta. Wykorzystanie tego mechanizmu na stronach Facebooka nie powinno nikogo zaskakiwać 🙂

Podsumowanie

To już ostatni wpis który chciałem umieścić na temat poprawy wydajności aplikacji pisanych w React. Mam nadzieję, że te cztery artykuły pomogą wam w skutecznym „przyśpieszeniu” waszych obecnych aplikacji. Debounce, throttle i windowing to na elementy nad którymi zawsze trzeba dokładniej się pochylić.

Kamil Józwik

Front end developer👨‍💻 Autor małego narzędzia dla programistów - frontbook.dev oraz autor kolejnych postów na tym blogu. Ulubiony stack to React wraz z TypeScript oraz wszystko co "nowe" w tym pięknym dynamicznym front end-owym świecie 🙂

Dodaj komentarz