Propagacja i delegacja zdarzeń w JavaScript

Artykuł dodany: 11 września 2017.

Stopień trudności (1 - dla początkujących, 5 - dla ekspertów): 2

Wykonywanie konkretnych akcji, na skutek czynności użytkownika na stronie, to kwintesencja webowego programowania. Nie ważne czy korzystamy z zewnętrznej biblioteki (typu jQuery, Vue.js, Angular), czy gdy zdarzenia obsługujemy bezpośrednio w czystym JavaScript, należy znać podstawowy ich funkcjonowania. Dzisiaj zajmiemy się:

- omówieniem podstawowych typów zdarzeń
- wytłumaczeniem jak działa propagacja zdarzeń
- objaśnieniem czym jest technika delegacji zdarzeń

W przykładach posłużę się zarówno biblioteką jQuery, jak i natywnym kodem JS. Sam artykuł jest rozwinięciem napisanego kilka lat temu tekstu o delegacji zdarzeń w jQuery. Do pracy potrzebny nam będzie dokument HTML5, zamieszczony poniżej.

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"/>
    <title>Propagacja i delegacja zdarzeń w JavaScript</title>
    <style>
    .active {
        background-color: red;
    }
    </style>
    <!-- 
        Artykuł omawia jednoczenie bibliotekę jQuery - dołączamy ją.
        W środowisku produkcyjnym należało by się zdecydować tylko na jedno rozwiązanie.
     -->
    <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
    <script>
        document.addEventListener("DOMContentLoaded", function(event) {
            // resztę kodu z przykładów należy wstawiać w tym miejscu
            // kod wykona się po załadowaniu treści drzewa DOM

            // dynamicznie dodajemy 4 link
            document.querySelector('#menu').insertAdjacentHTML(
                'beforeend', 
                '<li><a href="http://google.pl">Google.pl - dynamiczny</a></li>'
            );
        });
    </script>
</head>
<body>
    <nav id="test">
        <ul id="menu">
            <li><a href="http://wp.pl">Wp.pl</a></li>
            <li><a href="http://onet.pl">Onet.pl</a></li>
            <li><a href="http://interia.pl">Interia.pl</a></li>
        </ul>
    </nav>
</body>
</html>

Testowe menu `ul#menu` zawiera 3 linki do znanych portali. Po załadowaniu strony dodawany jest dynamicznie na koniec listy kolejny link do Google. Posłuży jako ilustracja do tekstu artykułu.

Zdarzenia (Events)

Od strony technicznej zdarzenia reprezentują obiekt który implementuje interface Event. W bardziej potocznym języku, zdarzenia oznaczają wszystkie podjęte przez użytkownika – bądź bezpośrednio przeglądarkę – akcje. Zdarzeniem jest na przykład: wysłanie formularza, kliknięcie w link, dotknięcie ekranu, wprowadzenie danych w pole formularza, wydruk strony, poruszenie myszą, wyświetlenie powiadomienia, podłączenie urządzenia VR (Virtual Reality) i dziesiątki innych. Najczęściej stosowane zdarzenia to:

click – wciśnięcie przycisku myszy i zwolnienie nad danym elementem
dblclick – podwójne kliknięcie w dany element
mousemove – wskaźnik myszy został przesunięty nad element
keypress – wciśnięcia klawisza na klawiaturze
submit – formularz został wysłany
input – zmieniła się wartość elementu `input`, `select`, `textarea`
drag – element został przeciągnięty
drop – element został upuszczony
DOMContentLoaded – dokument HTML został załadowany (bez czekania na zawartość ramek, zewnętrzne obrazki czy arkusze stylów)

Problemem na który należy zwrócić uwagę stosując zdarzenia jest (jak zawsze podczas korzystania z JS) ich wsparcie w przeglądarkach. Chociaż z podstawowymi zdarzeniami typu kliknięcie czy wysłanie formularza nie będzie problemu, to bardziej wymyślne lepiej sprawdzić przed użyciem. Pomoże nam w tym albo strona MDN, albo Can I use?

Dodawanie zdarzeń do elementów

W zależności od tego, czy korzystamy bezpośrednio z JS, czy stosujemy bibliotekę jQuery, zdarzenia podpinamy pod dany element następująco:

jQuery

$.on( events [, selector] [, data] , handler(eventObject) ) //zwraca obiekt jQuery

events – jedno lub więcej zdarzeń oddzielonych spacją – “click”, “focus”, “keypress” itd. Może zawierać dodatkowo przestrzenie nazw np. “click.foo.bar” grupujące dodatkowo zdarzenia
selector – string służący do odfiltrowania potomka elementu wywołującego zdarzenie
data – dodatkowe dane przekazywane do funkcji “handler” w chwili wystąpienia zdarzenia
handler(eventObject) – funkcja wywoływana gdy nastąpi zdarzenie

JavaScript

EventTarget.addEventListener(event, handler [, useCapture])

event – zdarzenie np. “click”, “focus”, “keypress”
handler(eventObject) – funkcja wywoływana gdy nastąpi zdarzenie
useCapture – faza nasłuchu – true: capture, false: bubbling

Nie wchodząc jeszcze w szczegóły, dodanie nasłuchu na zdarzenie (bądź zdarzenia w jQuery) w obu przypadkach sprowadza się do

- wyboru elementu nasłuchującego
- wyboru typu zdarzenia
- dodania funkcji która zostanie wywołana gdy zdarzenie nastąpi.

Dodatkowo obiekt `Event` zawiera bardzo użyteczną metodę:

Event.preventDefault() – zablokuj domyślną akcję np. przekierowanie po kliknięciu w link, wysłanie formularza

Mamy już testowy dokument, razem z kawałkiem kodu CSS ustawiającym czerwony kolor tła dla klasy `active`. Dodajmy teraz zdarzenie, aby po kliknięciu w element `a` została dokonana podmiana klasy.

jQuery

$('a').on('click', function(event) {
    event.preventDefault();
    $('a').removeClass('active');
    $(this).addClass('active');
});

JavaScript

// UWAGA! Konstrukcja NodeList.forEach dostępna jest w najnowszych przeglądarkach
// Dla starszych, oraz Internet Explorer / Edge lepiej użyć podstawową pętlę for.
var links = document.querySelectorAll('a');
links.forEach(function(item) {
    item.addEventListener('click', function(event) {
        event.preventDefault();
        links.forEach(function(item) {
            item.classList.remove('active');
        });
        this.classList.add('active');
    });
});

Niestety kod wywołany w ten sposób jest fatalny pod względem wydajności i przy bardzo dużej liczbie elementów może nawet zablokować przeglądarkę. Ponieważ zdarzenie zostało podpięte po kolei do każdego `a` przeglądarka jest zmuszona nasłuchiwać może nawet i setki elementów. Co więcej, działa tylko dla 3 pierwszych linków. Czwarty dodany dynamicznie nie ma podpiętego żadnego zdarzenia. Należy unikać pisania w ten sposób kodu! Rozwiązaniem tego problemu jest zastosowanie delegacji zdarzeń. Jednak aby do tego dojść należy wcześniej zrozumieć jak działają zdarzenia i jak zachowuje się przeglądarka. Innymi słowy czym jest propagacja zdarzeń.

DOMContentLoaded czyli wywołanie kodu gdy DOM został załadowany

W naszym testowym dokumencie użyliśmy już nasłuchu:

document.addEventListener("DOMContentLoaded", function(event) { ... });

Odpowiednikiem jQuery jest ( to mały wyjątek w stosunku do typowego wywołania metody .on() ):

$(function() { ... });

W obu przypadkach przeglądarka wywoła kod, gdy DOM zostanie załadowany czyli inaczej – gdy struktura dokumentu będzie gotowa do dalszej obróbki. Dlaczego jest to tak ważne? Istnieje kilka metod osadzania skryptów. W sekcji `head`, wewnątrz `body`, na końcu `body` – przed zamknięciem dokumentu. Tag `script` może też być wczytanie asynchronicznie lub z odroczeniem – odpowiadają za to atrybuty `async` oraz `defer`. Każda metoda ma swoje plusy i minusy, różnią się one wydajnością. Może się zdarzyć iż skrypt będzie chciał wywołać kod manipulujący strukturą DOM, gdy ta nie jest jeszcze dostępna. Spowoduje to oczywiście błędy i przerwanie działania kodu. Stosując powyższe funkcje upewniamy się, że wszystkie elementy pierwotnego dokumentu zostały załadowane i są widoczne dla skryptu.

Propagacja zdarzeń oraz fazy capture, target i bubbling

Poznaliśmy już podstawy ale dalej nie wiemy w jaki sposób przeglądarka obsługuje zdarzenia. Bardzo dobrze wyjaśnia to dokument W3C.

# Window
| Document  \
| HTML      |
| BODY      } CAPTURE
| NAV      /
| UL      /
| LI    /
| A     <-- TARGET
| LI    \
| UL      \
| NAV      \
| BODY      } BUBBLING
| HTML      |
| Document  /
# Window

Cała ścieżka propagacji odwzorowuje hierarchiczną strukturę DOM i przechodzi przez wszystkie elementy danej gałęzi. Zdarzenie rozpoczyna się od elementu `Window`, w dół drzewa aż do poszukiwanego elementu (target). Faza ta nazywa się przechwytywaniem (capture). Gdy zdarzenie osiągnie swój cel wszystkie zdarzenia przypięte do elementu są wywoływane i następuje podróż w odwrotnym kierunku – w górę drzewa. Przechodzi przez wszystkich rodziców, ponownie aż do `Window`. Jest to faza bąbelkowania (bubbling). Nie wszystkie zdarzenia “bąbelkują” (nie robią tego np. `load`, `focus`). Istnieje też możliwość przerwania propagacji przez wywołanie metody Event.stopPropagation().

jQuery

W jQuery nie ma możliwości wybrania do której fazy zdarzenie ma zostać przypięte. Domyślnie jest to `bubbling`.

JavaScript

Trzeci parametr metody `addEventListener` – useCapture – określa odpowiednią fazę nasłuchu – true oznacza `capture`, false `bubbling`.

Delegacja zdarzeń

Wiemy już jak funkcjonują zdarzenia oraz że nie należy ich przypinać do bezpośrednich dzieci (które mogą ulec modyfikacjom w dowolnym czasie – w naszym przykładzie element `a`). Poznaliśmy także w jaki sposób odbywa się propagacja zdarzeń. Pora to wykorzystać.

Skoro wiemy iż dziecko informuje rodzica o wykonanej akcji, a każdy rodzic może przekazać swoim dzieciom – także nowo utworzonym – identyczną wiedzę, to niech rodzic będzie punktem wyjściowym:

JavaScript

var menu = document.getElementById('menu');

menu.addEventListener('click', function (event) {
    event.preventDefault();
    // event.currentTarget === this === menu
    event.currentTarget.querySelectorAll('a').forEach(function (item) {
        item.classList.remove('active');
    });
    if (event.target.tagName === 'A') {
        event.target.classList.add('active');
    }
}, false);

Odwróciliśmy znacznie sytuację. Zdarzenie zostało przypięte tylko do jednego elementu, który jest w stanie zastosować je do wymaganego dziecka. `event.target` oznacza kliknięte dziecko, podczas gdy `event.currentTarget` jest elementem rodzica. Kontekst `this` również oznacza rodzica.

jQuery

$('#menu').on('click', 'a', function(event) {
    event.preventDefault();
    // event.target === this === event.currentTarget
    $(event.delegateTarget).find('a').removeClass('active');
    $(this).addClass('active');
});

Trochę inaczej sprawa wygląda w jQuery. `this` zostało przepisane przez framework i tym razem zawiera dane o linku `a`. Tak samo `event.target` oraz `event.currentTarget`. Framework utworzył za to nową właściwość – `event.delegateTarget` która zwiera element rodzica.

Delegacja zdarzeń jest niezmiernie pożyteczna i powinna być stosowana zawsze jako główny sposób dodawania eventów. Jako rodzica warto wybrać najbliższy niezmienny element. Sposób ten jest wydajny i działa na wszystkie elementy-dzieci – także te nieistniejące w chwili dodawania zdarzenia.

Komentarze

Nie ma jeszcze żadnych komentarzy do wyświetlenia. Może chcesz zostać pierwszą osobą która podzieli się swoją opinią?

Dodaj komentarz

*
Nazwa zostanie wyświetlona wraz z komentarzem. Możesz też utworzyć nowe konto w serwisie, dzięki czemu uzyskasz dodatkową funkcjonalność.
*
Akceptowana jest ograniczona składnia Textile. Wszystkie tagi HTML zostaną usunięte.