Wprowadzenie
A po co ograniczać ilość event listenerów? Powodów jest kilka:
- 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.
- 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/mouseleavedo 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.
