Event Delegation

Założenia wstępne

  • Podstawowa znajomość składni JS
  • Podstawowa znajomość mechanizmów JS-a (funkcje, eventy, zmienne)

Wprowadzenie

Na każdym blogu zw. z programowaniem główną rolę grają frameworki. Jest to całkowicie zrozumiałe. Znaczna większość projektów, nad którymi przyjdzie nam w tych czasach pracować będzie oparta na tego typu rozwiązaniach. Minusem jest to, że młodzi inżynierowie często zabierają się za frameworki kompletnie nie znając genezy ich powstania i problemów jakie rozwiązują. Pomyślałem, że pójdę trochę “pod prąd”, zrobię kilka kroków wstecz i omówię mechanizm czystego JS-a (w połączeniu z API przegladarki rzecz jasna :) ) .Prawdopodobnie pojawi się na tym blogu więcej wpisów dot. czystego JS-a.

Problem

Załóżmy, że dostałeś w pracy do zrobienia nowe zadanie. Masz stworzyć aplikację, która wyświetli trzy imiona naukowców. Kliknięcie w naukowca zmieni jego kolor na czerwony. Proste! Dodatkowy plus jest taki, że lista ta będzie przedstawiać nazwiska wybitnych biologów i epidemiologów, którzy zabłysnęli wiedzą podczas pandemii COVID-19 więc przy okazji nauczymy się czegoś konkretnego.

Otwierasz IDE, instalujesz Reacta, renderujesz paragrafy, każdy ma swojego onClicka, życie jest piękne. Niesety nie tym razem. W komentarzu w Jirze widzisz informację, że aplikacja ma być w całości stworzona przy pomocy czystego JS-a. No dobra, damy rade!

Udało nam sie stworzyć następujący kod

<div>
  <p>Edyta Górniak</p>
  <p>Ivan Komarenko</p>
  <p>Marcin Najman</p>
</div>
const allScientists = document.querySelectorAll("p");

allScientists.forEach((scientist) => {
  scientist.addEventListener("click", () => {
    scientist.style.color = "red";
  });
});

W HTML-u stworzyliśmy element div zawierający trzy paragrafy. Każdy z nich przedstawia imię i nazwisko naukowca.

Stworzony przez nas skrypt pobiera przy pomocy metody querySelectorAll "NodeListę" wszystkich paragrafów. Mapuje się po tej liście i każdemu z paragrafów przypisuje obslugę eventu click. Podczas kliknięcia w danego naukowca, jego godność zostanie przemalowana na czerwono. Wszystko działa i kolejne nazwiska zmieniają style wraz z kliknięciami myszki. Elegancko.

Jak to zazwyczaj bywa - można to zrobić lepiej. Jaki jest problem z powyższym rozwiązaniem? Najłatwiej doświadczyć problemu na własnej skórze. Powiedzmy, że wraz z rozwojem aplikacji pojawiło się nowe wymaganie. Chcemy stworzyć przycisk, który po kliknięciu doda kolejny element listy. Postarajmy się to zaimplementować.

<div class="wrapper">
  <p>Edyta Górniak</p>
  <p>Ivan Komarenko</p>
  <p>Marcin Najman</p>
</div>
<button class="button">Dodaj naukowca</button>
const allScientists = document.querySelectorAll("p");
const button = document.querySelector(".button");
const wrapper = document.querySelector(".wrapper");

const handleAddListItem = () => {
  const p = document.createElement("p");
  p.innerText = "Jerzy Zięba";
  wrapper.appendChild(p);
};
button.addEventListener("click", handleAddListItem);

allScientists.forEach((scientist) => {
  scientist.addEventListener("click", () => {
    scientist.style.color = "red";
  });
});

Pobraliśmy nasz nowy przycisk. Kliknięcie w niego wywołuje funkcję handleAddListItem. Funkcja ta tworzy nowy paragraf i w jego node tekstowy wpisuje Jerzego Ziębę. Ostatnim krokiem jest dodanie do wrappera naszego nowego paragrafu korzystając z metody appendChild.

Nowy button wydaje się działać. Kliknięcie powoduje dodanie Jerzego do listy. Task ogarnięty. Robisz PR-a, sam go sobie sprawdziłeś, mergujesz i przesuwasz go na Jirze do “QA Ready”. Po jakimś czasie odświeżasz swoją tablicę i widzisz, że QA zwrócił Twoje zadanie z powrotem do “InProgress” z informacją o niepoprawnej implementacji.

Odpalasz kod ponownie i zauważasz, że faktycznie kliknięcie i dodanie Jerzego (a nawet kilku!) działa, ale kliknięcie w niego nie powoduje zmiany kolory na czerwony, tak jak ma to miejsce w przypadku pierwszych trzech naukowców.

Dlaczego tak się stało?

Powodem jest to, że nie przypisaliśmy do paragrafu odpowiedniego event handlera. To jest duży minus tego typu rozwiązania. Jest mało elastyczne i dynamiczne dodawanie nowych elementów listy wymaga małej zmiany. Tak musiałaby wyglądać funkcja handleAddListItem żeby zagwarantować takie samo działanie jak w przypadku innych naukowców.

const handleAddListItem = () => {
  const p = document.createElement("p");
  p.addEventListener("click", () => {
    p.style.color = "red";
  });
  p.innerText = "Jerzy Zięba";
  wrapper.appendChild(p);
};

Jak już zapewne zauważyłeś sam - jest to dość paskudne powielenie kodu. Mamy już dwa miejsca, w których “doczepiamy” obsługę eventów do naszych paragrafów. Na szczęście istnieje bardziej eleganckie rozwiązanie (tak, to w końcu będzie event delegation :) ).

Zacznijmy od początku. W pierwszym kroku chcemy uzyskać efekt podobny do tego, który mieliśmy przed implementacją przycisku. Na ten moment chcemy tylko kolorować naukowca na czerwowno po kliknięciu . Tym razem wykorzystajmy mechanizm event delegation

<div class="wrapper">
  <p>Edyta Górniak</p>
  <p>Ivan Komarenko</p>
  <p>Marcin Najman</p>
</div>
const wrapper = document.querySelector(".wrapper");

const handleClick = (e) => {
  if (e.target.nodeName !== "P") return;
  e.target.style.color = "red";
};

wrapper.addEventListener("click", (e) => handleClick(e));

Powyżej udało nam sie uzyskać oczekiwany efekt. Kliknięcie powoduje dodanie dodatkowego stylu z kolorem czerwonym. Dochodzimy teraz do "clue" tego wpisu. Czym tak naprawdę jest event delegation? Tłumaczenie na Polski w tym przypadku jest całkiem pomocne w zrozumieniu idei.

Delegacja zdarzenia.

Czym jest delegacja? Każdy z korporacyjnym doświadczeniem wie, że zazwyczaj przełożony deleguje zadania do wykonania swoim podwładnym. Podobna sytuacja jest w przypadku delegowania obsługi zdarzeń w drzewie DOM.

Idea jest taka, że zamiast przypisywać wielu elementom dokładnie ten sam mechanizm obsługujący zdarzenie - przypisujemy to zadanie (delegujemy) do wspólnego przodka tych elementów.

Aby dokładniej zrozumieć zagadnienie - przeanalizujemy kod. Jak widzisz na powyższym snippecie, kodu jest znacznie mniej. Po pierwsze jedyne co pobieramy z drzewa DOM to referencja do wspólnego przodka paragrafów - czyli diva z klasą wrapper. Kolejny krok jest widoczny w ostatniej linijce snippetu. Do wrappera przypisujemy event listener “click” i powiązaną z nim funkcję handleClick. Oczywiście należy pamiętać o przekazaniu eventu (e) do tej funkcji.

Funkcja ta zobrazuje nam siłę płynącą z tego rozwiązania. Wykorzystujemy w niej przekazany uprzednio obiekt zdarzenia “e”. Cały mechanizm opiera się na tym jakich informacji ten obiekt nam dostarcza. Znajdziemy w nim między innymi informację, w jaki konkretnie element w drzewie DOM kliknęliśmy i na podstawie tego jesteśmy w stanie zbudować dalsze działanie skryptu.

Przeanalizujmy poniższą linijkę. To od niej zaczyna się działanie funkcji handleClick.

if (e.target.nodeName !== "P") return;

Chcemy sprawdzić czy kliknięty element jest tym, którego kolor jest modyfikowany. Jak do tego podejść? Obiekt e.target ma wiele informacji na temat klikniętego elementu. Jedną z nich jest property nodeName. Oznacza to, że jesteśmy w stanie odczytać w jaki konkretnie rodzaj elementu kliknęliśmy. Tak sie sklada, że nasi naukowcy mają element wspólny - są zamknięci w paragrafach. Możemy ten fakt wykorzystać.

W powyższym snippecie sprawdzamy czy klikniety element NIE JEST paragrafem. Jeśli nie jest - nie chcemy zrobić nic. Ma to sens bo faktycznie nie planujemy reagowac na taką sytuację. Interesują nas tylko paragrafy.

Przechodząc dalej wewnątrz naszej funkcji

e.target.style.color = "red";

obsługujemy przypadek “else” czyli sytuację, gdy jednak klikniemy w paragraf z naukowcem. W tym przypadku - do klikniętego elementu “e.target” przypisujemy nowy kolor. To wszystko. Skrypt działa i kliknięcie w naukowca znów zmienia jego kolor. Udało się to zrobić przy pomocy mechanizmu event delegation.

Na ten moment widzimy takie korzyści jak czytelność i zredukowana złóżoność kodu. Nie mamy żadnej pętli i przypisujemy tylko jeden event handler.

Kolejną zaletę zauważymy podczas implementacji naszego brakującego ficzera dodającego do listy Jerzego.

Zobaczmy jak będzie on wyglądał dzięki użyciu event delegation.

<div class="wrapper">
  <p>Edyta Górniak</p>
  <p>Ivan Komarenko</p>
  <p>Marcin Najman</p>
</div>
<button class="button">Dodaj</button>
const wrapper = document.querySelector(".wrapper");
const button = document.querySelector(".button");

const handleListItemClick = (e) => {
  if (e.target.nodeName !== "P") return;
  e.target.style.color = "red";
};

const handleButtonClick = () => {
  const p = document.createElement("p");
  p.innerText = "Jerzy Zięba";
  wrapper.appendChild(p);
};

button.addEventListener("click", handleButtonClick);
wrapper.addEventListener("click", (e) => handleListItemClick(e));

Podsumowanie

Gdy porównamy oba rozwiązania: z i bez event delegation to szybko zauważymy, że kod oparty o ten mechanizm jest bardziej elastyczny, odporny na błędy i łatwiejszy w rozwijaniu. Funkcja dodająca elementy po kliknięciu tworzy tylko nowy paragraf i dokleja go do wrappera w drzewie DOM. Dla porównania, ta sama funkcja bez event delegation musiała jeszcze przypisać event do nowo tworzonego elementu.

Jeśli byłeś na tyle dociekliwy, żeby w Twojej głowie pojawiło się pytanie “Jak to się dzieje, że kliknięcie w paragraf triggeruje event na elemencie div?” to gratuluję dociekliwości 🙂 Dzieje się tak za sprawą mechanizmu zwanego event bubbling. Nie jest to temat tego wpisu i zdecydowanie go poruszę w przyszłości ale dla zainteresowanych zostawiam kilka pojęć, które również mogą was zainteresować. Zdecydowanie warto je znać i są one mocno powiązane z tematem tego wpisu.

  • event propagation
  • event capturing
  • event bubbling
  • event target phase

Przydatne linki

javascript.info/event-delegation

stackoverflow.com/questions/1687296/what-is-dom-event-delegation

stackoverflow.com/questions/4616694/what-is-event-bubbling-and-capturing

javascript.info/bubbling-and-capturing