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
    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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<button id="withSeparatedEvents">
Add separated events
</button>
<button id="withParentEvent">
Add one parent event
</button>
<button id="withSeparatedEvents"> Add separated events </button> <button id="withParentEvent"> Add one parent event </button>
<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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<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>
<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>
<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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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('');
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('');
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
Promise i
setTimeout
setTimeout:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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);
};
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); };
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>
<td> w wierszu
<tr>
<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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const measurePerformance = functionToMeasure => {
const t0 = performance.now();
functionToMeasure();
const t1 = performance.now();
alert(`time: ${t1 - t0}`);
};
const measurePerformance = functionToMeasure => { const t0 = performance.now(); functionToMeasure(); const t1 = performance.now(); alert(`time: ${t1 - t0}`); };
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ą:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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);
});
})
};
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); }); }) };
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
button:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const onChildButtonClicked = e => {
if (e.target.classList.contains('button')) {
removeElement(e.target);
}
};
const onChildButtonClicked = e => { if (e.target.classList.contains('button')) { removeElement(e.target); } };
const onChildButtonClicked = e => {
  if (e.target.classList.contains('button')) {
    removeElement(e.target);
  }
};

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

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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);
});
}
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); }); }
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.