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/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.