Uwaga! Trwają prace nad nową wersją serwisu. Mogą występować przejściowe problemy z jego funkcjonowaniem. Przepraszam za niedogodności!

Nasłuchiwanie zdarzeń na elementach tworzonych dynamicznie w JavaScript

Korzystaj z mocy propagacji!

Chcesz wyświetlać informacje pobrane z serwera i tworzyć interaktywne elementy listy? Naucz się wykorzystywać propagację! Delegacja zdarzeń w kontekście API i elementów tworzonych dynamicznie to zagadnienie, które junior powinien poznać jak najszybciej.

Spis treści

 Elementy tworzone dynamicznie – co to znaczy

Są to elementy HTML tworzone przy pomocy JavaScriptu już po załadowaniu drzewa DOM – czyli strony z naszym kodem HTML. Przykładem może być tutaj zwykła to-do lista czy formularz dodawania
członków klubu do listy <ul />: wypełniamy dane, klikamy „dodaj” i kolejna osoba trafia do rozpiski jako element <li />. Ten dodany element listy to właśnie element HTML stworzony dynamicznie.

Innym przypadkiem jest pobieranie danych z serwera. Jeżeli np. chcemy wyświetlić na stronie listę książek, to po otrzymaniu odpowiedzi z API (zapewne tablicy obiektów z informacjami o książkach), utworzymy odpowiednie elementy – mogą to być zdjęcia okładek opatrzone tytułem i opisem.

 Na czym polega nasłuchiwanie zdarzeń

Nasłuchiwanie zdarzenia przez dany element to oczekiwanie na nastąpienie konkretnej sytuacji, np. kliknięcia, załadowania się filmu, naciśnięcia klawisza, scrollowania itd. W kodzie wygląda ono w ten sposób:

document.addEventListener('DOMContentLoaded', function () {
    console.log('Hello world');
});

W powyższym przykładzie oczekujemy zdarzenia DOMContentLoaded, czyli załadowania się drzewa DOM. Dopiero po wystąpieniu zdarzenia uruchomi się funkcja wyświetlająca w konsoli napis „Hello world”. Przykład ten doskonale pokazuje, że eventy nie zawsze związane są z akcjami użytkownika, jak znane Ci już pewnie zdarzenia np. click czy keydown.

 Co to jest propagacja

Propagacja to rozchodzenie się zdarzenia. Gdyby porównać elementy HTML do ustawionych w rzędzie członków rodziny, to rozejściem się zdarzenia można by nazwać taką sytuację:

Pradziadek czeka na zdarzenie „podano obiad”. Stoi on na początku rzędu, dalej stoi jego syn, potem wnuczka i prawnuczka. Obojętnie, które z nich powie „podano obiad”, to pradziadek i tak to usłyszy. Co więcej, będzie wiedział, kto jest autorem zawołania i będzie mógł odpowiedzieć: „dziękuję za informację, Mariuszu/Zofio/Zuzio”. Nawet więcej! Będzie mógł zarządzić, by wszyscy jego potomkowie poszli do jadalni 😉

Właśnie to, że znamy „autora zawołania” wykorzystamy za chwilę w przykładzie. Najpierw jednak omówimy dwa podejścia, które nie są optymalne (a jedno z nich nie działa!), lecz często pojawiają się w rozwiązaniach początkujących programistów.

 Kod projektu – dynamiczne tworzenie elementów

Przedstawię Ci to na przykładzie „panelu administratora”, gdzie za pomocą przycisku dodajemy nowy produkt do listy. Dla ułatwienia przekazu nie budowałem formularza danych produktu – potraktujmy nasz button jako ostatni etap dodawania informacji o produkcie.

 Kod HTML i szablon elementu listy

W pliku index.html przygotowałem już odpowiednią strukturę. Mamy tam przycisk „Dodaj produkt” oraz listę, na której będziemy umieszczać kolejne elementy. Zwróć uwagę na element listy <li /> o klasie .adminPanel__item--template – wykorzystam go zaraz jako szablon.

<main class="articleExamples">
    <h1 class="articleExamples__header">
        Rozwiązanie przed dodaniem nasłuchiwania na przycisku „Usuń”
    </h1>
    <section class="articleExamples__adminPanel adminPanel">
        <button class="adminPanel__addProductBtn">Dodaj produkt</button>
        <ul class="adminPanel__list">
            <li class="adminPanel__item adminPanel__item--template">
                <article class="adminPanel__container product">
                    <h2 class="product__name"></h2>
                    <button class="product__deleteBtn">usuń</button>
                </article>
            </li>
        </ul>
    </section>
    <!-- ... -->
</main>

 Kod JavaScript – dynamiczne dodawanie elementów

Całość kodu JS umieszczam w funkcji, która uruchomi się po evencie DOMContentLoaded, czyli po załadowaniu kodu HTML. Dzięki temu wiem, że zanim zacznę wyszukiwać elementy, będą one już istnieć w drzewie DOM.

Poniżej umieściłem komentarze, w których po kolei tłumaczę, co dzieje się w kodzie. Pamiętaj jednak, że w projektach komercyjnych zamieszczanie komentarzy nie jest dobrym rozwiązaniem (nazwy funkcji i zmiennych powinny wystarczyć do zrozumienia rozwiązań).

document.addEventListener('DOMContentLoaded', function () {
    // wyszukuję listę <ul /> oraz szablon elementu listy
    const productsList = document.querySelector('.adminPanel__list');
    const productItemTemplate = document.querySelector(
        '.adminPanel__item--template'
    );

    // wyszukuję przycisk dodający produkty
    const addBtn = document.querySelector('.adminPanel__addProductBtn');

    let productCounter = 1;

    // na tym przycisku ustawiam nasłuchiwanie na event 'click'
    addBtn.addEventListener('click', function () {
        // wykorzystuję szablon elementu listy i usuwam klasę ukrywającą ten element
        const newProductItem = productItemTemplate.cloneNode(true);
        newProductItem.classList.remove('adminPanel__item--template');

        // dodaję treść do nagłówka produktu
        const productTitle = newProductItem.querySelector('.product__name');
        productTitle.innerText = 'Produkt: ' + productCounter;

        // dodaję element listy do rodzica <ul />
        productsList.appendChild(newProductItem);

        // zwiększam licznik o 1, by nagłówki różniły się liczbą
        productCounter++;
    });
});

W linii 17 usuwam klasę .adminPanel__item--template, ponieważ w kodzie CSS przypisałem do niej właściwość display z wartością none. Szablon nadal nie będzie widoczny na stronie, a nowe elementy już tak.

Kod powyższego rozwiązania znajdziesz w repozytorium, a jego działanie podejrzysz dzięki GitHub Pages. Uwaga: jasnoróżowe przyciski „Próba 1”, „Próba 2” i „Próba 3” przekierują Cię do rozwiązań przedstawionych poniżej.

 Nasłuchiwanie na elementach tworzonych dynamicznie – nieprawidłowe podejście

Gdyby przełożyć to na nasz przykład z rodziną, to takim nieprawidłowym (a w drugim przypadku – co najmniej nieoptymalnym) podejściem byłaby próba stworzenia sytuacji, w której każdy z członków rodziny mówi sam do siebie „podano obiad” i sam na ten obiad idzie. Jak wiele niepotrzebnie wypowiedzianych słów!

 Próba 1: querySelectorAll() – wyszukanie
wszystkich przycisków „usuń”

Sposób myślenia w tym przypadku jest taki: mam wiele produktów i wszystkie te produkty mają przycisk „usuń”. Muszę więc wyszukać wszystkie przyciski na stronie, dodać do nich nasłuchiwanie na event click, a w przypisanej do zdarzenia funkcji umieścić kod obsługujący usuwanie produktu.

Rozwiązanie to mogłoby wyglądać tak:

const deleteBtns = document.querySelectorAll('.product__deleteBtn');

for (const btn of deleteBtns) {
    btn.addEventListener('click', function () {
        const product = btn.closest('.adminPanel__item');
        product.remove();
    });
}

Sprawdź podgląd. Dlaczego mimo to przycisk „usuń” nie działa? Ponieważ powyższy kod JavaScript skończył się wykonywać po uruchomieniu strony, czyli jeszcze zanim w ogóle pojawiły się na niej nasze produkty z przyciskiem „usuń”. Nie mieliśmy więc szansy „namierzyć ich” – w tym przypadku za pomocą .querySelectorAll() – ponieważ jeszcze nie istniały.

Podobnie stałoby się w kodzie asynchronicznym, gdy próbowalibyśmy wyszukać przyciski zanim nasze produkty pobiorą się z serwera.

Skoro więc problemem jest nieistnienie elementów, to czemu by nie dodać do nich eventListenera w momencie ich tworzenia? Spróbujmy!

 Próba 2: dodanie nasłuchiwania w momencie dynamicznego tworzenia elementu

Wracamy do kodu, w którym tworzymy nowe elementy po kliknięciu „Dodaj produkt”. Tam do przycisku „usuń” każdego kolejnego produktu dodajemy nasłuchiwanie na click i funkcję kasującą.

let productCounter = 1;

addBtn.addEventListener('click', function () {
    const newProductItem = productItemTemplate.cloneNode(true);
    newProductItem.classList.remove('adminPanel__item--template');

    const productTitle = newProductItem.querySelector('.product__name');
    productTitle.innerText = 'Produkt: ' + productCounter;

    productsList.appendChild(newProductItem);

    productCounter++;

    const deleteBtn = newProductItem.querySelector('.product__deleteBtn');

    deleteBtn.addEventListener('click', function () {
        const product = deleteBtn.closest('.adminPanel__item');
        product.remove();
    });
});

Nasze rozwiązanie działa (przetestuj)! Ale czy jest optymalne? Wyobraźmy sobie, że na stronie pojawia się kilka tysięcy elementów – to kilka tysięcy funkcji zajmujących pamięć. Problem ten można łatwo
rozwiązać poprzez przeniesienie funkcji anonimowej do osobnej deklaracji funkcji, np. tak:

let productCounter = 1;

addBtn.addEventListener('click', createItem);

function createItem() {
    const newProductItem = productItemTemplate.cloneNode(true);
    newProductItem.classList.remove('adminPanel__item--template');

    const productTitle = newProductItem.querySelector('.product__name');
    productTitle.innerText = 'Produkt: ' + productCounter;

    productsList.appendChild(newProductItem);

    productCounter++;

    const deleteBtn = newProductItem.querySelector('.product__deleteBtn');

    deleteBtn.addEventListener('click', function () {
        const product = deleteBtn.closest('.adminPanel__item');
        product.remove();
    });
}

Możesz spotkać się z opinią, że delegacja zdarzeń zawsze jest lepsza od powyższego rozwiązania, bo nie zwiększa tak znacznie czasu ładowania strony / nie zajmuje tyle pamięci. Argumenty te jednak pojawiają się w towarzystwie założenia, że mamy do czynienia z dziesiątkami tysięcy elementów na stronie. Możliwe, że nigdy nie spotkasz się z potrzebą wyświetlenia takiej liczby elementów. A jeśli tak, to rozwiążesz to np. przed dodanie paginacji (stron, które zawierają kolejne części listy), filtrowanie zapytań czy lazy loading i problem z wydajnością strony w kontekście nasłuchiwania zdarzeń nie będzie mieć znaczenia.

Jakie rozwiązanie pozwoli nam jednak nie przejmować się powyższym zagadnieniem? Czas na próbę nr 3.

 Próba 3: nasłuchiwanie na elementach tworzonych dynamicznie – wykorzystanie propagacji

Połączmy teraz przykład stojącej w rzędzie rodziny z naszym panelem administratora. U nas „pradziadkiem” jest element <ul />. To on będzie nasłuchiwał kliknięcia. „Usłyszy”, co prawda, wszystkie kliknięcia wykonane na swoich potomkach, ale zareaguje dopiero wtedy, gdy ktoś kliknie w przycisk „usuń”.

Nazywamy to delegacją zdarzenia.

Dzięki temu zniknie problem wyszukiwania przycisków „usuń” lub dodawania nasłuchiwania bezpośrednio na nich. Dlaczego? Dlatego, że u nas element <ul /> nie jest tworzony dynamicznie. Zostanie więc bez problemu znaleziony na stronie po załadowaniu drzewa DOM (przetestuj).

productsList.addEventListener('click', function (e) {
    const clickInitializer = e.target;

    const isDeleteBtn =
        clickInitializer.classList.contains('product__deleteBtn');
    if (isDeleteBtn) {
        const product = clickInitializer.closest('.adminPanel__item');
        product.remove();
    }
});
Kod powyższego rozwiązania znajdziesz w repozytorium, a jego działanie podejrzysz dzięki GitHub Pages. Uwaga: jasnoróżowe przyciski „Próba 1”, „Próba 2” i „Próba 3” przekierują Cię do rozwiązań przedstawionych poniżej.

 Refaktoryzacja – zadanie dodatkowe

Zapoznaj się z artykułem „Refaktoryzacja – jak tworzyć krótsze funkcje i czytelniejszy kod”, a następnie spróbuj wykonać refaktoryzację panelu administratora. Sporo kodu można wydzielić do mniejszych funkcji i przy okazji przepraktykować ich odpowiednie nazywanie.

Możesz też poćwiczyć tworzenie klas zgodnie z metodologią BEM, kodując formularz dodawania nowych produktów w pliku index.html. Nie zapomnij wówczas podpiąć na formularzu nasłuchiwania na event submit.

 

Zapamiętaj powyższe rozwiązanie z propagacją i od teraz używaj go w swoich projektach ( nie musisz jednak używać go zawsze). Internet i aplikacje są pełne eventów oraz dynamicznego tworzenia contentu – czy to z pomocą formularzy, czy korzystania z API. Odtąd więc ustawianie nasłuchiwania na „niedynamicznych przodkach” powinno stać się Twoim chlebem powszednim.

Udostępnij ten artykuł:

Mentoring to efektywna nauka pod okiem doświadczonej osoby, która:

  • przekazuje Ci swoją wiedzę i nadzoruje Twoje postępy w zdobywaniu umiejętności,
  • uczy Cię dobrych praktyk i wyłapuje złe nawyki,
  • wspiera Twój rozwój i zwiększa zaangażowanie w naukę.