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 metodiMerkitys
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.

TapahtumaLaukeaa kunKä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:

  1. Capture (kaappausvaihe): ulkoa sisään, window-objektista kohteeseen.
  2. Kohde (target): kohde-elementti itse.
  3. 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>
Kaavio tapahtuman kulusta: capture-vaihe etenee dokumentin juuresta kohde-elementtiin
Capture-vaihe: tapahtuma etenee ulkoa sisään kohde-elementtiin.
Kaavio tapahtuman kulusta: bubbling-vaihe etenee kohde-elementistä takaisin ulospäin
Bubbling-vaihe: tapahtuma palaa kohteesta takaisin ulospäin.

Jos hiirellä klikataan lähetyspainiketta, click-tapahtuma etenee seuraavassa järjestyksessä. Huomaa, että polku alkaa ja päättyy window-objektiin — ei html-elementtiin:

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:

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 kupliKupliva vastineMerkitys
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

TapahtumaMilloin
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.

Event reference

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.

ViiveRajoitus
Englanniksidebouncethrottle
Milloin suoritetaankun tapahtumat ovat loppuneetenintään kerran aikavälissä
Sopiihakukenttä, automaattinen tallennusvieritys, hiiren liike, koon muutos
Ideaodota rauhoittumistaota 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ä.

Sama input-tapahtuma kolmella eri käsittelyllä. Kirjoita kenttään nopeasti ja vertaa lukuja: suoraan kasvaa joka näppäinpainalluksesta, viivytetty vain kun lopetat, rajoitettu tasaisin välein. Erillisenä sivuna: viive.html.

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ää.

Vieritä aluetta alaspäin. Kortit saavat luokan vasta kun ne tulevat näkyviin, ja tarkkailu lopetetaan heti perään. Kokeile muuttaa rootMargin-arvoksi 200px tai threshold-arvoksi 1. Erillisenä sivuna: tarkkailija.html.

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.

Avaa dialogi ja yritä liikkua sarkaimella sen ulkopuolelle. Sulje se ja katso konsolista, mihin fokus palasi. Ohje-painike käyttää popover-attribuuttia eikä javascriptia lainkaan. Erillisenä sivuna: dialogi.html.

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.

MetodiMitä tekee
history.pushStatelisää uuden kohdan historiaan — paluunappi palaa edelliseen tilaan
history.replaceStatekorvaa 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.

Kirjoita suodatin ja vaihda sivua. Kyselyosa näkyy sivulla, koska kehyksessä ei ole osoiteriviä. Paina sitten selaimen paluunappia: sivunumero muuttuu mutta suodatin ei, koska ne käyttävät eri metodia. Erillisenä sivuna: osoiterivi.html.

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ä

Luentovideo: DOM ja tapahtumat. Jos video ei näy, katso se suoraan YouTubesta.

Luennolla käsiteltäviä aiheita:

Luentovideo: tapahtumankäsittelijät käytännössä. Jos video ei näy, katso se suoraan YouTubesta.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti