DOM ja tapahtumat
WWW-sivu ja selainympäristö ovat täynnä monenlaisia tapahtumia (events), joihin sivun ja sillä mahdollisesti toimivan javascript-sovelluksen täytyy reagoida. Joudumme opettelemaan tapahtumapohjaista ohjelmointia. Sovelluksen suorittaminen ei enää tapahdukaan synkronisesti järjestyksessä vaan asynkronisesti: kirjoitat funktioita, joita selain kutsuu silloin kun jotain tapahtuu.
Käy läpi Introduction to events -dokumentti.
Tämä sivu jatkaa siitä, mihin DOM-luento jäi. Elementtien etsiminen ja käsittely oletetaan tunnetuiksi.
Skriptien lataaminen
WWW-sivuun liittyy tyypillisesti useita eri osia: varsinainen html-dokumentti, css-tiedostoja, kuvia, videoita, skriptejä ja niin edelleen. Selain lataa näitä osia yleensä rinnakkain. Poikkeuksena ovat skriptit, jotka oletuksena ladataan ja suoritetaan synkronisesti: koko skripti ladataan ja suoritetaan, ja vasta sitten siirrytään dokumentissa eteenpäin. Synkronisesti latautuva skripti siis pysäyttää suorituksensa ajaksi kaiken muun.
Käytössä on neljä vaihtoehtoa. Attribuutit kirjoitetaan XHTML:ssä muodossa defer="defer", koska XML:ssä ei ole totuusarvoattribuutteja.
1. Synkroninen lataus (oletus)
<script src="skripti.js"></script>
Blokkaa html-dokumentin lataamisen ja DOM-puun muodostamisen, kunnes skripti on suoritettu. Jos tämä script-elementti on dokumentin head-osassa, koodissa ei voi käsitellä mitään body-osassa olevaa, koska DOM-puu ei ole vielä valmis.
2. async
<script src="skripti.js" async="async"></script>
Ladataan rinnakkain dokumentin muiden osien kanssa, mutta suorituksen ajaksi blokataan kuten edellä. Suoritusjärjestystä ei voi ennalta tietää, koska se riippuu siitä, missä järjestyksessä skriptit latautuvat.
3. defer
<script src="skripti.js" defer="defer"></script>
Ladataan rinnakkain, mutta suoritetaan vasta kun DOM-puu on valmis, juuri ennen DOMContentLoaded-tapahtumaa. Skriptit suoritetaan siinä järjestyksessä kuin ne esiintyvät HTML-koodissa. Tämä on useimmiten oikea valinta.
4. Moduuli
<script src="skripti.js" type="module"></script>
Moduuliskripti käyttäytyy oletusarvoisesti kuten defer: se suoritetaan vasta kun dokumentti on jäsennetty, ja järjestys säilyy. defer-attribuutilla ei ole moduuliin mitään vaikutusta.
Varo tätä: jos moduuliin lisää async-attribuutin, se käyttäytyykin kuten kohta 2 — suoritus tapahtuu heti latauksen valmistuttua eikä järjestystä voi ennustaa.
<script src="skripti.js" type="module" async="async"></script>
Kurssilla tähän tulee kuitenkin mutka: sivut tarjoillaan XHTML-muodossa, eivätkä Chromium-pohjaiset selaimet lataa moduuliskriptejä lainkaan XHTML-tilassa ilman async-attribuuttia. Kurssin XHTML-sivuilla se on siis pakko lisätä — ja silloin on tiedostettava, että järjestystakuu menetetään. Jos moduuleja on useita ja järjestys on tärkeä, hoida riippuvuudet import-lauseilla yhden sisäänkäyntitiedoston kautta.
Tapahtumankäsittelijän asettaminen
Tapahtumankäsittelijä asetetaan aina addEventListener-metodilla. Muita tapoja — kuten element.onclick = ... tai HTML:n onclick-attribuuttia — ei pidä käyttää. addEventListener varmistaa, ettei mahdollisia muita saman tapahtuman käsittelijöitä ylimääritellä, ja mahdollistaa tietyn käsittelijän poistamisen.
Parametreina annetaan tapahtuman tyyppi merkkijonona ja osoitin tapahtumaa käsittelevään funktioon. Huomaa, ettei funktion nimen perään tule sulkeita: omakoodi on osoitin funktioon, omakoodi() on funktiokutsu.
// nimetty funktio (suositeltavin: tämän voi myös poistaa myöhemmin)
function omakoodi(e) {
console.log("käsittelijä suoritettiin", e.type);
}
window.addEventListener("load", omakoodi);
// anonyymi funktio
window.addEventListener("load", function (e) {
console.log("toinen käsittelijä");
});
// nuolifunktio — huomaa this-varaus alempana
window.addEventListener("load", (e) => {
console.log("kolmas käsittelijä");
});
Kolmas parametri: asetukset
Kolmantena parametrina voi antaa asetusobjektin. Nämä kolme ovat hyödyllisimmät:
// suoritetaan vain kerran, minkä jälkeen käsittelijä poistuu itsestään
nappi.addEventListener("click", omakoodi, { once: true });
// capture-vaiheessa, katso luku Tapahtuman eteneminen DOM-puussa
elementti.addEventListener("click", omakoodi, { capture: true });
// lupaus, ettei käsittelijä kutsu preventDefault-metodia:
// selain voi vierittää sivua odottamatta koodin suorittamista
document.addEventListener("scroll", omakoodi, { passive: true });
Vanhemmissa esimerkeissä näkee kolmantena parametrina pelkän totuusarvon true, joka tarkoittaa samaa kuin { capture: true }.
Käsittelijän poistaminen
Käsittelijän voi poistaa removeEventListener-metodilla. Poistaminen onnistuu vain, jos annat täsmälleen saman funktio-osoittimen ja samat capture-asetukset. Anonyymia funktiota ei voi poistaa, koska siihen ei ole viitettä.
window.removeEventListener("load", omakoodi);
Jos poistettavia käsittelijöitä on useita, kätevin tapa on AbortController: yksi abort()-kutsu poistaa kaikki samaan signaaliin liitetyt käsittelijät kerralla.
const ohjain = new AbortController();
nappi.addEventListener("click", omakoodi, { signal: ohjain.signal });
kentta.addEventListener("input", toinenKoodi, { signal: ohjain.signal });
ohjain.abort(); // molemmat käsittelijät poistuvat
Kts. Matching event listeners for removal.
Event-objekti
Tapahtumankäsittelijä saa aina parametrinaan Event-tyyppisen objektin, jolta saa lisätietoja tapahtumasta.
| Ominaisuus tai metodi | Merkitys |
|---|---|
| target | elementti, jolle tapahtuma alun perin tapahtui |
| currentTarget | elementti, jonka käsittelijää juuri nyt suoritetaan |
| type | tapahtuman tyyppi merkkijonona, esim. "click" |
| bubbles | kupliiko tapahtuma vai ei |
| eventPhase | vaihe: 1 = capture, 2 = kohde, 3 = bubbling |
| defaultPrevented | onko oletustoiminta jo estetty |
| isTrusted | true jos tapahtuman aiheutti käyttäjä, false jos koodi |
| preventDefault() | peruu tapahtuman oletustoiminnan |
| stopPropagation() | estää tapahtuman etenemisen seuraaviin elementteihin |
| stopImmediatePropagation() | sama, mutta estää myös saman elementin muut käsittelijät |
Tapahtumatyypeillä on omat tarkemmat luokkansa, joilla on lisää ominaisuuksia. MouseEvent tarjoaa mm. clientX, clientY, button, ctrlKey ja shiftKey, ja KeyboardEvent mm. key, code ja repeat.
kentta.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
console.log("Enter painettu");
}
if (e.key === "Escape") {
kentta.value = "";
}
});
Käytä e.key-ominaisuutta äläkä vanhentunutta keyCode-numeroa. key kertoo, mikä merkki syntyi; code kertoo, mikä fyysinen näppäin painettiin näppäinasettelusta riippumatta.
target vai currentTarget?
Ero on tapahtumankäsittelyn tärkein yksittäinen asia. target on se, mihin osuttiin. currentTarget on se, joka käsittelee. Kun listalle on asetettu yksi käsittelijä ja käyttäjä klikkaa listan riviä, target on rivi ja currentTarget on lista.
Älä käytä originalTarget-ominaisuutta. Se on Firefox-kohtainen laajennus, joka ei kuulu standardiin, ja muissa selaimissa sen arvo on undefined.
this ja nuolifunktiot
Tavallisessa funktiossa this viittaa käsittelijässä samaan elementtiin kuin currentTarget. Nuolifunktiossa se ei viittaa, koska nuolifunktiolla ei ole omaa this-sidontaa:
nappi.addEventListener("click", function (e) {
console.log(this === e.currentTarget); // true
});
nappi.addEventListener("click", (e) => {
console.log(this === e.currentTarget); // false — this on jotain muuta
});
Käytä siis aina e.currentTarget-ominaisuutta äläkä this-viitettä. Silloin käsittelijän voi kirjoittaa kummalla tavalla tahansa ilman että mikään hajoaa.
load ja DOMContentLoaded
Ensimmäinen javascript-ohjelmoijan käsittelemä tapahtuma on yleensä load- tai DOMContentLoaded-tapahtuma. Kummallakin varmistetaan, ettei omaa koodia suoriteta ennen kuin käsiteltävät elementit ovat olemassa.
| Tapahtuma | Laukeaa kun | Käyttö |
|---|---|---|
| DOMContentLoaded | HTML on jäsennetty ja DOM-puu valmis | lähes aina tämä |
| load | myös kuvat, tyylit ja muut resurssit ladattu | kun tarvitset kuvien mitat tms. |
document.addEventListener("DOMContentLoaded", function () {
// DOM-puu on valmis, elementtejä voi hakea ja käsitellä
});
window.addEventListener("load", function () {
// kaikki resurssit ovat latautuneet
});
Jos liität skriptin defer-attribuutilla, kumpaakaan ei tarvita: koodi suoritetaan muutenkin vasta kun DOM-puu on valmis. Katso Skriptien lataaminen.
Esimerkki load- ja DOMContentLoaded-tapahtumista
Oletustoiminnan estäminen
Monella tapahtumalla on selaimen oma oletustoiminta: linkki avaa sivun, lomake lähetetään palvelimelle, oikea hiirenpainike avaa valikon. preventDefault() peruu sen.
Tyypillisin tilanne on lomake, joka halutaan käsitellä javascript-ohjelmalla eikä lähettää palvelimelle. Lomakkeen käsitteleminen tapahtuu submit-tapahtumassa (esimerkki):
function omasubmit(e) {
e.preventDefault(); // estetään lomakkeen lähettäminen palvelimelle
const nimi = e.currentTarget.elements.nimi.value;
console.log("Lomake käsitellään tässä:", nimi);
}
document.addEventListener("DOMContentLoaded", function () {
document.forms[0].addEventListener("submit", omasubmit);
});
<form method="post" action="https://foo.bar.example/">
<fieldset>
<label>Nimi <input type="text" value="" name="nimi" /></label>
<div><input type="submit" value="Lähetä" /></div>
</fieldset>
</form>
Huomaa kaksi asiaa. Kuuntele lomakkeen submit-tapahtumaa, älä lähetysnapin click-tapahtumaa — lomakkeen voi lähettää myös Enter-näppäimellä. Ja pidä action-attribuutti paikallaan, jotta lomake toimii silloinkin kun javascript ei jostain syystä lataudu.
preventDefault() ei estä tapahtuman etenemistä DOM-puussa, ja stopPropagation() ei estä oletustoimintaa. Ne ovat toisistaan riippumattomia.
Tapahtuman eteneminen DOM-puussa
Tapahtuma ei kohdistu vain siihen elementtiin, jolle se tapahtui, vaan se kulkee koko polun puun juuresta kohteeseen ja takaisin. Kulku jakautuu kolmeen vaiheeseen:
- Capture (kaappausvaihe): ulkoa sisään, window-objektista kohteeseen.
- Kohde (target): kohde-elementti itse.
- Bubbling (kuplinta): sisältä ulos, kohteesta takaisin window-objektiin.
Oletuksena käsittelijä liitetään bubbling-vaiheeseen. Capture-vaiheeseen pääsee asetuksella { capture: true }. Käytännössä capture-vaiheen voi jättää lähes aina huomiotta ja keskittyä bubblingiin.
Capture ja bubbling ovat standardin termit. Vanhoissa materiaaleissa näkee capture-vaiheesta myös nimeä tunneling; se ei ole standardin termi eikä sillä löydy dokumentaatiota.
Seuraavanlaisessa HTML-dokumentissa click-tapahtuma voitaisiin käsitellä monessa eri vaiheessa:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="fi" xml:lang="fi">
<head>
<title>Capture ja bubbling</title>
</head>
<body>
<form method="post" action="https://foo.bar.example/">
<fieldset>
<label>Nimi <input type="text" value="" name="nimi" /></label>
<div><input type="submit" value="Lähetä" /></div>
</fieldset>
</form>
</body>
</html>
Jos hiirellä klikataan lähetyspainiketta, click-tapahtuma etenee seuraavassa järjestyksessä. Huomaa, että polku alkaa ja päättyy window-objektiin — ei html-elementtiin:
- Capture-vaihe ulkoa sisään:
- window
- document
- html
- body
- form
- fieldset
- div
- Kohdevaihe: input
- Bubbling-vaihe sisältä ulos:
- div
- fieldset
- form
- body
- html
- document
- window
Kokeile esimerkkiä ja katso konsolin tulosteet:
"use strict";
let laskuri = 1;
function nayta(vaihe) {
return function (e) {
console.log(vaihe, laskuri++, e.currentTarget, "eventPhase:", e.eventPhase);
};
}
document.addEventListener("DOMContentLoaded", function () {
// querySelectorAll("*") EI osu document- eikä window-objektiin,
// joten ne lisätään erikseen
const kohteet = [window, document, ...document.querySelectorAll("*")];
for (const kohde of kohteet) {
kohde.addEventListener("click", nayta("bubbling"));
kohde.addEventListener("click", nayta("capture"), { capture: true });
}
document.forms[0].addEventListener("submit", function (e) {
e.preventDefault();
});
});
Yksi yksityiskohta yllättää tulosteessa. Kohde-elementissä capture- ja bubbling-käsittelijät suoritetaan rekisteröintijärjestyksessä, ei niin että capture tulisi ensin. Koska yllä bubbling-käsittelijä lisätään ensimmäisenä, kohde-elementin kohdalla konsoliin tulostuu "bubbling" ennen "capturea". e.eventPhase on tuolloin molemmilla 2 eli kohdevaihe.
Etenemisen estäminen
Joskus — erittäin harvoin — on tarpeellista estää eteneminen. Tähän on kaksi metodia, joilla on eri vaikutus:
- stopPropagation() estää tapahtuman etenemisen seuraaviin elementteihin. Saman elementin muut käsittelijät suoritetaan silti.
- stopImmediatePropagation() estää lisäksi saman elementin muut käsittelijät.
Näiden käyttämiseen pitää olla hyvä syy. Etenemisen estäminen rikkoo muiden koodinpätkien delegoinnin, ja vika on erittäin hankala löytää — kaikki näyttää oikealta, mutta jokin toinen käsittelijä ei vain koskaan suoriudu.
Delegointi
Mitä hyötyä bubblingista on? Sen ansiosta useiden elementtien tapahtumat voidaan käsitellä yhdellä käsittelijällä, joka on liitetty niiden yhteiseen esivanhempaan. Tätä kutsutaan delegoinniksi.
Etuja on kaksi. Käsittelijöitä tarvitaan yksi eikä sataa, ja käsittely toimii myös niille elementeille, jotka lisätään sivulle vasta myöhemmin — uuteen elementtiin ei tarvitse muistaa lisätä käsittelijää.
Seuraavassa esimerkissä käsitellään listan kaikkien li-elementtien click-tapahtumat ul-elementin kautta:
"use strict";
function riviaKlikattu(e) {
// e.target on elementti, jota klikattiin
// e.currentTarget on elementti, joka käsittelee tapahtuman (tässä ul)
const rivi = e.target.closest("li");
// klikkaus osui listan sisään mutta li-elementtien ulkopuolelle
if (rivi === null) {
return;
}
rivi.textContent = Number(rivi.textContent) + 1;
}
document.addEventListener("DOMContentLoaded", function () {
const lista = document.querySelector("ul");
lista.addEventListener("click", riviaKlikattu);
});
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
closest() on delegoinnin tärkein työkalu. Se etsii lähimmän esivanhemman (tai elementin itsensä), joka vastaa css-valitsinta. Ilman sitä koodi rikkoutuu heti, kun li-elementin sisään lisätään esimerkiksi strong-elementti: silloin e.target onkin se strong eikä rivi.
Jos listassa on eri tyyppisiä rivejä, valitsinta voi tarkentaa ja matches()-metodilla voi haarautua:
function tyokaluaKlikattu(e) {
const nappi = e.target.closest("button");
if (nappi === null) {
return;
}
if (nappi.matches(".poista")) {
nappi.closest("li").remove();
}
else if (nappi.matches(".muokkaa")) {
console.log("muokataan");
}
}
Käsittelijä silmukassa
Delegointi on syy siihen, ettei käsittelijää yleensä kannata lisätä silmukassa jokaiseen elementtiin erikseen:
// toimii, mutta on tarpeeton: sata elementtiä, sata käsittelijää,
// eivätkä myöhemmin lisättävät elementit saa käsittelijää lainkaan
for (const rivi of document.querySelectorAll("li")) {
rivi.addEventListener("click", (e) => {
console.log(e.currentTarget.textContent);
});
}
Jos silmukkaa kuitenkin tarvitaan, sulkeuma toimii nykyaikaisessa javascriptissa oikein: for..of-silmukan const-muuttuja on lohkon sisäinen, joten jokainen käsittelijä näkee oman elementtinsä. Vanhoissa materiaaleissa varoitetaan funktion esittelemisestä silmukan sisällä — tuo ongelma liittyi var-sanaan ja on nykyisin poistunut.
Bubbling and capturing · How JavaScript Event Delegation Works
Kaikki tapahtumat eivät kupli
Delegointi toimii vain kupliville tapahtumille. Nämä eivät kupli, joten niitä ei voi käsitellä esivanhemmassa:
| Ei kupli | Kupliva vastine | Merkitys |
|---|---|---|
| focus | focusin | elementti sai kohdistuksen |
| blur | focusout | elementti menetti kohdistuksen |
| load, error | — | resurssin lataus onnistui tai epäonnistui |
| scroll elementillä | — | kuplii vain document-tasolla |
Käytännössä tämä tarkoittaa, että lomakkeen kenttien kohdistusta seurataan focusin- ja focusout-tapahtumilla, jos halutaan yksi käsittelijä koko lomakkeelle. Yksittäiselle kentälle liitettynä focus ja blur toimivat hyvin.
Tapahtuman bubbles-ominaisuudesta voi aina tarkistaa asian ajonaikaisesti.
Tärkeimmät tapahtumat
| Tapahtuma | Milloin |
|---|---|
| DOMContentLoaded | DOM-puu on valmis |
| load | kaikki resurssit ladattu |
| click | elementtiä klikattiin (myös näppäimistöllä napin kohdalla) |
| submit | lomake lähetetään |
| input | kentän arvo muuttui — jokaisen merkin jälkeen |
| change | kentän arvo muuttui ja muokkaus päättyi |
| keydown, keyup | näppäintä painettiin tai vapautettiin |
| mousedown, mouseup, dblclick | hiiritapahtumat |
| focusin, focusout | kohdistus siirtyi |
| resize | ikkunan koko muuttui |
| animationstart, animationend | CSS-animaatio alkoi tai päättyi |
| Drag and Drop | raahaus ja pudotus |
input vai change? Tekstikentässä input laukeaa jokaisen kirjoitetun merkin jälkeen ja change vasta kun kentästä poistutaan. Jos haluat reagoida kirjoittamiseen heti — esimerkiksi suodattaa listaa sitä mukaa kun käyttäjä kirjoittaa — käytä input-tapahtumaa. Valintaruuduilla ja pudotusvalikoilla ne laukeavat käytännössä samaan aikaan.
click-tapahtuma
Lähes kaikilla html-elementeillä on click-tapahtuma.
function omaklik(e) {
console.log(e.type, e.target, e.currentTarget, e.clientX, e.clientY);
}
// koska käsiteltävä elementti on vasta koodin jälkeen, on käytettävä
// DOMContentLoaded-tapahtumaa tai defer-attribuuttia
document.addEventListener("DOMContentLoaded", function () {
const kohde = document.getElementById("click");
kohde.addEventListener("click", omaklik);
});
<p id="click">click this</p>
Jos teet klikattavan käyttöliittymäelementin, käytä button-elementtiä äläkä div- tai p-elementtiä. Nappi saa klikkauksen myös näppäimistöltä (Enter ja väli), se on osa sarkainkiertoa ja ruudunlukija kertoo sen napiksi. Klikattava div on saavutettavuusongelma, jonka korjaaminen käsin vaatii sekä tabindex- että role-attribuutit ja oman näppäimistökäsittelijän.
Tapahtumien laukaiseminen koodissa
Tapahtumia voi aiheuttaa myös javascript-koodissa. Klikkaukseen on olemassa oma metodinsa, joka on selkein tapa:
nappi.click(); // laukaisee click-tapahtuman kuten käyttäjän klikkaus
Jos tapahtumaobjekti pitää rakentaa itse, käytä oikeaa luokkaa. Pelkkä Event ei sisällä hiiritietoja, kuten koordinaatteja:
const tapahtuma = new MouseEvent("click", {
bubbles: true,
cancelable: true, // ilman tätä preventDefault() ei toimi
clientX: 100,
clientY: 200
});
elementti.dispatchEvent(tapahtuma);
Omat tapahtumat luodaan CustomEvent-luokalla, jonka detail-kenttään voi liittää oman datan. Tämä on siisti tapa irrottaa sovelluksen osat toisistaan:
const tapahtuma = new CustomEvent("tuote-lisatty", {
bubbles: true,
detail: { id: 42, nimi: "Kissa" }
});
lista.dispatchEvent(tapahtuma);
// jossain aivan muualla:
document.addEventListener("tuote-lisatty", function (e) {
console.log(e.detail.nimi);
});
Koodin laukaisema tapahtuma eroaa aidosta yhdessä suhteessa: e.isTrusted on false. Selain ei myöskään suorita kaikkia oletustoimintoja ohjelmallisesti laukaistuille tapahtumille.
Kts. Creating and triggering events.
Kun tapahtumia tulee liian usein
Jotkin tapahtumat laukeavat tiheästi: input joka näppäinpainalluksesta, scroll ja pointermove kymmeniä kertoja sekunnissa. Jos käsittelijä tekee raskasta työtä — verkkopyynnön, mittauksen tai uudelleenpiirron — käyttöliittymä nykii.
Käsittelijää ei siis kannata suorittaa joka kerta. Suorituksia voi viivyttää niin, että ne tehdään vasta kun tapahtumat ovat loppuneet, tai rajoittaa niin, että niitä tehdään enintään yksi tietyn aikavälin sisällä. Ero kannattaa opetella oikein päin.
| Viive | Rajoitus | |
|---|---|---|
| Englanniksi | debounce | throttle |
| Milloin suoritetaan | kun tapahtumat ovat loppuneet | enintään kerran aikavälissä |
| Sopii | hakukenttä, automaattinen tallennus | vieritys, hiiren liike, koon muutos |
| Idea | odota rauhoittumista | ota näyte tasaisin välein |
Viive
Hakukenttä, joka hakee kirjoitettaessa: pyyntöä ei kannata tehdä joka kirjaimesta vaan vasta kun käyttäjä pitää tauon.
/**
* Palauttaa funktion, joka suoritetaan vasta kun kutsut ovat tauonneet.
*
* @param {Function} funktio Suoritettava funktio
* @param {number} viive Odotusaika millisekunteina
* @returns {Function} Viivästetty funktio
*/
const viivasta = function (funktio, viive) {
let tunniste = null;
return function (...parametrit) {
// Jokainen uusi kutsu peruu edellisen odotuksen
clearTimeout(tunniste);
tunniste = setTimeout(function () {
funktio(...parametrit);
}, viive);
};
};
const hae = viivasta(function (e) {
console.log("haetaan", e.target.value);
}, 300);
kentta.addEventListener("input", hae);
Noin 300 millisekuntia on tavallinen valinta hakukentälle: tarpeeksi lyhyt tuntumaan välittömältä, tarpeeksi pitkä karsimaan turhat pyynnöt.
Viive ei korvaa peruutusta. Jos kutsu tekee verkkopyynnön, tarvitaan myös AbortController — muuten hidas vanha vastaus voi saapua uuden jälkeen ja korvata sen. Viive vähentää pyyntöjä, peruutus hoitaa järjestyksen.
Rajoitus
Vierityksen seuraaminen: asento halutaan tietää jatkuvasti, mutta ei kymmeniä kertoja sekunnissa. Suoritusten määrä siis rajoitetaan yhteen tietyn aikavälin sisällä. Väliin osuvat kutsut ohitetaan kokonaan.
const rajoita = function (funktio, vali) {
let odottaa = false;
return function (...parametrit) {
if (odottaa) {
return;
}
funktio(...parametrit);
odottaa = true;
setTimeout(function () {
odottaa = false;
}, vali);
};
};
Piirtämiseen liittyvässä työssä requestAnimationFrame on tätä parempi: se ajoittaa työn juuri ennen seuraavaa ruutua, joten tahdistus on aina näytön kanssa oikea. Kts. animointiluento.
let pyydetty = false;
window.addEventListener("scroll", function () {
if (pyydetty) {
return;
}
pyydetty = true;
requestAnimationFrame(function () {
paivitaPalkki();
pyydetty = false;
});
});
Jos työ ei ole kiireellistä lainkaan, requestIdleCallback suorittaa sen silloin kun selaimella ei ole muuta tekemistä.
Tarkkailijat
Tarkkailijat kertovat muutoksista ilman että niitä tarvitsee jatkuvasti kysellä. Yhteinen kaava on sama kaikissa: luo tarkkailija takaisinkutsulla, kerro mitä tarkkaillaan, ja lopeta kun et enää tarvitse.
ResizeObserver on käsitelty erikseen. Kaksi muuta ovat tässä.
IntersectionObserver
IntersectionObserver kertoo, milloin elementti tulee näkyviin tai poistuu näkyvistä. Se ratkaisee kolme tavallista tarvetta: laiskan latauksen, äärettömän vierityksen ja animaation käynnistämisen oikeaan aikaan.
Vaihtoehto olisi scroll-tapahtuma, ja se on huono: tapahtuma laukeaa kymmeniä kertoja sekunnissa, ja jokaisessa käsittelijässä pitäisi laskea elementin sijainti — mikä pakottaa selaimen laskemaan asettelun uudelleen. Tarkkailija tekee saman selaimen omalla aikataululla ilman tätä kuormaa.
const tarkkailija = new IntersectionObserver(function (havainnot) {
// Takaisinkutsu saa taulukon: kaikki muuttuneet elementit kerralla
for (const havainto of havainnot) {
if (!havainto.isIntersecting) {
continue;
}
havainto.target.classList.add("nakyvissa");
// Kertaluontoinen asia: lopeta tämän elementin tarkkailu
tarkkailija.unobserve(havainto.target);
}
}, {
// rootMargin kasvattaa aluetta: elementti huomataan 200 pikseliä
// ennen kuin se tulee näkyviin
rootMargin: "200px",
// threshold kertoo, kuinka suuren osan on näyttävä. 0 = yksi pikseli
// riittää, 1 = koko elementin on oltava näkyvissä.
threshold: 0
});
for (const kortti of document.querySelectorAll(".kortti")) {
tarkkailija.observe(kortti);
}
Kolme yksityiskohtaa kannattaa huomata. Takaisinkutsu saa taulukon eikä yhtä elementtiä, joten se käydään läpi silmukassa. isIntersecting kertoo suunnan — sama takaisinkutsu laukeaa sekä saapumisesta että poistumisesta. Ja unobserve kannattaa kutsua heti, jos asia on kertaluontoinen.
Yksinkertaiseen kuvien laiskaan lataamiseen ei tarvita tarkkailijaa
lainkaan: <img loading="lazy" /> riittää.
MutationObserver
MutationObserver kertoo DOM-puun muutoksista: lisätyistä ja poistetuista solmuista, attribuuttien muutoksista ja tekstin muuttumisesta.
const tarkkailija = new MutationObserver(function (muutokset) {
for (const muutos of muutokset) {
console.log(muutos.type, muutos.addedNodes.length, "lisätty");
}
});
tarkkailija.observe(lista, {
childList: true, // lapsien lisäys ja poisto
subtree: true, // myös syvemmällä
attributes: false
});
Tätä tarvitaan harvemmin kuin luulisi. Jos itse muutat DOM-puuta, tiedät muutoksesta jo — silloin on selvempää kutsua omaa funktiota kuin tarkkailla omaa jälkeään. Oikeat käyttötapaukset ovat tilanteet, joissa muutos tulee jostain, mitä et hallitse: toisen kirjaston tuottama sisältö, selainlaajennus tai käyttäjän muokattava alue (contenteditable).
Varo silmukkaa. Jos takaisinkutsu muuttaa samaa puuta jota se tarkkailee, se laukeaa uudelleen. Lopeta tarkkailu muutoksen ajaksi disconnect-metodilla tai rajaa tarkkailu tarkasti.
Muista lopettaa tarkkailu kun sitä ei enää tarvita — esimerkiksi web-komponentin disconnectedCallback-metodissa:
tarkkailija.disconnect();
Dialogit ja kerrokset
Modaali on vaativa toteutettava: fokus on siirrettävä sisään, pidettävä sisällä, palautettava lähtöpaikkaan, ja taustan pitää lakata toimimasta. Omin käsin tehty div-modaali on lähes aina rikki näppäimistöllä.
Käytä dialog-elementtiä. Se hoitaa kaiken tämän itse.
<dialog id="vahvista">
<h2>Poistetaanko rivi?</h2>
<form method="dialog">
<button value="peruuta">Peruuta</button>
<button value="poista">Poista</button>
</form>
</dialog>
const dialogi = document.getElementById("vahvista");
// showModal tekee kolme asiaa, jotka jäävät muuten itse tehtäväksi:
// fokus siirtyy dialogiin ja pysyy siellä, muu dokumentti muuttuu
// toimimattomaksi, ja Esc sulkee dialogin.
dialogi.showModal();
dialogi.addEventListener("close", function () {
// returnValue on painikkeen value-attribuutti
console.log("suljettiin arvolla", dialogi.returnValue);
// Fokus palautuu itsestään siihen elementtiin, josta dialogi avattiin
});
Huomaa method="dialog": se sulkee dialogin lähetyksessä ja
asettaa returnValue-arvon ilman javascriptia.
Taustan tyylittäminen käy ::backdrop-pseudoelementillä:
dialog::backdrop {
background-color: rgb(0 0 0 / 0.5);
}
show-metodi avaa dialogin ilman modaalisuutta: fokusta ei vangita eikä tausta lakkaa toimimasta. Sitä käytetään harvemmin.
popover
Valikot, työkaluvihjeet ja pienet ponnahdukset eivät ole modaaleja: taustan pitää jäädä toimimaan. Niihin on popover-attribuutti, ja yksinkertaisimmassa tapauksessa javascriptia ei tarvita lainkaan:
<button type="button" popovertarget="valikko">Valikko</button>
<div id="valikko" popover="auto">
<ul>
<li><a href="#yksi">Yksi</a></li>
<li><a href="#kaksi">Kaksi</a></li>
</ul>
</div>
Selain hoitaa avaamisen, sulkemisen ulkopuolelta klikkaamalla ja Esc-näppäimellä sekä kerrostumisen muun sisällön päälle.
inert
Jos joudut tekemään kerroksen itse, inert-attribuutti poistaa alueen kokonaan vuorovaikutuksesta: sen sisältö ei saa fokusta, ei reagoi klikkauksiin eikä näy apuvälineille.
// muu sivu pois käytöstä oman kerroksen ajaksi
document.getElementById("main").inert = true;
Tämä on se osa, joka showModal-metodissa tulee valmiina.
Osoiterivi ja History API
Sovelluksen tila on tavallisesti javascript-muuttujissa. Osa siitä kuuluu kuitenkin osoiteriville, ja tästä syntyy tavallinen puute: suodatettu näkymä, jota ei voi lähettää linkkinä eikä ladata uudelleen.
Nyrkkisääntö: jos tilan pitäisi kestää sivun uudelleenlataus tai olla jaettavissa, se kuuluu osoitteeseen. Hakuehto, sivunumero, järjestys ja valittu välilehti kuuluvat. Lomakkeen keskeneräinen sisältö ja avoin valikko eivät.
URL ja URLSearchParams
Osoitteita ei pilkota merkkijonokäsittelyllä. URL-olio jäsentää osoitteen osiin, ja URLSearchParams hoitaa parametrit — myös koodauksen, joka menee käsin helposti väärin.
const osoite = new URL(window.location.href);
// lue
const suodatin = osoite.searchParams.get("suodatin") ?? "";
const sivu = Number(osoite.searchParams.get("sivu") ?? 1);
// kirjoita
osoite.searchParams.set("suodatin", "opel");
// poista: tyhjää parametria ei kannata jättää osoitteeseen
osoite.searchParams.delete("sivu");
console.log(osoite.href);
getAll palauttaa kaikki samannimiset arvot, mikä on kätevä monivalinnalle. Kts. myös fetch-luento, jossa samaa oliota käytetään pyynnön parametreihin.
pushState ja replaceState
Osoitteen voi vaihtaa lataamatta sivua uudelleen.
| Metodi | Mitä tekee |
|---|---|
| history.pushState | lisää uuden kohdan historiaan — paluunappi palaa edelliseen tilaan |
| history.replaceState | korvaa nykyisen kohdan — paluunappi ohittaa tämän |
Valinta niiden välillä on käyttöliittymäpäätös: onko muutos sellainen, että käyttäjä haluaisi peruuttaa sen paluunapilla?
// Hakukentän kirjoittaminen: replaceState. Muuten jokainen kirjain
// jättäisi historiaan oman kohdan, ja paluunapin painelu kestäisi ikuisuuden.
const osoite = new URL(window.location.href);
osoite.searchParams.set("suodatin", arvo);
history.replaceState({ suodatin: arvo }, "", osoite);
// Sivun vaihtaminen: pushState. Tähän paluu on mielekästä.
osoite.searchParams.set("sivu", String(sivu));
history.pushState({ sivu: sivu }, "", osoite);
Ensimmäinen parametri on tila-olio, joka talletetaan historiaan ja saadaan takaisin popstate-tapahtumassa. Toinen parametri on jäänne eikä sillä ole vaikutusta — sille annetaan tyhjä merkkijono.
popstate
Tämä on se osa, joka jää tavallisesti tekemättä. Osoitteen vaihtaminen ei riitä: on myös kuunneltava selaimen paluunappia, tai osoite muuttuu eikä näkymä seuraa.
window.addEventListener("popstate", function (e) {
// Tila voi olla null, jos käyttäjä tuli sivulle suoraan tai
// osoitetta muutettiin ilman tila-oliota. Luotettavampi lähde on
// itse osoite.
const osoite = new URL(window.location.href);
naytaTulokset(osoite.searchParams.get("suodatin") ?? "");
});
Huomaa, ettei pushState laukaise popstate-tapahtumaa. Se laukeaa vain käyttäjän liikkumisesta historiassa — paluunapista, eteenpäin-napista tai history.back-kutsusta. Oma muutos on siis tehtävä kahdesti: näkymään ja osoitteeseen.
Selvin rakenne on tehdä näkymän päivittämisestä yksi funktio, joka lukee tilan osoitteesta. Silloin sama funktio kelpaa sivun latautumiseen, omaan muutokseen ja paluunappiin:
const paivita = function () {
const p = new URL(window.location.href).searchParams;
naytaTulokset(p.get("suodatin") ?? "", Number(p.get("sivu") ?? 1));
};
// sivun latautuessa
paivita();
// paluunapista
window.addEventListener("popstate", paivita);
// omasta muutoksesta
kentta.addEventListener("input", viivasta(function (e) {
const osoite = new URL(window.location.href);
osoite.searchParams.set("suodatin", e.target.value);
history.replaceState(null, "", osoite);
paivita();
}, 300));
Näin osoite on yksi totuus näkymän tilasta, eikä samaa tietoa ylläpidetä kahdessa paikassa.
Huomio palvelimesta
Jos käytät pushState-metodia polkujen vaihtamiseen — esimerkiksi /autot/5 — palvelimen on osattava palauttaa sama sivu myös suoralla pyynnöllä. Muuten osoitteen jakaminen tai uudelleenlataus antaa 404-virheen: selain ei ole käynyt polulla, joten se pyytää sitä palvelimelta.
Tämän takia hakuparametrit (?suodatin=opel) ovat kurssin puitteissa helpompi valinta kuin polut: ne osoittavat samaan tiedostoon eikä palvelimen asetuksia tarvitse muuttaa.
Esimerkkejä
- Esimerkki, jossa käytetään DOM-standardin metodeja ja ominaisuuksia
- Tietojen tallentaminen objektiin
- Propagointi — tapahtuman liikkuminen DOM-puussa ylöspäin
- Kapselointi, encapsulation.js
- Elävä NodeList-objekti, nodelist.js — huomaa, että tämä vanha esimerkki käyttää window.onload-ominaisuutta; uudessa koodissa käytä addEventListener-metodia
Luennolla käsiteltäviä aiheita:
- elementtien käsitteleminen
- dynaaminen lomakkeen luominen
- listan numerointi
- elementtien poistaminen
- tapahtumankäsittelijät
- sivun ulkoasun muuttaminen (avautuva ja sulkeutuva lista, värin muutos)
Käyttäjien kommentit