Szczegółowa analiza zdarzeń JavaScript

preventDefault i stopPropagation: kiedy używać poszczególnych metod i do czego dokładnie one służą.

Event.stopPropagation() i Event.preventDefault()

Obsługa zdarzeń JavaScript jest często prosta. Jest to szczególnie istotne w przypadku prostej (względnie płaskiej) struktury HTML. Sprawy się komplikują, gdy zdarzenia toczą się (czyli rozchodzą się) przez hierarchię elementów. Zazwyczaj jest to moment, w którym deweloperzy sięgają po stopPropagation() lub preventDefault(), aby rozwiązać napotkane problemy. Jeśli myślisz sobie, że wypróbuję preventDefault(), a jeśli to nie zadziała, wypróbuję stopPropagation(), a jeśli nic nie da, wypróbuję oba – ten artykuł jest przeznaczony dla Ciebie. Wyjaśnię, do czego służy każda z metod i kiedy należy jej używać, a także przedstawię przykłady działania. Moim celem jest rozwiązanie tego problemu raz na zawsze.

Zanim przejdziemy zbyt szczegółowo do szczegółów, warto wspomnieć pokrótce o dwóch rodzajach obsługi zdarzeń możliwych w JavaScript (we wszystkich nowoczesnych przeglądarkach, czyli w Internet Explorerze w wersjach starszych niż 9, w ogóle nie obsługują rejestrowania zdarzeń).

Style WKKW (przechwytywanie i bubbing)

Wszystkie nowoczesne przeglądarki obsługują przechwytywanie zdarzeń, ale programiści bardzo rzadko z niego korzystają. Co ciekawe, była to jedyna forma obsługi zdarzeń, która początkowo była obsługiwana przez Netscape. Największy konkurent firmy Netscape, Microsoft Internet Explorer, w ogóle nie obsługiwał rejestrowania zdarzeń, a jedynie obsługiwał inny rodzaj zdarzeń, czyli dymki zdarzeń. Po utworzeniu W3C okazało się, że sprawdzają się one w obu stylach zdarzeń i zadeklarowały, że przeglądarki powinny obsługiwać oba za pomocą trzeciego parametru metody addEventListener. Początkowo parametr ten był tylko wartością logiczną, ale trzeci parametr we wszystkich nowoczesnych przeglądarkach obsługuje obiekt options. Możesz go użyć, aby określić, czy chcesz używać przechwytywania zdarzeń:

someElement.addEventListener('click', myClickHandler, { capture: true | false });

Pamiętaj, że obiekt options i jego właściwość capture są opcjonalne. Jeśli pominiesz którykolwiek z tych elementów, domyślna wartość parametru capture to false, co oznacza, że używane będzie dymki zdarzeń.

Rejestrowanie zdarzeń

Co to znaczy, że moduł obsługi zdarzeń „nasłuchuje w fazie przechwytywania”? Aby to zrozumieć, musimy wiedzieć, jak powstały zdarzenia i jak przebiegają. Ta zasada obowiązuje w przypadku wszystkich zdarzeń, nawet jeśli Ty jako deweloper nie korzystasz z nich, nie dbasz o nie i nie zastanawiasz się nad nimi.

Wszystkie zdarzenia rozpoczynają się w oknie, a najpierw przechodzą przez fazę rejestrowania. Oznacza to, że po wysłaniu zdarzenia rozpoczyna ono okno i przesuwa się „w dół” do elementu docelowego najpierw. Dzieje się tak nawet wtedy, gdy słuchasz tylko w fazie dymków. Przyjrzyjmy się przykładowym znacznikom i JavaScriptowi:

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('#C was clicked');
  },
  true,
);

Gdy użytkownik kliknie element #C, wysyłane jest zdarzenie pochodzące z window. Zdarzenie to zostanie rozpowszechnione w elementach podrzędnych w ten sposób:

window => document => <html> => <body> => itd., aż dotrze do celu.

Nie ma znaczenia, że nic nie wykrywa zdarzenia kliknięcia w elemencie window, document, <html> ani w elemencie <body> (ani w dowolnym innym elemencie na drodze do celu). Zdarzenie nadal ma miejsce w elemencie window i rozpoczyna swoją podróż zgodnie z opisem.

W naszym przykładzie zdarzenie kliknięcia przebiegnie dalej (jest to ważne słowo, ponieważ bezpośrednio wynika z działania metody stopPropagation() i omówimy je w dalszej części tego dokumentu) z window do jego elementu docelowego (w tym przypadku #C) na podstawie każdego elementu między window a #C.

Oznacza to, że zdarzenie kliknięcia rozpocznie się o window, a przeglądarka zada te pytania:

„Czy na etapie nagrywania jest coś, co wykrywa zdarzenie kliknięcia na urządzeniu window?”. Jeśli tak się stanie, uruchamiają się odpowiednie moduły obsługi zdarzeń. W naszym przykładzie nic nie jest, więc żadne moduły obsługi nie zostaną uruchomione.

Zdarzenie zostanie przeniesione do document, a przeglądarka zapyta: „Czy coś nasłuchuje zdarzenia kliknięcia na document w fazie przechwytywania?”. W takim przypadku uruchamiają się odpowiednie moduły obsługi zdarzeń.

Następnie zdarzenie przeniesie się do elementu <html>, a przeglądarka zapyta: „Czy na etapie przechwytywania jest jakiś element <html> wykrywany?”. Jeśli tak, uruchamiają się odpowiednie moduły obsługi zdarzeń.

Następnie zdarzenie przeniesie się do elementu <body>, a przeglądarka zapyta: „Czy na etapie przechwytywania jest jakiś element nasłuchiwania zdarzenia kliknięcia elementu <body>?”. W takim przypadku uruchamiają się odpowiednie moduły obsługi zdarzeń.

Następnie zdarzenie przeniesie się do elementu #A. Ponownie pojawi się pytanie: „Czy na etapie przechwytywania w #A jest wykrywane zdarzenie kliknięcia? Jeśli tak, zostaną uruchomione odpowiednie moduły obsługi zdarzeń.

Następnie zdarzenie przeniesie się do elementu #B (i zostanie zadane to samo pytanie).

Na koniec zdarzenie osiągnie swój cel i przeglądarka zapyta: „Czy coś nasłuchuje zdarzenia kliknięcia elementu #C w fazie przechwytywania?”. Tym razem odpowiedź brzmi: „tak!”. Ten krótki czas, w którym zdarzenie znajduje się w miejscu docelowym, jest nazywany „fazą docelową”. Uruchomi się wtedy moduł obsługi zdarzeń, a przeglądarka pobierze od konsoli.log informację, że kliknięto #C. To wszystko. Błąd! W ogóle nie jesteśmy skończeni. Proces trwa, ale teraz przechodzi w fazę dymków.

Dymki wydarzeń

Przeglądarka zapyta:

„Czy coś nasłuchuje zdarzeń kliknięcia w domenie #C w fazie dymków?”. Tu zwracaj szczególną uwagę. Wychwytywanie kliknięć (lub dowolnego typu zdarzenia) jest całkowicie możliwe w obu etapach przechwytywania oraz dymków. A jeśli moduły obsługi zdarzeń zostały połączone przewodowo w obu fazach (np. przez wywołanie .addEventListener() dwukrotnie, raz z użyciem capture = true i raz za pomocą capture = false), to oba moduły obsługi zdarzeń będą się bezwzględnie uruchamiać w przypadku tego samego elementu. Trzeba jednak pamiętać, że odpalają się w różnych fazach (przechwytywanie, a drugie w fazie dymki).

Następnie zdarzenie będzie propagowane (co częściej nazywa się „dymkiem”, ponieważ wydaje się, że zdarzenie przenosi się „w górę” drzewa DOM) do elementu nadrzędnego – #B, a przeglądarka pyta: „Czy coś nasłuchuje zdarzeń kliknięć w elemencie #B w fazie dymków?”. W naszym przykładzie nic nie jest, więc żadne moduły obsługi nie zostaną uruchomione.

Następnie wydarzenie pojawi się jako dymek do elementu #A, a przeglądarka zapyta: „Czy coś nasłuchuje zdarzeń kliknięć w fazie dymków w #A?”.

Następnie zdarzenie będzie wyświetlane jako dymek <body>: „Czy coś wykrywa zdarzenia kliknięcia w elemencie <body> w fazie dymków?”.

Następny element <html>: „Czy coś nasłuchuje zdarzeń kliknięcia elementu <html> w fazie dymków?

Następny element: document: „Czy coś nasłuchuje zdarzeń kliknięcia na ścieżce document w fazie dymków?”.

I ostatni element window: „Czy coś wykrywa zdarzenia kliknięcia w oknie w fazie dymków?”.

Uff... To była długa podróż, a nasze wydarzenie jest już prawdopodobnie bardzo zmęczone, ale wierzysz lub nie. Tak wygląda podróż przez każde wydarzenie. Najczęściej nie jest to zauważalne, ponieważ deweloperzy są zwykle zainteresowani tylko jedną lub drugą fazą zdarzenia (i zwykle jest to faza dymków).

Warto poświęcić trochę czasu na przechwytywanie zdarzeń i buforowanie zdarzeń oraz zapisywanie w konsoli niektórych nut w miarę uruchamiania modułów obsługi. Bardzo wnikliwe jest obserwowanie ścieżki, jaką zmierza zdarzenie. Oto przykład, w którym nasłuchuje się każdego elementu na obu fazach.

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in capturing phase');
  },
  true,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in capturing phase');
  },
  true,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in capturing phase');
  },
  true,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in capturing phase');
  },
  true,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in capturing phase');
  },
  true,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in capturing phase');
  },
  true,
);

document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in bubbling phase');
  },
  false,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in bubbling phase');
  },
  false,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in bubbling phase');
  },
  false,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in bubbling phase');
  },
  false,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in bubbling phase');
  },
  false,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in bubbling phase');
  },
  false,
);

Dane wyjściowe konsoli będą zależeć od klikniętego elementu. Jeśli klikniesz „najgłębszy” element drzewa DOM (element #C), zobaczysz, że każdy z tych modułów obsługi zdarzeń został uruchomiony. Jeśli użyjesz trochę stylów CSS, aby łatwiej było zauważyć, który element jest, w tym przypadku widać element wyjściowy konsoli (#C, także ze zrzutem ekranu):

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"
"click on <body> in bubbling phase"
"click on <html> in bubbling phase"
"click on document in bubbling phase"

Możesz wypróbować tę funkcję w prezentacji na żywo poniżej. Kliknij element #C i zobacz dane wyjściowe konsoli.

event.stopPropagation()

Wiedząc, skąd biorą się zdarzenia i jak przechodzą (tj. rozprowadzają się) w modelu DOM zarówno na etapie przechwytywania, jak i dyskusji, możemy zwrócić naszą uwagę na event.stopPropagation().

Metoda stopPropagation() może zostać wywołana (większość) natywnych zdarzeń DOM. Piszę „większość”, ponieważ istnieje kilka metod, w których wywołanie tej metody nie da żadnego rezultatu (ponieważ zdarzenie nie rozpoczyna się od jego rozpoczęcia). Do tej kategorii należą zdarzenia takie jak focus, blur, load, scroll i kilka innych. Możesz wywołać stopPropagation(), ale nie wydarzy się nic interesującego, ponieważ te zdarzenia nie są rozpowszechniane.

Ale co robi stopPropagation?

Działa prawie tak, jak mówi. Gdy je wywołasz, zdarzenie przestanie od tego momentu przechodzić do elementów, do których w przeciwnym razie się odwołuje. Dotyczy to obu kierunków (przechwytywania i kulowania dymków). Jeśli więc wywołasz stopPropagation() w dowolnym miejscu w fazie przechwytywania, zdarzenie nigdy nie dotrze do fazy docelowej lub fazy dymków. Jeśli nazwiesz go w fazie dymków, będzie on już przechodzić przez fazę zbierania, ale przestanie „się wznosić” od momentu, w którym go wywołano.

Wróćmy do tych samych przykładowych znaczników. Jak sądzisz, co by się stało, gdybyśmy na etapie rejestrowania w elemencie #B wywołalibyśmy funkcję stopPropagation()?

Efektem będzie taki wynik:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"

Możesz wypróbować tę funkcję w prezentacji na żywo poniżej. Kliknij element #C w prezentacji na żywo i sprawdź, jakie dane wyjściowe uzyskasz w konsoli.

A może zatrzymać propagację w #A na etapie dymków? Daje to następujący wynik:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

Możesz wypróbować tę funkcję w prezentacji na żywo poniżej. Kliknij element #C w prezentacji na żywo i sprawdź, jakie dane wyjściowe uzyskasz w konsoli.

Jeszcze jedno, dla zabawy. Co się stanie, jeśli wywołamy funkcję stopPropagation() w fazie docelowej dla elementu #C? Pamiętaj, że „faza docelowa” to nazwa nadana okresowi, w którym zdarzenie znajduje się w miejscu docelowym. Efektem będzie taki wynik:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"

Pamiętaj, że moduł obsługi zdarzeń dla obiektu #C, w którym logujemy „kliknięcie #C na etapie przechwytywania”, nadal działa, ale ten, w którym logujemy „kliknięcie #C w fazie dymków”, już nie. Ma to sens. Wywołaliśmy zdarzenie stopPropagation() od poprzedniego, więc właśnie to nastąpi.

Możesz wypróbować tę funkcję w prezentacji na żywo poniżej. Kliknij element #C w prezentacji na żywo i sprawdź, jakie dane wyjściowe uzyskasz w konsoli.

Zachęcamy do przetestowania każdej z tych prezentacji na żywo. Kliknij tylko element #A lub tylko element body. Spróbuj przewidzieć, co się stanie, i sprawdź, czy wszystko jest w porządku. Od tego momentu masz już dość precyzyjną prognozę.

event.stopImmediatePropagation()

Co to za dziwna i nie jest często używana metoda? Jest podobna do metody stopPropagation, ale nie zatrzymuje przesyłania zdarzenia do elementów podrzędnych (przechwytywanie) lub elementów nadrzędnych (dymki), ale ta metoda ma zastosowanie tylko wtedy, gdy do jednego elementu połączono więcej niż 1 moduł obsługi zdarzeń. Ponieważ addEventListener() obsługuje styl multiemisji zdarzeń, możliwe jest, że moduł obsługi zdarzeń można połączyć z jednym elementem więcej niż raz. W takim przypadku (w większości przeglądarek) moduły obsługi zdarzeń są wykonywane w kolejności, w jakiej zostały podłączone. Wywołanie stopImmediatePropagation() uniemożliwia uruchamianie kolejnych modułów obsługi. Na przykład:

<html>
  <body>
    <div id="A">I am the #A element</div>
  </body>
</html>
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run first!');
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run second!');
    e.stopImmediatePropagation();
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I would have run third, if not for stopImmediatePropagation');
  },
  false,
);

Wynikiem podanego wyżej przykładu będzie konsola:

"When #A is clicked, I shall run first!"
"When #A is clicked, I shall run second!"

Zauważ, że trzeci moduł obsługi zdarzeń nigdy nie działa, ponieważ wywołuje funkcję e.stopImmediatePropagation(). Gdybyśmy zamiast tego wywołali e.stopPropagation(), trzeci moduł obsługi nadal działał.

event.preventDefault()

Co wtedy robi preventDefault(), jeśli funkcja stopPropagation() uniemożliwia zdarzenie „w dół” (przechwytywanie) lub „w górę” (dymek)? Wygląda na to, że działa podobnie. Czy tak?

Raczej nie. Choć te dwie usługi często się mylą, tak naprawdę nie mają ze sobą nic wspólnego. Gdy zobaczysz „preventDefault()”, dodaj w głowie słowo „action”. myśl „powstrzymać domyślne działanie”.

O jakie domyślne działanie możesz zapytać? Odpowiedź nie jest jednak dość jednoznaczna, ponieważ w dużym stopniu zależy od kombinacji elementu i zdarzenia. Aby sprawy były jeszcze bardziej niejasne, czasami w ogóle nie ma domyślnego działania.

Zacznijmy od bardzo prostego przykładu. Co może się stać, gdy klikniesz link na stronie internetowej? Oczekujesz oczywiście, że przeglądarka otworzy adres URL podany w tym linku. W tym przypadku element jest tagiem kotwicy, a zdarzeniem jest zdarzeniem kliknięcia. Ta kombinacja (<a> + click) ma „działanie domyślne” polegające na przejściu do atrybutu href linku. A jeśli chcesz zapobiec wykonaniu tego domyślnego działania przez przeglądarkę? Załóżmy, że chcesz uniemożliwić przeglądarce otwieranie adresu URL określonego w atrybucie href elementu <a>. To właśnie preventDefault() zrobi za Ciebie. Przeanalizuj ten przykład:

<a id="avett" href="https://www.theavettbrothers.com/welcome">The Avett Brothers</a>
document.getElementById('avett').addEventListener(
  'click',
  function (e) {
    e.preventDefault();
    console.log('Maybe we should just play some of their music right here instead?');
  },
  false,
);

Możesz wypróbować tę funkcję w prezentacji na żywo poniżej. Kliknij link The Avett Brothers (Avett Brothers) i zobacz dane wyjściowe konsoli (oraz fakt, że nie nastąpi przekierowanie na stronę Avett Brothers).

Zwykle kliknięcie linku oznaczonego etykietą The Avett Brothers prowadzi do strony www.theavettbrothers.com. W tym przypadku umieściliśmy moduł obsługi zdarzeń kliknięcia w elemencie <a> i ustaliliśmy, że domyślne działanie powinno być zablokowane. Po kliknięciu tego linku użytkownik nie będzie w stanie nigdzie przechodzić. Zamiast tego konsola wyświetli komunikat: „Może powinniśmy zagrać tutaj jego muzykę?”.

Jakie inne kombinacje elementów i zdarzeń umożliwiają zablokowanie domyślnego działania? Nie potrafię ich wymienić, a czasem trzeba po prostu poeksperymentować. Oto kilka z nich:

  • Element <form> + zdarzenie „submit”: preventDefault() w przypadku tej kombinacji uniemożliwi przesłanie formularza. Jest to przydatne, jeśli chcesz przeprowadzić weryfikację, a jeśli coś się nie uda, możesz warunkowo wywołać metodę preventDefault, aby zatrzymać przesyłanie formularza.

  • Element <a> + zdarzenie „click”: preventDefault() w przypadku tej kombinacji uniemożliwia przeglądarce otwarcie adresu URL podanego w atrybucie href elementu <a>.

  • Zdarzenie document + „mousewheel”: preventDefault() dla tej kombinacji zapobiega przewijaniu strony kółkiem myszy (przewijanie z klawiatury nadal będzie działać).
    ↜ Wymaga to wywoływania funkcji addEventListener() z parametrem { passive: false }.

  • document + zdarzenie „keydown”: preventDefault() w tej kombinacji jest śmiertelne. Strona jest w dużej mierze bezużyteczna, uniemożliwiając przewijanie klawiatury, naciskanie kart i wyróżnianie klawiatury.

  • document + zdarzenie „mousedown”: preventDefault() w przypadku tej kombinacji zapobiega podświetleniu tekstu za pomocą myszy i innych domyślnych działań, które użytkownik wywołałby po naciśnięciu kursora myszy.

  • Element <input> + zdarzenie „keypress”: preventDefault() w przypadku tej kombinacji uniemożliwia znaki wpisywane przez użytkownika przed dotarciem do elementu wejściowego (ale nie rób tego – rzadko lub w ogóle występuje uzasadniony powód).

  • document + zdarzenie „contextmenu”: preventDefault() w przypadku tej kombinacji uniemożliwia wyświetlenie menu kontekstowego w natywnej przeglądarce po kliknięciu przez użytkownika prawym przyciskiem lub przytrzymaniu przycisku (albo w inny sposób, w jaki może pojawić się menu kontekstowe).

Ta lista nie jest wyczerpująca, ale mam nadzieję, że dzięki niej dowiesz się, jak korzystać z preventDefault().

Zabawny żart praktyczny?

Co się stanie, jeśli stopPropagation() oraz preventDefault() na etapie rejestrowania, zaczynając od dokumentu? Następuje zabawa! Ten fragment kodu wyrenderuje prawie wszystkie strony internetowe:

function preventEverything(e) {
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
}

document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });

Nie wiem, dlaczego mielibyście to robić (może tylko żartować z innej osoby), ale warto zastanowić się, co się tutaj dzieje i dlaczego tak się dzieje.

Wszystkie zdarzenia pochodzą z window, więc w tym fragmencie kodu zdarzenia click, keydown, mousedown, contextmenu i mousewheel zatrzymują się, zanim dotrą do jakichkolwiek elementów, które mogą je nasłuchiwać. Wywołujemy też metodę stopImmediatePropagation, aby umożliwić dostęp do dokumentu również wtedy, gdy ta funkcja zostanie użyta.

Pamiętaj, że atrybuty stopPropagation() i stopImmediatePropagation() to nie to, co powoduje, że strona jest bezużyteczna (przynajmniej w większości przypadków). Po prostu blokują wydarzenia, które w innym przypadku mogłyby się pojawić.

Jest też nazywane preventDefault(), co jak na pewno pamiętasz, uniemożliwia domyślne działanie. Dzięki temu wszystkie działania domyślne (takie jak przewijanie kółkiem myszy, przewijanie za pomocą klawiatury, podświetlanie lub naciskanie klawisza Tab, klikanie linków, wyświetlanie menu kontekstowego itd.) są blokowane i pozostawiają stronę w ogólnie bezużytecznym stanie.

Wersje demonstracyjne

Aby przejrzeć wszystkie przykłady z tego artykułu w jednym miejscu, obejrzyj poniższą prezentację.

Podziękowania

Baner powitalny od Toma Wilsona na kanale Unsplash.