Zdarzenia

Zdarzenia czyli różne rzeczy, które mogą się dziać w przeglądarce. Może je wywoływać użytkownik, ale i każdy element na stronie. Przykładowo klikając na przycisk na stronie, wywołujemy zdarzenie click. Wybierając za pomocą klawisza tab kolejny element w formularzu, wywołujemy zdarzenie focus. Opuszczając taki element, wywołujemy blur. Obrazek się wgrał? Wywołuje zdarzenie load. Formularz się wysyła? Wywoływane jest zdarzenie submit.

Jako programiści w większości przypadków będziemy chcieli reagować na moment gdy dane zdarzenie się zadzieje. Takich zdarzeń są setki. Nie musisz ich wszystkich wkuwać na pamięć, ponieważ na co dzień korzystamy tylko z kilku - ot obsługa myszki, klawiatury itp. Te bardziej popularne znajdziesz tutaj

Nasłuchiwanie zdarzeń

Aby zareagować na wykonanie zdarzenia, powinniśmy podpiąć dla danego elementu funkcję nasłuchującą, która zostanie odpalona w momencie wykonania danego zdarzenia. Służy do tego funkcja element.addEventListener().

Przyjmuje ona 3 wartości: typ zdarzenia, funkcję, która zostanie odpalona w momencie zaistnienia zdarzenia, oraz trzeci opcjonalny argument, na którym skupimy się nieco później.


//możemy podpiąć klasyczną funkcję anonimową
const btn = document.querySelector(".button");

btn.addEventListener("click", function() {
    console.log("Kliknąłem na element A");
});

//lub funkcję strzałkową
const btn = document.querySelector(".button");
btn.addEventListener("click", () => {
    console.log("Kliknąłem na element B");
});

//lub podpiąć funkcję poprzez referencję do niej
function showText() {
    console.log("Kliknąłem na element C");
}

const btn = document.querySelector(".button");
btn.addEventListener("click", showText);

Od tej pory po pojedynczym kliknięciu na nasz przycisk zostaną wywołane wszystkie funkcje.

Przy wielu elementach będziemy działać podobnie:


const buttons = document.querySelectorAll("button");
        
for (let btn of buttons) {
    btn.addEventListener("click", () => {
        console.log("Klik");
    })
}

Do wyrejestrowania (odpięcia) danej funkcji służy metoda element.removeEventListener(), która przyjmuje 2 parametry: nazwę zdarzenia i funkcję, którą chcemy wyrejestrować.

W powyższym kodzie podpięliśmy dwie funkcje anonimowe, co jest dość częstą praktyką. Niestety dla funkcji removeEventListener musimy podać funkcję, którą chcemy odpiąć. Dla funkcji anonimowych nie mam jak takiej funkcji podać, dlatego takich funkcji nie da się odpiąć.


const btn = document.querySelector(".button");

function elementClick() {
    console.log("Kliknąłem na element");
}

btn.addEventListener("click", elementClick);
btn.removeEventListener("click", elementClick);

const btn = document.querySelector(".button");
let counter = 0;

function elementClick() {
    counter++;
    btn.innerText = `Klik numer ${counter}`;
    if (counter >= 5) {
        btn.removeEventListener("click", elementClick);
    }
}

btn.addEventListener("click", elementClick);

Ten element

Bardzo często będziemy chcieli odwołać się z wnętrza funkcji do danego elementu, do którego podpięliśmy funkcję. Możemy to zrobić na kilka sposobów:


const btn = document.querySelector("button");

//w przypadku klasycznej funkcji możemy użyć zmiennej lub this
btn.addEventListener("click", function() {
    console.log(this) //button
    console.log(btn) //button

    this.innerText = "klik";
    btn.innerText = "klik";
})

//w przypadku funkcji strzałkowej this użyć nie możemy
btn.addEventListener("click", () => {
    console.log(this) //window
    console.log(btn) //button

    this.innerText = "klik"; //błąd
    btn.innerText = "klik";
});

function klik() {
    console.log(this);
}

const btn = document.querySelector("button");
btn.addEventListener("click", klik);

Jak widzisz this nie zawsze zadziała, ponieważ w przypadku funkcji strzałkowej będzie wskazywał na zupełnie inny obiekt (np. window). Stąd wydaje się, że użycie zmiennej do której podpiąłeś zdarzenie jest bezpieczniejsze. Warto tutaj pamiętać, że odwoływanie się przez taką zmienną ze środka funkcji zadziała w przypadku gdy używamy let/const, natomiast w przypadku starego kodu, gdzie używamy var, zazwyczaj this sprawdzało się lepiej (sytuacja taka sama jak opisywana tutaj).


function klik() {
    console.log(this);
}

const btn = document.querySelector("button");
btn.addEventListener("click", klik);

Tak naprawdę do danego elementu możemy też odwołać się na inne sposoby - poznasz je poniżej.

Wkraczamy w głąb zdarzenia

Podpinając daną funkcję nasłuchującą, możemy ustawić jej parametr (najczęściej nazywany event lub e, ale nazwa może być dowolna), do którego Javascript każdorazowo przekaże nam dodatkowy obiekt z informacjami związanymi z tym zdarzeniem.


element.addEventListener("click", e => {
    console.log(e);
});

Informacje takie będą związane z danym typem zdarzenia. Dla przykładu dla zdarzeń związanych z myszką będziemy mogli pobrać pozycję kursora, informacje który klawisz myszki został naciśnięty itp. Dla zdarzeń związanych z klawiszami pobierzemy który klawisz został naciśnięty, czy trzymaliśmy klawisz Shift i tak dalej. Dla zdarzenia animationend będziemy mogli pobrać informacje o zakończonej animacji. Różne zdarzenia przekazują nam po prostu różne informacje.

Dla poniższego inputa podpiąłem kilka zdarzeń: click, mouseover i keydown. Każdy z nich wypisuje informacje o sobie w konsoli:

Używanie tego parametru nawet w przypadkach, gdzie z niego nie korzystasz nie jest błędem. Nic nie tracisz, a zyskujesz jednolitość zapisu i zyskasz jeden znak 😉


testEvent.addEventListener("mouseover", () => { .... });

//vs

testEvent.addEventListener("mouseover", e => { ... });

Wstrzymanie domyślnej akcji

Niektóre elementy na stronie mają swoje domyślne akcje. Linki przenoszą w jakieś miejsca, formularze się wysyłają itp.

Aby zapobiec wykonaniu domyślnej akcji skorzystamy z metody e.preventDefault():


//jeżeli chcemy robić walidację formularzy
form.addEventListener("submit", e => {
    e.preventDefault();
    console.log("ten formularz się nie wyśle");
});

//jeżeli chcemy kombinować z tym co wpisuje użytkownik
input.addEventListener("keydown", e => {
    e.preventDefault();
    console.log("w ten input nic nie wpiszesz");
});

//jeżeli chcemy robić bajerancką nawigację
link.addEventListener("click", e => {
    e.preventDefault();
    console.log("Ten link nigdzie nie przeniesie.");
});

Zachowanie zdarzeń

Przeanalizujmy prosty przykład. Mamy dwa elementy, do których podpinamy nasłuchujące funkcje.
Do obydwu elementów podpinamy zdarzenie click.


<div class="parent">
    <button class="button">Kliknij mnie i sprawdź w konsoli</button>
</div>

const div = document.querySelector(".parent");
const btn = document.querySelector(".button");

div.addEventListener("click", e => {
    console.log("Kliknięto div");
});

btn.addEventListener("click", e => {
    console.log("Kliknięto przycisk");
});

Jeżeli teraz klikniemy na przycisk, w konsoli zostanie wyświetlony komunikat "Kliknięto przycisk". Równocześnie jednak zostanie wyświetlony komunikat dla div.

Wynika to z tego, jak zachowują się zdarzenia. Każde zdarzenie składa się z 3 faz:

  • faza capture - kiedy event podąża w dół drzewa (od window) do danego elementu
  • faza target - kiedy event dotrze do elementu, który wywołał to zdarzenie
  • faza bubbling - kiedy event pnie się w górę drzewa aż dotrze do window

fazy eventów

Standardowo zdarzenia domyślnie składają się z wszystkich trzech faz. Gdy wystąpi dane zdarzenia dla buttona, zaczyna ono biec od góry drzewa (faza capturing). Dociera do naszego buttona (faza target), a następnie wraca w gorę drzewa aż dotrze do obiektu window (faza bubbling).

Podczas takiej wędrówki gdy zdarzenie natrafi na element (np. któryś z rodziców, czy element, który został kliknięty), który ma podpiętą funkcję nasłuchującą dane zdarzenie, odpali ją.

Możesz to sprawdzić na poniższym przykładzie. Kliknij na Button i sprawdź w konsoli w jakiej kolejności oraz w jakiej fazie odpalane są funkcje nasłuchujące podpięte pod dane elementy:

Żeby nasze funkcje były odpalane w fazie capture, powinniśmy użyć trzeciego parametru zwanego useCapture. Domyślnie wynosi on false, co oznacza, że funkcje będą odpalane w fazie powrotnej.


btn.addEventListener("click", e => {...}, true); //capturing
btn.addEventListener("click", e => {...}); //bubbling

Jako trzeci parametr możemy też przekazać obiekt, z kilkoma właściwościami:


element.addEventListener("click", doSomething, {
    capture: false, //czy używać fazy capture
    once: true, //po pierwszym odpaleniu nasłuchiwanie zostanie usunięte - czyli dane nasłuchiwanie zadziała tylko 1x
    passive: false //jeżeli true, funkcja nigdy nie odpali preventDefault() nawet jeżeli podano je w funkcji
    signal : pozwala dodać obiekt typu AbortController, dzięki któremu dana funkcja zostanie automatycznie usunięta w momencie wywołania funkcji abort()
});

Kliknij w poniższy przycisk i sprawdź w konsoli kolejność zdarzeń.

Tak naprawdę tylko w wyjątkowych sytuacjach będziesz chciał zmienić zachowanie zdarzeń na stronie, dlatego w większości przypadków trzeci parametr możemy pominąć.

Możliwe, że w tym momencie możesz być nieco zmieszany. Zamiast po prostu odpalić event na danym elemencie, dodatkowo lata on po rodzicach. Znowu jakieś udziwnienia?
Osobiście lubię przyrównywać to do narysowanego w zeszycie przycisku. Gdy palcem naciśniemy na taki rysunek, przyciśniemy i rysunek, ale i kartkę na którym został narysowany, a i wszystkie kartki, które leżą poniżej.

Przykład z naciśnięciem przycisku

Zatrzymanie propagacji

Aby przerwać powyższą wędrówkę (propagację), skorzystamy z metody e.stopPropagation(). Użyta wewnątrz danej funkcji nasłuchującej przerywa dalszą wędrówkę:


const div = document.querySelector(".parent");
const btn = document.querySelector(".parent .button");

div.addEventListener("click", e => {
    console.log("Kliknięto div");
});

btn.addEventListener("click", e => {
    e.stopPropagation();
    console.log("Kliknięto przycisk");
});

Istnieje też metoda stopImmediatePropagation(), która także blokuje wędrówkę zdarzenia danego typu. Różni się ona od stopPropagation() tym, że poza blokadą wędrówki dodatkowo zatrzyma dla danego elementu wywołanie reszty funkcji nasłuchujących dany rodzaj zdarzenia.

Porównajmy więc 2 przykłady. Pierwszy z nich będzie korzystał z stopPropagation, a drugi z stopImmediatePropagation.

W przykładzie poniżej element .grand-parent ma włączone nasłuchiwanie click. Element .parent ma podpięte 3 nasłuchiwania click. W pierwszym z nich odpala e.stopPropagation().


divGrandfather.addEventListener("click", e => {
    console.log("Kliknąłeś na grandfather");
});

divParent.addEventListener("click", e => {
    e.stopPropagation();
    console.log("Kliknąłeś na parent A");
});

divParent.addEventListener("click", e => {
    console.log("Kliknąłeś na parent B");
});

divParent.addEventListener("click", e => {
    console.log("Kliknąłeś na parent C");
});

button.addEventListener("click", e => {
    console.log("Kliknąłeś na button");
});

I wersja z stopImmediatePropagation:


divG.addEventListener("click", e => {
    console.log("Kliknąłeś na grandfather");
});

divP.addEventListener("click", e => {
    e.stopImmediatePropagation();
    console.log("Kliknąłeś na parent A");
});

divP.addEventListener("click", e => {
    console.log("Kliknąłeś na parent B");
});

divP.addEventListener("click", e => {
    console.log("Kliknąłeś na parent C");
});

button.addEventListener("click", e => {
    console.log("Kliknąłeś na button");
});

Element nasłuchujący i ten, na którym odpalono zdarzenie

Jak widziałeś powyżej element który nasłuchuje zdarzenie wcale nie musi być tym, na którym dane zdarzenie zostało wywołane. Możemy dla przykładu nasłuchiwać zdarzenia click dla dokumentu, a zostanie ono odpalone na buttonie, który jest w tym dokumencie.

Właściwość e.target wskazuje na element, na którym dane zdarzenie się wydarzyło (czyli nastąpiła faza target), a właściwość e.currentTarget na element, do którego podpięliśmy funkcję nasłuchującą dane zdarzenie.


<div class="parent" id="parentTarget">
    <button class="button" type="button">Test targeta</button>
</div>

const parent = document.querySelector(".parent");
parent.addEventListener("click", e => {
    console.log("e.target: ", e.target);
    console.log("e.currentTarget: ", e.currentTarget, parent);
})

Problem ze zdarzeniami i dynamicznymi elementami

Wyobraź sobie, że mamy listę elementów, które są do niej wstawiane dynamicznie:


<div class="elements-list">
    <!-- tutaj trafią nowe elementy -->
</div>

<div class="add-element-bar">
    <button type="button" class="btn add-element">
        Dodaj element
    </button>
</div>

let counter = 0;
const addButton = document.querySelector(".add-element");
const list = document.querySelector(".elements-list");

addButton.addEventListener("click", e => {
    counter++;

    //tworzę element
    const el = document.createElement("div");
    el.classList.add("element");
    el.innerText = "To jest element " + counter;

    //dodaje element do listy
    list.appendChild(el);
});

Teraz chcielibyśmy do każdego takiego elementu dodać przycisk usuwania, który po kliknięciu usunie taki element:


addButton.addEventListener("click", e => {
    ...

    const del = document.createElement("button");
    del.innerText = "Usuń";
    del.classList.add("delete");
    el.appendChild(del);

    ...
});

Podepnijmy pod niego zdarzenie click, które usunie element:


const addButton = document.querySelector(".add-element");
const list = document.querySelector(".elements-list");

addButton.addEventListener("click", e => {
    ...
});

const deleteButtons = document.querySelectorAll(".delete");

for (const el of deleteButtons) {
    el.addEventListener("click", e => {
        e.target.parentElement.remove();
    });
}

Sprawdź działanie powyższego kodu

Dodajemy nowy element, klikamy na przycisk usuń i co? I nie działa.

Kod na 100% jest poprawny, błędów w konsoli nie ma. Więc czemu to coś nie działa?

Zwróć uwagę, kiedy i jak podpinamy funkcje nasłuchujące dla przycisków .delete

Za pomocą querySelectorAll pobieramy wszystkie przyciski .delete i podpinamy im zdarzenie.

Ale zaraz, zaraz - w momencie użycia querySelectorAll nie ma jeszcze żadnych przycisków .delete, ponieważ dodajemy je dynamicznie.

Jak rozwiązać taki problem?

Pierwsze rozwiązanie to podpięcie zdarzenia po stworzeniu elementu:


addBtn.addEventListener("click", e => {
    counter++;

    //tworzę element
    const el = document.createElement("div");
    el.classList.add("element");
    el.innerText = "To jest element " + counter;

    const del = document.createElement("button");
    del.innerText = "Usuń";
    del.classList.add("delete");
    del.addEventListener("click", e => {
        del.closest(".element").remove();
    });
    el.appendChild(del);

    list.appendChild(el);
});

To rozwiązanie jest całkowicie poprawne i działające, zresztą sprawdź tutaj

Problem z nim jest jeden. Jeżeli będziemy mieli milion takich elementów, to podepniemy milion funkcji nasłuchujących. W dzisiejszych czasach nie jest to jakiś olbrzymi problem, ale jednak.

Zamiast powyższego, możemy do tego problemu podejść nieco inaczej. Zamiast podpinać się bezpośrednio pod dane elementy, podepniemy się pod jakiegoś rodzica (w naszym przypadku może to być element .list, body, document itp) i za pomocą e.target wewnątrz funkcji będziemy sprawdzać jaki element został kliknięty:


list.addEventListener("click", e => {
    //e.target - ten który kliknęliśmy
    //e.currentTarget - ten któremu podpięliśmy addEventListener (czyli list)

    //sprawdzam czy kliknięty element jest przyciskiem i ma klasę .delete
    if (e.target.classList.contains("delete")) {
        e.target.closest(".element").remove();
    }
});

Dzięki temu ograniczyliśmy liczbę funkcji nasłuchujących do jednej. Zyskaliśmy także to, że nasze zdarzenie zadziała dla elementów, które dopiero zostaną dodane.

To co - sprawdzamy?


Jak więc widzisz użycie propagacji niesie z sobą duże korzyści. Oszczędzamy pamięć, nie trzeba martwić się o to, czy elementy istnieją czy dopiero powstaną - same plusy.

Niestety - jak to w życiu bywa - nie zawsze będzie to takie proste. W powyższym kodzie sprawdzam, czy e.target wskazuje na przycisk i ma klasę .delete. Problem pojawi się, gdy w takim przycisku będzie jakaś dodatkowa ikonka - FontAwesome czy w formacie svg, a użytkownik klikając w przycisk kliknie właśnie w tą ikonkę. W takim przypadku e.target nie będzie wskazywał na button, a ikonkę.


const parent = document.querySelector(".btn-parent");
parent.addEventListener("click", e => {
    console.log("e.target: ", e.target);

    if (e.target.tagName === "BUTTON") {
        console.log("Kliknąłeś w button");
    }
})

Aby się z tym uporać możemy zastosować dwa podejścia.

Jednym z nich będzie funkcja closest():


const cnt = document.querySelector(".btn-parent");
cnt.addEventListener("click", e => {
    const btn = e.target.closest("button");
    if (btn) {
        console.log("Kliknąłeś w button");
    }
})

Innym rozwiązaniem może być zastosowanie dla ikony właściwości CSS pointer-events: none, która wyłącza rejestrowanie wskaźnika (w tym myszy) dla elementu, dla którego została użyta.

Inne sposoby rejestrowania zdarzeń

Poza opisaną powyżej metodą addEventListener() istnieją też inne sposoby podpięcia zdarzeń do elementów na stronie.

Wyłap ze strony dowolny element i wypisz go w konsoli za pomocą console.dir:


const h2 = document.querySelector("h2");
console.dir(h2);

Wśród wypisanych właściwości będzie wiele zaczynających się od on - np. onclick, onmouseover, onload itp. Bez zaskoczenia służą one właśnie do podpinania funkcji nasłuchujących.


const element = document.querySelector("#button");

element.onclick = function() {
    console.log("Kliknięto element");
}

element.onmouseover = function() {
    console.log("Najechano na przycisk");
}

Aby usunąć wcześniej przypisane w ten sposób zdarzenie, wystarczy pod daną właściwość podstawić null:


element.onclick = null;

Jest to prosta, szybka metoda podpięcia zdarzenia pod element. Problem z tym sposobem polega na tym, że do jednego elementu dla pojedynczego zdarzenia możemy podpiąć tylko jedną funkcję:


element.onclick = function() {
    console.log("Kliknięto element A");
}

//nadpisałem powyższe
element.onclick = function() {
    console.log("Kliknięto element B");
}

I teraz wyobraź sobie, że właśnie ściągnąłeś na swoją stronę zewnętrzny skrypt z super sliderem. Po kliknięciu na boczne strzałeczki slider odpala funkcjonalność, która zmienia slajdy. Ty jako programista chciałbyś, by po kliknięciu nie tylko slajdy się zmieniały, ale i dodatkowo zmianie ulegał dodatkowo tekst opisujący. Strzałka ma podpiętą funkcjonalność zmiany slajdu, a ty nie możesz edytować podpiętej funkcji, ponieważ oznaczało by to dłubanie w zewnętrznym, często zminimalizowanym kodzie.

Nie zawsze będzie to jednak problemem. Dość często możesz założyć, że nie będziesz nigdy chciał podpinać dodatkowych funkcjonalności dla danego elementu. Wtedy śmiało możesz użyć opisywanej metody:


const xhr = new XMLHttpRequest();
xhr.onload = function() { ... }
xhr.onerror = function() { ... }
xhr.send(null);

const form = document.querySelector("form");
form.onsubmit = function() { ... }

Druga metoda to wykonywanie kodu, który zapisujemy inline jako wartość atrybutu danego elementu:


function pageLoaded() {
    console.log("Wczytano stronę");
}

function otherFn() {
    console.log("inny komunikat");
}

<body onload="pageLoaded()">

    <a
        href="jakasStrona.html"
        onclick="alert('Kliknąłeś')"
        onmouseover="console.log('najechałeś kursorem')"
    > kliknij </a>

    <button onclick="console.log('kliknąłeś'); otherFn();">
        klik
    </button>

</body>

Ten sposób rejestrowania funkcji zazwyczaj nie jest zalecany z kilku prostych powodów.

Po pierwsze miesza JavaScript z kodem HTML. Przy jednym elemencie da się żyć. Spróbuj jednak wyobrazić sobie, że takich elementów masz bardzo dużo, a każdy z nich musi reagować na różne zdarzenia. W wyniku dostaniesz nawet nie zupę takową co prawdziwy bigos.

Po drugie pisząc w ten sposób kod działamy na zmiennych globalnych, co w większości sytuacji będzie zwyczajnie kiepskie.

Po trzecie jest bardziej podatny na ataki XSS, ponieważ nie można tutaj stosować Content Security Policy.

Przy czym nie oznacza to, że sposób ten nie znajduje swojego zastosowania.

Trening czyni mistrza

Jeżeli chcesz sobie potrenować zdobytą wiedzę, zadania znajdują się w repozytorium pod adresem: https://github.com/kartofelek007/zadania

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.