Events

Zdarzenia to czynności, które dzieją się 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.

Pokaż listę najczęściej używanych zdarzeń

DOMContentLoaded

Wspominałem to już w dziale o DOM, ale warto to sobie przypomnieć raz jeszcze.
Za chwilę zaczniemy podpinać zdarzenia do elementów na stronie. Żeby takie podpinanie mogło zadziałać, te elementy muszą być już dostępne dla skryptu.
Oznacza to, że zanim zaczniemy cokolwiek podpinać, musimy się upewnić, że został już wczytany html i zostało stworzone drzewo dokumentu.

Aby mieć pewność, że elementy już istnieją użyjemy jednej z trzech metod: wstawimy nasz skrypt na końcu strony (najlepiej tuż przed tagiem </body>), do naszego skryptu dodamy atrybut defer lub wykryjemy czy dokument został już w całości wczytany.

Jeżeli pracujemy sami nad naszą prywatną stroną mamy raczej kontrolę kto co kiedy odpala. Ale sytuacja zmienia się gdy piszemy skrypty, które będą używane przez innych, albo pracujemy w zespole. Dlatego właśnie zawsze dobrą praktyką jest sprawdzać, czy dany dokument jest już gotowy do użycia w JavaScript.

Aby wykryć czy dokument został wczytany, skorzystamy ze zdarzenia DOMContentLoaded:


document.addEventListener("DOMContentLoaded", () => {

    console.log("DOM został wczytany");
    console.log("Tutaj dopiero wyłapujemy elementy");

});

W bardzo wielu skryptach zamiast DOMContentLoaded używane jest zdarzenie load dla obiektu window. Jest to często błąd, wynikający z niewiedzy autora skryptu. Zdarzenie load dla window jest odpalany, gdy wszystkie elementy na stronie zostaną załadowane - nie tylko drzewo dom, ale także i grafiki. Bardzo często będzie to powodować mocno zauważalne opóźnienia, ponieważ dla przykładu nasze dynamicznie generowane za pomocą JavaScript menu odpali się dopiero po wczytaniu dużych grafik, które użyliśmy gdzieś poniżej na stronie.
Jeżeli więc twój skrypt ma tylko działać na elementach, a nie czekać na wczytanie całych grafik, zawsze używaj zdarzenia DOMContentLoaded.

Rejestracja 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ę rejestrującą, oraz trzeci opcjonalny argument, który służy do ustawiania dodatkowych opcji dla zdarzenia, takich jak propagacja. W większości przypadków trzeci parametr jest pomijany.


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

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

//lub za pomocą funkcji strzałkowej
btn.addEventListener("click", () => {
    console.log("Kliknąłem na element B");
});

Od tej pory po pojedynczym kliknięciu zostaną wywołane dwie funkcje.

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

W powyższym kodzie podpięliśmy funkcje anonimowe, co jest częstą praktyką. W przypadku odpinania zdarzeń nie możemy używać funkcji anonimowych, a zamiast nich musimy podpinać funkcję przez nazwę.


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

function elementClick() {
    console.log("Klikął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ąłeś na element ${counter} razy`;
    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ą z ś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 eventem.


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

Informacje takie będą związane z danym typem eventu. 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 eventu animationend będziemy mogli pobrać informacje o zakończonej animacji. Różne eventy przekazują nam po prostu różne informacje.

Dla poniższego inputa podpiąłem kilka eventów: 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ę eventy. 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 innej fazie, powinniśmy użyć trzeciego parametru. 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
});

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

Tak naprawdę tylko w wyjątkowych sytuacjach będziesz chciał zmienić zachowanie eventów, 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 karki, 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(); //you shall not pas!
    console.log("Kliknięto przycisk");
});

Istnieje też metoda stopImmediatePropagation(), która także blokuje wędrówkę eventu 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?

Zdarzenie możemy podpinać przy dodawaniu danego elementu:


addBtn.addEventListener("click", e => {
    counter++;
    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);
});

I to rozwiązanie zadziała - zresztą sprawdź tutaj

Problem z tym rozwiązaniem 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.

Możemy też rozwiązać ten problem w inny sposób. 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.classList.contains("button")) {
        console.log("Kliknąłeś w button");
    }
})

Aby się z tym uporać możemy zastosować minimum dwa podejścia (takie mi przychodzą do głowy).

Jednym z nich będzie funkcja closest():


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

    if (e.target.closest(".button")) {
        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łączy możliwość klikania na niej.

Dodatek: inne sposoby rejestrowania zdarzeń

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

Pierwsza z nich to rejestracja zdarzenia inline (jako atrybut elementu):


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

<body onload="pageLoaded()">
    ...
</body>

Zauważ, że nazwa zdarzenia poprzedzona jest przedrostkiem on. Tak więc nie mamy zdarzenia load, mouseover, click, keyup, tylko onload, onclick, onkeyup itp.

Ten sposób rejestrowania funkcji dla zdarzeń nie jest za bardzo zalecany z kilku powodów. Po pierwsze miesza warstwy logiki i danych czyli JavaScript z kodem HTML. 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 nie znajdzie swojego zastosowania.

Kolejna metoda podpinania funkcji nasłuchującej polega na podstawieniu jej pod odpowiednią właściwość danego obiektu.

Jeżeli wyłapiesz ze strony dowolny element i wypiszesz go w konsoli


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

Wśród wypisanych właściwości nich będzie wiele właściwości 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;

Problem z tym sposobem podpinania zdarzeń polega na tym, że po pierwsze do jednego elementu możemy podpiąć tylko jedną funkcję rejestrującą dla jednego rodzaju zdarzenia.


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

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

Wyobraź sobie, że na swoją stronę wrzucasz super slider, który po kliknięciu na strzałkę pięknie przewija zdjęcia. Ty jako programista chciałbyś, aby nie tylko zdjęcia się zmieniały, ale i dodatkowo zmianie ulegały teksty poniżej slidera. Strzałka ma jednak już podpiętą funkcjonalność, 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 spotkasz się z tutorialami na temat np. XMLHttpRequest, w których autorzy używają właśnie tego zapisu ze względu na jego krótszy zapis w stosunku do poniżej opisanego addEventListener:


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

Kod ten ani nie podpina pod jedną czynność wielu funkcji nasłuchujących, ani nie potrzebuje używać dodatkowych funkcjonalności jakie daje addEventListener. Może to być więc wystarczające rozwiązanie.

To było po pierwsze. Po drugie nie mamy tu jak sterować w której fazie zdarzenia nasza funkcja ma się odpalać. I znowu - jeżeli ci to nie potrzebne to można użyć.

Trening czyni mistrza

Jeżeli chcesz sobie potrenować zdobytą wiedzę z tego działu, to zadania znajdują się w repozytorium pod adresem: https://github.com/kartofelek007/zadania-dom

W repozytorium jest branch "solutions". Tam znajdziesz przykładowe rozwiązania.

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.

Menu