Przejdź do treści

Czy wiesz, jak działają zdarzenia w React?

Podczas realizacji pewnego ficzera w aplikacji, gdzie UI zbudowany był z komponentów Reactowych, natknąłem się na problem z eventami. Jeśli też miałeś / miałaś kłopot ze zdarzeniami w Reactowej aplikacji, to zapraszam do lektury.

Baner promujący artykuł

Story time

Zadanie polegało na dodanie debounce 1 do inputa, który był użyty w komponencie wyszukiwarki. A wszystko po to, aby naciśnięcie i trzymanie wciśniętego przycisku ENTER na klawiaturze nie powodowało ciągłego wysłania zapytań HTTP do serwera.

  const keyDownHandler = (evt) => {
    console.log(evt.key); // Display pressed key name
    // ...
  };

  return (
    // ...
    <input
        onKeyDown={keyDownHandler}
    />
    // ...
  )

Wpadłem więc na pomysł, aby handler — który uruchamia się, kiedy zdarzenie keydown zostanie wyzwolone — owrapować za pomocą debounce.

  const keyDownHandler = debounce((evt) => {
    console.log(evt.key); // Ups...
    // ...
  }, 1000);

  // ...

Wówczas pozyskanie informacji z obiektu evt stało się niemożliwe. Dlaczego?

Projekt na StackBlitz gotowy do edycji:
https://stackblitz.com/edit/problem-with-react-events

Kilka słów o SyntheticEvent

W pliku package.json projektu, o którym mowa jest zależność:

    "react-dom: ^16.0.0"

A to oznacza, że trzeba powiedzieć kilka słów o starej (w chwili publikacji aktualna wersja to v17.0.2) naturze wrappera SyntheticEvent.

Oficjalna dokumentacja znajduje się na stronie reactjs.org 2

Obiekt SyntheticEvent wrapuje wszystkie natywne eventy, rozwiązując tym samym problem cross platformowości.

Problem cross platformowości polega na tym, że pod różnymi przeglądarkami API może wyglądać inaczej, przez co trzeba pisać wyjątki sprawdzające czy dana funkcja jest dostępna, czy też nie.

Uwaga

Nie wolno używać API na podstawie nazwy przeglądarki.
Taka technika nie jest mile widziana. Należy sprawdzić, czy API jest dostępne w obecnym środowisku, a następnie, gdy jest, użyć go.

Tym samym rejestrowanie zdarzeń (w trybie bubbling) w komponentach Reactowych jest niezwykle proste. Wystarczy skorzystać ze starej nazwy atrybutu HTML, ale w zapisie camelCase. Przykład:

function Example() {
  return (
    <input
      onFocus={(e) => {
        console.log('Focused on input');
      }}
    />
  )
}

Ciekawostka

Jeśli chcemy zarejestrować event w trybie capture, wystarczy zmienić nazwę handlera onFocus na onFocusCapture.

Idąc dalej, w v16 istniała strategia stworzenia puli eventów 3. Została ona wyeliminowana w wersji v17… i bardzo dobrze!

Utworzenie takiej puli wprowadziło mnie w kłopoty.

Otóż kiedy dodałem debounce i przekazałem obecny handler jako callback, to utraciłem dostęp do obiektu zdarzenia evt.

W dokumentacji jest nawet przykład ze zwykłym setTimeout, na którym jest zaprezentowany niedziałający kod:

// @see https://reactjs.org/docs/legacy-event-pooling.html
function handleChange(e) {
  // This won't work because the event object gets reused.
  setTimeout(() => {
    console.log(e.target.value); // Too late!
  }, 100);
}

Jedyny rozwiązaniem, aby w callbacku setTimeout (lub też debounce) obiekt zdarzenia był dostępny, jest wykorzystanie metody persist(), która tworzy funkcję isPersistent zwracającą true.

  /**
   * We release all dispatched `SyntheticEvent`s after each event loop, adding
   * them back into the pool. This allows a way to hold onto a reference that
   * won't be added back into the pool.
   */
  persist: function() {
    // Modern event system doesn't use pooling.
    if (!enableModernEventSystem) {
      this.isPersistent = functionThatReturnsTrue;
    }
  },

Poprzedni system zarządzania eventami oparty na poolingu polegał na tym, że gdy isPersistent zwracała true to dany event nie był usuwany.

Co to jest Event Pooling (eng. grupowanie)?

Podczas tworzenia dużej listy obiektów istnieje problem, że do trzymania w pamięci danych o tych obiektach zostanie wykorzystana duża pamięć.

Dlatego też w silnikach istnieje system Usuwania Nieużytków (Garbage Collection), który to proces usuwa obiekty, do których nie ma już referencji. Świetny mechanizm, który powoduje, że możemy używać aplikacji dłuższy czas.

Problemem jest stworzenie “za dużej” liczby obiektów. Ponieważ może być tak, że tworząc nowe obiekty, nie potrzebujemy już starych, więc po co trzymać je w pamięci i czekać aż proces Garbage Collection zostanie uruchomiony?

Proces usuwania “śmieci” może być kosztowny i trwać na tyle długo, że zostanie to zauważaone w UI. Dobrym przykładem są gry o dużej responsywności, tam zatrzymanie wątku na kilkanąście minilekund może powodować, że gra nie jest solidna.

Ciekawostka

Z poziomu API silnika nie mamy możliwości uruchomienia usuwania śmieci.

Świetnym rozwiązaniem na tego typu problem jest stworzenie stałej liczby obiektów, która to posiada konkretną liczbę utworzonych obiektów. Tym samym, proces Garbage Collection nie musi usuwać nieużytków, ponieważ my ich nie tworzymy, a tylko aktualizujemy już istniejące.

Dlaczego usunięto Event Pooling?

Pooling miał pomagać na problemy z pamięcią, ale dla nowoczesnych przeglądarek (eng. modern browsers) nie pomagał, a wprowadzał tylko problemy (np. opisywany w tym artykule), więc zdecydowano się go usunąć.

Pull Request usuwający Event Pooling:
https://github.com/facebook/react/pull/18969, zmerdżowany do mastera 21 maja 2020.

Ciekawostka

Pomysłodawcą usunięcie Event Pooling był Dan Abramov. Pull Request https://github.com/facebook/react/pull/18216, nie został zmerdżowany, ponieważ pojawił się nowy PR, który realizował więcej np. usunięcie odpowiednich Feature Flag.

Argumenty jakie przytoczył Dan Abramov w swojej propozycji pozbycia się EP:

  • EP pomagał w tworzeniu eventów, ale nie był pomocny w:
    • usuwaniu eventów
    • niszczeniu eventów
    • reużyciu eventów
  • nikt inny nie używa takiej techniki
  • EP jest zagmatwane (np. nie można użyć e.target w fn używającej setState)

Zmiany od wersji v17

Poniżej możecie zobaczyć aktualny (na dzień publikacji artykułu) kod prosto z repozytorium facebook/react, gdzie jest informacja, że nowy system zarządzania eventami nie używa poolingu.

// @see https://github.com/facebook/react/blob/cae635054e17a6f107a39d328649137b83f25972/packages/react-dom/src/events/SyntheticEvent.js

/**
 * We release all dispatched `SyntheticEvent`s after each event loop, adding
 * them back into the pool. This allows a way to hold onto a reference that
 * won't be added back into the pool.
 */
persist: function() {
    // Modern event system doesn't use pooling.
},

/**
 * Checks if this event should be released back into the pool.
 *
 * @return {boolean} True if this should not be released, false otherwise.
 */
isPersistent: functionThatReturnsTrue,

W changelogu do wersji v17 również jest w wzmianka o nowym sposobie na zarządzenia Eventami — https://reactjs.org/blog/2020/08/10/react-v17-rc.html#no-event-pooling

Moje rozwiązanie problemu

Z uwagi na to, że nie mogę podnieść wersji tej zależności tak łatwo, to najlepszym rozwiązaniem będzie użycie debounce… ale w innym miejscu!

Największym problemem, jest to, że trzymając wciśnięty klawisz ENTER, wysyłane są zapytania HTTP, w takim razie owrapuję tylko wywołanie funkcji wysyłającej zapytanie!

Problem rozwiązany.

Podziękowania

Dziękuję @miloszpp za podsunięcie pomysłu, aby napisać o tym artykuł na blogu.

Bibliografia