Events - tematy dodatkowe

Wywoływanie powszechnych zdarzeń

Istnieje kilka metod, które możemy wykorzystać do wywoływania danego zdarzenia na danych elementach:


element.click(); //kliknęliśmy w element
element.select(); //zaznaczamy element (tekst w inpucie)
element.focus(); //wybieramy element (jak za pomocą klawiatury)
element.blur(); //opuszczamy element

form.submit(); //wysyłamy formularz
form.reset(); //resetujemy formularz

const button = document.querySelector("button");
button.addEventListener("click", e => {
    console.log("klik!!!");
});

button.click();

Event

Poza powyższymi - nielicznymi - metodami możemy też skorzystać z konstruktora Event, który służy do tworzenia nowego zdarzenia.


const event = new Event("load");

Do konstruktora tego możemy przekazać dodatkowy obiekt, który pozwoli nam ustawić kilka dodatkowych cech danego zdarzenia:


const event = new Event("load", {
    "bubbles"    : false, //czy zdarzenie ma iść w górę dokumentu
    "cancelable" : false  //czy można je zatrzymać,
    "composed"   : false //czy dane zdarzenie będzie podążać z shadow DOM do DOM
});

Po utworzeniu takiego zdarzenia musimy je jeszcze wywołać za pomocą dispatchEvent():


const event = new Event("load");

const img = document.querySelector("img");
img.dispatchEvent(event);

Zdarzenia dla myszki, klawiatury

Istnieje też kilka wyspecjalizowanych konstruktorów, które służą do tworzenia zdarzeń konkretnego typu:

  • MouseEvent
  • KeyboardEvent
  • InputEvent
  • ...

Ich pełną listę znajdziesz na stronach https://developer.mozilla.org/en-US/docs/Web/API/UIEvent (w lewym menu na dole) i https://dom.spec.whatwg.org/#customevent.


const event = new MouseEvent("click", {
    bubbles: true,
    cancelable: true,
    clientX: 200,
    clientY: 200
});

console.log(event.clientX); // 200

Zdarzenia takie możesz oczywiście tworzyć przy pomocy konstruktora Event. Sęk w tym, że konstruktor ten służy raczej do tworzenia zdarzeń generycznych. Jeżeli podepniesz pod element funkcję nasłuchującą np. kliknięcie czy naciśnięcie klawisza i zbadasz sobie przekazane do zmiennej e dodatkowe informacje o danym zdarzeniu, zobaczysz, że różnią się one w zależności od typu zdarzenia.


const input = document.querySelector("#event-test");

input.addEventListener("mousedown", e => {
    console.log(e);
})
input.addEventListener("keyup", e => {
    console.log(e);
})

Jeżeli zależy ci na tych informacjach, powinieneś korzystać z odpowiednich dla danego typu zdarzenia konstruktorów. Jeżeli jednak twoim celem jest tylko wywołanie danego zdarzenia - spokojnie możesz użyć konstruktora Event.

Możesz to też sprawdzić klikając w poniższy przycisk. Po kliknięciu w niego utworzę dwa zdarzenia. Jedno za pomocą konstruktora MouseEvent, a drugie za pomocą Event:


const button = document.querySelector("#mouse-event-test");
const div = document.querySelector("#mouse-event-test-div");

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

button.addEventListener("click", e => {
    const eventA = new MouseEvent("click");
    const eventB = new Event("click");

    div.dispatchEvent(eventA);
    div.dispatchEvent(eventB);
});

CustomEvent i dodatkowe dane

Tak naprawdę nie musimy się ograniczać do zdarzeń, które już istnieją, ponieważ bez problemu możemy też tworzyć nasze własne. Możemy tutaj wykorzystać powyższy konstruktor Event:


document.addEventListener("mySuperEvent", e => {
    ...
});

const event = new Event("mySuperEvent");
document.dispatchEvent(event);

Jeżeli wraz ze zdarzeniem chcemy przesłać dodatkowe dane, powinniśmy skorzystać z konstruktora CustomEvent. Działa on podobnie do konstruktora Event, ale posiada dodatkową właściwość detail, która służy właśnie do przesyłania dodatkowych danych:


const event = new CustomEvent("loadComplete", {
    detail: {
        name : "Karol",
        number: 102
    },
    bubbles: true
});

document.addEventListener("loadComplete", e => {
    console.log(e.detail);
});

//odpalam dla img, a nasłuchuję w document
//stąd tworząc to zdarzenie użyłem właściwości bubbles: true (6 linia)
const img = document.querySelector("img");
img.dispatchEvent(event);

Technicznie rzecz biorąc nic nie stoi na przeszkodzie, by takie dane wysłać też wraz ze zdarzeniem stworzonym za pomocą konstruktora Event. Tworzony na bazie konstruktora obiekt zachowuje się tak samo jak inne obiekty które robiliśmy w poprzednich rozdziałach, a więc bez problemu możemy do niego dodawać właściwości:


document.addEventListener("somethingHappen", e => {
    console.log(e.detail);
    console.log(e.mySuperPet);
});

const event = new Event("somethingHappen");
event.detail = {name : "Karol"}
event.mySuperPet = {name : "Pies"}

document.dispatchEvent(event);

Problem z takim podejściem jest taki, że w ten sposób potencjalnie możemy nadpisać właściwość, która w normalnych warunkach istniała by w danym zdarzeniu (a któż to wie co będzie za tydzień). Stąd też gdy przesyłamy dodatkowe dane, zalecane jest skorzystać z CustomEvent.

Po co to wszystko?

My tu pitu pitu, a pojawia się najważniejsze pytanie. Po co mi to wszystko?

Do komunikacji. A ona jest najważniejsza - zwłaszcza międzyludzka.

Zdarzenia bardzo często są wykorzystywane nie tylko do reakcji na to co się dzieje na stronie, ale też jako mechanizm służący do przesyłania informacji między różnymi - oddzielnymi bytami na stronie.

Można tutaj wymienić dwa rodzaje takiej komunikacji. Obie powszechnie stosowane w najpopularniejszych frameworkach.

Po pierwsze komunikacja w górę, o której rozmawialiśmy w przypadku omawiania propagacji.

Przykładowo w jednym pliku będziesz miał klasę jednego komponentu, w drugim innego. Komponent będący dzieckiem będzie chciał poinformować komponent rodzica o jakiejś czynności, którą właśnie wykonało. Jak to zrobić? Odwołanie się do rodzica przez parentElement i odpalenie bezpośrednio funkcji nie wchodzi w grę, ponieważ parentElement wskazuje na element na stronie - nie obiekt zbudowany na klasie.
Sytuację taką możemy rozwiązać właśnie za pomocą propagacji:


class ParentComponent {
    constructor(selector) {
        this.element = document.querySelector(selector);
        this.communication();
    }

    showPassedData(data) {
        console.log(data);
    }

    communication() {
        this.element.addEventListener("loadData", e => {
            e.stopPropagation(); //nie chce by dane poszły wyżej
            this.showPassedData(e.detail);
        })
    }
}


class ChildComponent {
    constructor(selector) {
        this.element = document.querySelector(selector);
        this.communication();
    }

    communication() {
        this.element.addEventListener("click", e => {
            const event = new CustomEvent("loadData", {
                detail : {
                    data : "Przykładowe dane przekazane"
                },
                bubbles : true
            });
            this.element.dispatchEvent(event)
        })
    }
}


const childComponent = new ChildComponent(".child");
const parentComponent = new ParentComponent(".parent");

Działanie powyższego kodu możesz zobaczyć na przykładowej stronie.

Observer

Komunikację miedzy komponentami możemy realizować na wiele sposobów - zależnych od skomplikowania naszej aplikacji i naszej fantazji.

Wyobraź sobie, że robisz aplikację, która składa się z nastu oddzielnych komponentów. Jeden z tych komponentów wykonuje jakąś czynność (np. wczytuje dane, strzela z karabinu, porusza się, wpłaca dotację na kurs, zostaje kliknięty itp.), a reszta powinna być o tym poinformowana. Tylko jak to zrobić?

Jakiś czas temu podobne pytanie zadałem swojemu kursantowi. "Masz kilka oddzielnych bytów, które mają różne funkcje. Chcesz w pewnym momencie je wszystkie odpalić - jak byś to zrobił?".

"Bo ja wiem. Pewnie wrzucił bym je do jakiejś tablicy, a potem za pomocą pętli wszystkie je wywołał?".

W zasadzie trafił, bo właśnie na takim rozwiązaniu bazuje wzorzec Observer, którym poniżej się zajmiemy.

W najprostszej postaci rozwiązanie to mogło by mieć postać:


const ob1 = {
    show(data) {
        console.log("dane:", data);
    }
}

const ob2 = {
    print(data) {
        console.log("dane:", data);
    }
}


const subscribers = [];

subscribers.push(ob1.show);
subscribers.push(ob2.print);

//robię pętlę po tablicy i odpalam każdą zapisaną funkcję przekazując jej jakieś dane
subscribers.forEach(fn => {
    fn("jakieś przykładowe dane");
});

Powyższe mikro rozwiązanie sprawdziło by się w bardzo małym skrypcie. Zazwyczaj takich miejsc komunikacyjnych będzie naście. Jedne funkcje będziemy chcieli uruchamiać gdy coś się wczyta, inne funkcje gdy użytkownik coś zrobi itp. Dla nas oznacza to stworzenie kilku takich tablic, do których każdorazowo będziemy odkładać odpowiednie funkcje by potem je wywołać. Żeby za każdym razem nie powtarzać kodu, możemy stworzyć klasę, która da nam odpowiednie funkcjonalności.


class EventObserver {
    constructor() {
        this.subscribers = [];
    }

    on(fn) { //subskrypcja - dodawanie funkcji do tablicy
        this.subscribers.push(fn);
    }

    off(fn) { //usuwanie
        this.subscribers = this.subscribers.filter(el => el !== fn);
    }

    emit(data) { //wywoływanie wszystkich funkcji w tablicy
        this.subscribers.forEach(fn => fn(data));
    }
}

const observer = new EventObserver();


//komponent 1
const element1 = document.querySelector(".element1");
observer.on(data => {
    element1.innerHTML = data;
});


//komponent 2
const element2 = document.querySelector(".element2");
observer.on(data => {
    element2.innerHTML = data;
});


//komponent 3
//tu coś robię i w pewnym momencie informuję resztę
//setTimeout jest tylko przykładem
setTimeout(() => {
    const data = {number: 102};
    observer.emit(data);
}, 1000)

Przykładowe zastosowanie powyższego rozwiązania możesz podejrzeć tutaj i tutaj.

Jeżeli przeanalizujesz powyższy kod, zauważysz, że aby wyrejestrować daną funkcje z subskrypcji (czyli usunąć funkcję z danej tablicy), dla funkcji off() musimy podać referencję do usuwanej funkcji, co niby działa podobnie jak przy funkcji removeEventListener, ale nie zawsze jest wygodne.

W powyższym kodzie gdy subskrybowaliśmy za pomocą funkcji on() nowe funkcje, podaliśmy je jako funkcje anonimowe-strzałkowe - jest to zwyczajnie wygodne rozwiązanie. Oznacza to jednak, że podobnie do klasycznych zdarzeń na stronie nie jesteśmy w stanie takich funkcji wyrejestrować, ponieważ nie mamy do nich żadnej referencji. Albo więc takie funkcje będziemy pisać normalnie, a następnie w czasie rejestracji będziemy podawać ich nazwę (zupełnie jak przy addEventListener), albo nieco dopakujemy nasze rozwiązanie, dzięki czemu będziemy mogli usuwać i funkcje anonimowe:


class EventObserver {
    constructor() {
        this.subscribers = [];
        this.id = 0;
    }

    on(fn) {
        this.id++;
        this.subscribers.push({id: this.id, fn: fn});
        return this.id;
    }

    off(id) { //usuwanie
        this.subscribers = this.subscribers.filter(el => el.id !== id);
    }

    emit(data) { //wywoływanie wszystkich funkcji w tablicy
        this.subscribers.forEach(el => el.fn(data));
    }
}

const observer = new EventObserver();

//subskrybuje anonimowe funkcje
const subscribe1 = observer.on(data => {
    console.log("funkcja 1:", data);
})
const subscribe2 = observer.on(data => {
    console.log("funkcja 2:", data);
})

//wyłączam z subskrypcji tylko pierwszą funkcję
observer.off(subscribe1);

observer.emit("example data"); //funkcja 2: example data

Pojedynczy obiekt może informować inne byty o wielu swoich zdarzeniach. Wówczas możemy pokusić się o stworzenie dla niego kilku zmiennych, które następnie będziemy wykorzystywać w komunikacji. Rozwiązanie takie zwie się Sygnałami i stosowane jest np. w Godot - darmowym silniku do tworzenia gier (1, 2).

Przy okazji polecam spróbować Godota. Bo o podobnych sprawach jak omawiane tutaj można pisać i dumać godzinami, a prawda jest taka, że jak czegoś większego za ich pomocą nie stworzysz, to do końca tego nie poczujesz.


const ob = {
    onLoadStart : new EventObserver(),
    onLoadComplete : new EventObserver(),
    onSomething : new EventObserver(),

    loadData() {
        this.onLoadStart.emit();

        fetch("http://przykladowy-adres")
            .then(res => res.json())
            .then(res => {
                this.onLoadComplete.emit(res);
            })
    }
}

ob.lodaData();


//komponent 1
const element1 = document.querySelector(".element1");
ob.onLoadStart.on(data => {
    element1.classList.add("loading");
});

ob.onLoadComplete.on(data => {
    element1.classList.remove("is-loading");
    element2.innerHTML = data;
});


//komponent 2
const element2 = document.querySelector(".element2");
ob.onLoadStart.on(data => {
    element2.classList.add("loading");
});

ob.onLoadComplete.on(data => {
    element2.classList.remove("is-loading");
    element2.innerHTML = data;
    console.log(data);
});

Przykładowe zastosowanie możesz zobaczyć tutaj i tutaj.

Rozwiązanie to zastosujemy też w rozdziale, w którym tworzymy aplikację do rysowania.


Inną odmianą powyższych rozwiązań jest wzorzec Publish-Subscribers (w skrócie znany pub-sub). Zamiast oddzielnych zmiennych, tworzymy jeden obiekt, który zawiera zmienną - obiekt subscribers. Będziemy do niej dodawać nowe klucze (tematy), pod które trafią tablice z subskrybowanymi funkcjami.


const pubsub = {
    subscribers: {}, //obiekt z przyszłymi tablicami, które trafią pod odpowiednie klucze

    on(subject, fn) {
        if (this.subscribers[subject] === undefined) this.subscribers[subject] = [];
        this.subscribers[subject].push(fn);
    },

    off(subject, fn) {
        if (this.subscribers[subject] === undefined) return;
        this.subscribers[subject] = this.subscribers[subject].filter(el => el !== fn);
    },

    emit(subject, data) {
        if (this.subscribers[subject] === undefined) return;
        this.subscribers[subject].forEach(fn => fn(data));
    }
}

Rozwiązanie takie pozwoli nam subskrybować się pod dowolny temat, który będziemy podawać jako tekst:


//komponent1.js
//tworzę element, działam na nim, czaruję itp.
const element = "....";

pubsub.on("loadData", data => { //podpinam funkcję do tematu "loadData"
    element.show(); //gdy dane się wczytają pokaż mój element
});

pubsub.on("pageChange", data => { //podpinam funkcję pod inny temat
    element.something(); //gdy strona się zmieni coś tam zrób
});

pubsub.on("otherSomething", data => { //podpinam się pod coś innego
    element.remove(); //tu już nie wiem co robić to element usuwam...
});


//komponentLoad.js
//gdy dane się wczytają, wyemituj o tym informację,
const data = "...jakieś dane...";
pubsub.emit("loadData", data);

//komponent page.js
//to samo co powyżej - po prostu inny temat, których możemy mieć tyle ile nam pasuje
const pageData = {name : "pageName", url: "..."};
pubsub.emit("pageChange", pageData);

//komponent inny.js
pubsub.emit("otherSomething", {});

Zauważ, że bardzo podobnie działają klasyczne zdarzenia. Różnice są w nazwach funkcji. Zamiast naszego on mamy addEventListener. Zamiast off jest removeEventListener. Zamiast emit mamy dispatchEvent, a zamiast obiektu pubsub mamy document, lub inny element, który wybierzemy jako nasz "kolektor".

Podobnie też do powyższego kodu w przypadku zdarzeń na początku podpinamy funkcje pod wskazany temat ("click", "mouseover", "mojEvent", "lubiePlacki"), a następnie gdy dane zdarzenie się wykona, wszystkie te funkcje są odpalane.


//subskrybuję kilka funkcji pod "temat" click

//component1.js
//powyżej było on(), teraz mamy addEventListener()
document.addEventListener("click", e => {
    console.log(e.detail);
});

//component2.js
document.addEventListener("click", e => {
    console.log(e.detail);
});

//component3.js
//robię "emit" naszego tematu przy okazji przekazując jakieś dane
const event = new CustomEvent("click", {
    detail: {
        name : "Marcin",
        surname : "Nowak"
    }
});
document.dispatchEvent(event);

Można więc powiedzieć, że twórcy Javascript przygotowali dla nas gotowy wzorzec pubsub. Różnicą jest to, że zdarzenia mogą wędrować w górę dokumentu, co w przypadku naszego obiektu nie ma miejsca.

Ewentualnie - podobnie jak w poprzednich przypadkach możemy pokusić się o małą rozbudowę naszego obiektu tak byśmy w przyszłości mogli rejestrować funkcje anonimowe.


const pubsub = {
    id: 0,
    subscribers: {}, //obiekt z przyszłymi tablicami, które trafią pod odpowiednie klucze

    on(subject, fn) {
        this.id++;
        if (this.subscribers[subject] === undefined) this.subscribers[subject] = [];
        this.subscribers[subject].push({id: this.id, fn: fn});
        return this.id;
    },

    off(id) {
        for (let key of Object.keys(this.subscribers)) {
            this.subscribers[key] = this.subscribers[key].filter(el => el.id !== id);
        }
    },

    emit(subject, data) {
        if (this.subscribers[subject] === undefined) return;
        this.subscribers[subject].forEach(el => el.fn(data));
    }
}

//rejestruję funkcje pod dwa oddzielne tematy
const subscribe1 = pubsub.on("loadData", data => {
    console.log("funkcja 1:", data);
});
const subscribe2 = pubsub.on("loadData", data => {
    console.log("funkcja 2:", data);
});
const subscribe3 = pubsub.on("otherSubject", data => {
    console.log("funkcja 3:", data);
});
const subscribe4 = pubsub.on("otherSubject", data => {
    console.log("funkcja 4:", data);
});

//wyrejestrowuję dwie funkcje
pubsub.off(subscribe1);
pubsub.off(subscribe3);

//emituję dane
pubsub.emit("loadData", "example-data"); //funkcja 2: example-data
pubsub.emit("otherSubject", "example-data"); //funkcja 4: example-data

Kolejna odmiana

Jeżeli pisałeś kiedyś w Svelte, z pewnością natrafiłeś tam na podejście podobne do powyżej opisanego. Są nimi tak zwane storage. Ich działanie jest bardzo podobne do powyższego, z tą różnicą, że za manipulację schowkiem odpowiadają odpowiednie funkcje.

Pierwszą jest funkcja set(), która służy do ustawiania nowej wartości tylko wtedy gdy jest ona inna od obecnej w schowku.

Drugą jest update(). Służy ona do aktualizowania danej wartości.

Kolejna to subscribe(), bardzo podobna do funkcji on() z powyższych przykładów.

Przykładowa implementacja takiego rozwiązania może mieć postać:


//store.js
class Store {
    constructor(startData) {
        this.data = startData;
        this.functions = [];
    }

    set(data) {
        if (this.data !== data)
        this.data = data;
        this.functions.forEach(fn => fn(this.data))
    }

    update(fn) {
        this.set(fn(this.data))
        this.functions.forEach(fn => fn(this.data))
    }

    subscribe(fn) {
        this.functions.push(fn);
        fn(this.storeData);
        return () => this.#unsubscribe(fn);
    }

    #unsubscribe(fn) {
        this.functions = this.functions.filter(el => el !== fn);
    }
}

export function writable(data) {
    return new Store(data)
}

const store = writable({name : "Karol"});

const un1 = store.subscribe((data) => {
    console.warn("sub 1", data);
})

const un2 = store.subscribe((data) => {
    console.warn("sub 2", data);
})

const un3 = store.subscribe((data) => {
    console.warn("sub 3", data);
})

store.update((data) => {
    data = "lorem ipsum A"
    return data;
})

//w powyższych przykładach służyła do tego metoda off
//w Svelte metoda subscribe zwraca nam funkcję, która posłuży do wypisania
un2();

store.update((data) => {
    data = "lorem ipsum B"
    return data;
})

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.