Sprawdź jak ograniczyć ilość event listenerów w JS!

Wprowadzenie

A po co ograniczać ilość event listenerów? Powodów jest kilka:

  1. Szybsze budowanie się UI. Mniej listenerów do podpięcia to po prostu mniej kodu do wykonania. To także często mniej operacji na DOM – aby podpiąć listener do elementu musimy przecież pobrać go np za pomocą querySelector.
  2. Płynniejsza obsługa UI. Mniej listenerów to mniej nasłuchiwania, szczególnie jeśli np w podpinamy bardzo dużo eventów mouseenter/mouseleave do np tabelki.

W jaki sposób możemy więc ograniczyć ilość listenerów? To zależy od sytuacji, ale jeden ze sposobów może być wykorzystany nader często – mianowicie podpinanie listenerów na rodzicu zamiast na poszczególnych elementach.

W tym artykule porównamy szybkość podpinania listenerów w 2 przypadkach – gdy podepniemy jeden listener to całej tablicy a także gdy podepniemy listener do każdego przycisku w wierszach tablicy. Tablicę wypełnimy tysiącem wierszy z których każdy będzie miał 1 przycisk do usunięcia tego wiersza.

Porównanie sposobów

Szkielet przykładu

Aby mieć szkielet do porównania, potrzebować będziemy 2 przycisków do przełączania się między trybami:

<button id="withSeparatedEvents">
  Add separated events
</button>
<button id="withParentEvent">
  Add one parent event
</button>

Oraz tabelki która będzie wypełniona wierszami z przyciskiem:

<table>
  <caption class="caption">
    Please click one of buttons to attach event listeners
  </caption>
  <thead>
    <tr>
      <th>Name</th>
      <th>Surname</th>
      <th>Actions</th>
    </tr>
  </thead>
  <tbody class="tableBody">
  <!-- To be filled with rows with elements -->
  </tbody>
</table>

Tabelkę wypełnią elementy wygenerowane w następujący sposób:

const arrayHelper = Array(1000).fill(null);
  document.querySelector('.tableBody')
    .innerHTML = arrayHelper
    .map((_, index) =>
      `
        <tr>
          <td>
            Name ${index}
          </td>
          <td>
            Surname ${index}
          </td>
          <td>
            <button class="button">
              Remove ${index} element
            </button>
          </td>
        </tr>
      `).join('');

Usuwanie elementu zostanie zasymulowane za pomocą Promise i setTimeout:

const wait = timeout => new Promise(resolve => setTimeout(resolve, timeout));

const removeElement = async el => {
  const timeout = 2000;
  await wait(timeout); // to emulate network request
  const row = el.closest('tr');
  row.parentElement.removeChild(row);
};

Elementami na których kliknięcie będziemy reagować to przyciski. Przyciski umieszczone będą wewnątrz kolumny <td> w wierszu <tr>, stąd potrzebować będziemy znaleźć wiersz w którym ten element się znajduje i go usunąć. Pomoże nam w tym metoda closest, która przeszuka rodziców w górę aż natrafi na wiersz. Wtedy będziemy mogli usunąć wiersz.

Ostatnią istotną funkcją ze szkieletu będzie funkcja mierząca czas wykonania:

const measurePerformance = functionToMeasure => {
  const t0 = performance.now();
  functionToMeasure();
  const t1 = performance.now();
  alert(`time: ${t1 - t0}`);
};

Funkcja ta odejmie czas zakończenia operacji od czasu rozpoczęcia operacji oraz wyświetli ten czas.

Podpięcie listenera do każdego przycisku z osobna

Podpięcie zrealizujemy funkcją:

const onButtonClicked = e => removeElement(e.target);

const addSeparatedEvents = async () => {
  tableCaption.innerHTML = 'Separated events per every button';
  await wait(0); // to update table caption
  cleanPreviousListeners();

  measurePerformance(() => {
    document.querySelectorAll('.button').forEach(el => {
      el.addEventListener('click', onButtonClicked);
    });
  })
};

Na moim komputerze, po 6-krotnym spowolnieniu CPU w Dev Tools w zakładce Performance, czas podpięcia wynosił 7 milisekund:

Podpięcie listenera na całej tabeli

Przy podpięciu listenera na całej tabeli musimy odfiltrować kliknięcia w inne elementy. Zrobimy to sprawdzając, czy kliknięty element ma klasę button:

const onChildButtonClicked = e => {
  if (e.target.classList.contains('button')) {
    removeElement(e.target);
  }
};

Samo podpięcie wygląda następująco:

const addParentEvent = async () => {
  tableCaption.innerHTML = 'One event on table body';
  await wait(0); // to update table caption
  cleanPreviousListeners();

  measurePerformance(() => {
    document.querySelector('.tableBody')
      .addEventListener('click', onChildButtonClicked);
  });
}

Na moim komputerze, po 6-krotnym spowolnieniu CPU w Dev Tools w zakładce Performance, czas podpięcia wynosił 0.4 milisekundy:

Porównanie szybkości na wolniejszym urządzeniu i szokujące wnioski

Nawet na PC widać, że opcja z jednym eventem jest prawie 18 razy szybsza. Co jednak pokaże eksperyment z bardzo wolnym urządzeniem mobilnym?

Do tego celu odkurzyłem swoją starą Motorolę Moto G2 🙂

Wyniki prezentują się następująco:

Wersja z wieloma listenerami:

Wersja z pojedynczym listenerem:

Czyli wersja z pojedynczym listenerem jest sto razy szybsza niż wersja z wieloma!

Kod przykładu na Github

Kod przykładu dostępny jest pod adresem:

https://github.com/radek-anuszewski/click-event-on-parent-demo

Klikalny przykład na Github Pages

Klikalny przykład dostępny jest pod adresem:

https://radek-anuszewski.github.io/click-event-on-parent-demo/

Podsumowanie

Oczywiście – możesz powiedzieć, że nadal mówimy tu o milisekundach. Możesz powiedzieć również, że Moto G 2 to bardzo stary i wolny telefon (fakt, przycina się przy pisaniu na klawiaturze 🙂 ).

Z drugiej strony, w sytuacji gdy mamy rzeczywistą stronę internetową może nałożyć się na siebie wiele takich sytuacji, co w konsekwencji spowoduje że będzie ona działać wolniej niż powinna.

Dodatkowo sytuacja opisana w artykule pokazuje, jak ważne jest testowanie również na wolniejszych urządzeniach, gdyż mniej optymalne rozwiązania oddziałują na słabsze urządzenia znacznie bardziej niż na te mocniejsze.

Oczywiście rozwiązanie zaciemnia nieco kod – dlatego jego zastosowanie powinno być poprzedzone testami na wolnym urządzeniu i potwierdzeniu, że faktycznie należy szukać optymalizacji wydajności.