Web-komponentit
Selain osaa itse sen, mitä varten useimmat javascript-kehykset on tehty: omien uudelleenkäytettävien elementtien määrittelemisen. Web-komponentit eivät ole kirjasto vaan kolmen selainrajapinnan yhteisnimitys, ja niitä voi käyttää myös toisistaan riippumatta.
| Rajapinta | Mitä se tekee |
|---|---|
| Custom elements | oma HTML-elementti omalla toiminnallaan, esimerkiksi <auto-kortti> |
| template-elementti | merkkausta, jota ei renderöidä vaan kloonataan tarvittaessa |
| Shadow DOM | elementin sisään liitetty erillinen DOM-puu, jonka tyylit eivät valu ulos |
Suurin osa tästä sivusta käsittelee custom elementtejä, koska niistä on eniten hyötyä ja vähiten haittaa. Shadow DOM on käsitelty omana lukunaan kompromisseineen.
Luennon asioita harjoitellaan käytännössä web-komponenttiohjauksessa.
Olet jo käyttänyt yhtä web-komponenttia. Tämän kurssin sivupohja lataa lite-youtube-nimisen custom elementin, ja kurssin videot näytetään sen avulla. Katso sivun lähdekoodia.
Oma elementti
Custom element on luokka, joka periytyy HTMLElement-luokasta ja rekisteröidään customElements.define-kutsulla.
class Tervehdys extends HTMLElement {
connectedCallback() {
const nimi = this.getAttribute("nimi") ?? "maailma";
// this on elementti itse, joten sitä käsitellään kuten mitä tahansa
// DOM-elementtiä
this.textContent = `Hei ${nimi}!`;
}
}
customElements.define("tervehdys-teksti", Tervehdys);
<tervehdys-teksti nimi="Matti"></tervehdys-teksti>
Nimessä on pakko olla tavuviiva. Se ei ole tyyliseikka vaan sääntö: tavuviiva erottaa omat elementit selaimen omista, eikä HTML-standardi ota koskaan käyttöön tavuviivallisia nimiä. Nimi ei myöskään saa alkaa numerolla eikä sisältää isoja kirjaimia.
Elementti tarvitsee aina sulkevan tagin. Custom element ei voi olla tyhjä elementti kuten img tai input. Muotoa <tervehdys-teksti /> ei siis voi käyttää: jäsennin lukisi sen avaavaksi tagiksi, ja loput sivusta päätyisi sen sisään. Tämä on sama ansa kuin itsestään sulkeutuva script-elementti. XHTML-sivulla lyhyt muoto toimisi, mutta polyglot-merkkauksen on toimittava myös HTML-jäsentimellä, joten sulkeva tagi kirjoitetaan aina.
Rekisteröimätön elementti ei ole virhe. Selain jäsentää sen HTMLUnknownElement-tyyppisenä ja näyttää sen sisällön sellaisenaan, ja kun määrittely myöhemmin ladataan, elementti päivitetään (upgrade). Siksi HTML-sivulla skriptin voi ladata defer-attribuutilla tai moduulina, ilman että sivu hajoaa väliaikaisesti.
XHTML-sivulla Chrome ei suorita moduuliskriptejä eikä noudata defer-attribuuttia. Firefoxissa molemmat toimivat, mutta sivun on toimittava kaikissa selaimissa. Lataa skripti XHTML-sivulla tavallisena skriptinä.
Elinkaari
Selain kutsuu luokan metodeita elementin elinkaaren eri vaiheissa. Nimet ovat kiinteitä.
| Metodi | Milloin |
|---|---|
| constructor | elementti luodaan — tässä ei vielä pääse attribuutteihin eikä lapsiin |
| connectedCallback | elementti liitetään dokumenttiin |
| disconnectedCallback | elementti poistetaan dokumentista — tässä siivotaan |
| attributeChangedCallback | seurattu attribuutti muuttuu |
| adoptedCallback | elementti siirretään toiseen dokumenttiin — harvinainen |
Mitä konstruktorissa saa tehdä
Konstruktori suoritetaan, kun selain luo elementin — mahdollisesti ennen kuin sen attribuutteja tai lapsia on jäsennetty. Siellä ei saa lukea attribuutteja, koskea lapsiin eikä muokata elementin ulkopuolista dokumenttia.
class Esimerkki extends HTMLElement {
constructor() {
// super() on kutsuttava ensimmäisenä
super();
// Tämä on oikea paikka omille kentille ja sidotuille käsittelijöille
this.laskuri = 0;
this.klikattiin = this.klikattiin.bind(this);
}
}
Varsinainen työ kuuluu connectedCallback-metodiin.
connectedCallback voi laueta useammin kuin kerran
Tämä on tavallisin virhelähde. Metodia kutsutaan joka kerta, kun elementti liitetään dokumenttiin — ja koska elementin siirtäminen DOM-puussa on irrotus ja uudelleenliitos, se laukeaa myös siirroissa.
class Laskuri extends HTMLElement {
connectedCallback() {
// EI NÄIN: nuolifunktio on joka kerta uusi, joten siirron jälkeen
// käsittelijöitä on kaksi ja yksi klikkaus kasvattaa laskuria kahdella
this.addEventListener("click", () => this.kasvata());
}
}
Selain ei lisää samaa funktiota samaan elementtiin kahdesti. Ongelma syntyy, kun käsittelijä on joka kerta uusi funktio: nuolifunktio tai bind-kutsun tulos.
Ratkaisu on rakentaa sisältö vain kertaalleen ja poistaa käsittelijät disconnectedCallback-metodissa:
class Laskuri extends HTMLElement {
connectedCallback() {
// rakennetaan sisältö vain ensimmäisellä kerralla
if (!this.rakennettu) {
this.rakenna();
this.rakennettu = true;
}
this.addEventListener("click", this.kasvata);
}
disconnectedCallback() {
// Siivous kuuluu tänne: irrotettu elementti ei siivoudu itsestään, jos
// sillä on ajastimia, tarkkailijoita tai käsittelijöitä muissa
// elementeissä.
this.removeEventListener("click", this.kasvata);
}
}
Jos käsittelijä on liitetty itse elementtiin, se vapautuu roskienkeruussa elementin mukana. Jos se on liitetty window- tai document-olioon, se ei vapaudu vaan jää elämään — tämä on sama asia kuin animointiluennon ajastimet ja ResizeObserver.
Lapsielementit eivät ole valmiita
Kun selain jäsentää sivua virtana, connectedCallback voi laueta ennen kuin elementin lapset on luettu. Seuraava voi siis palauttaa null:
connectedCallback() {
const otsikko = this.querySelector("h2"); // voi olla null!
}
HTML-sivulla yksinkertaisin ratkaisu on ladata skripti moduulina tai defer-attribuutilla, jolloin se suoritetaan vasta sitten, kun dokumentti on jäsennetty. XHTML-sivulla Chrome ei noudata kumpaakaan, joten määrittele komponentti vasta DOMContentLoaded-tapahtumassa. Silloin selain päivittää sivun elementit vasta, kun niiden lapset on jäsennetty:
document.addEventListener("DOMContentLoaded", function () {
customElements.define("auto-kortti", AutoKortti);
});
Jos komponenttia luodaan javascriptilla, lapset ovat paikoillaan.
Attribuutit ja ominaisuudet
Attribuuttien seuraaminen vaatii kaksi asiaa: staattisen observedAttributes-listan ja attributeChangedCallback-metodin.
class Laskuri extends HTMLElement {
// Vain tässä listatut attribuutit laukaisevat takaisinkutsun
static get observedAttributes() {
return ["arvo", "askel"];
}
attributeChangedCallback(nimi, vanha, uusi) {
// HTML-sivulla takaisinkutsu laukeaa myös alkuarvojen asettamisessa,
// ennen connectedCallback-metodia. Chromessa XHTML-sivulla ei.
if (vanha === uusi) {
return;
}
this.paivita();
}
}
XHTML-sivun ansa: kun Chrome jäsentää XHTML-sivua, se ei kutsu attributeChangedCallback-metodia attribuuteille, jotka on kirjoitettu sivun merkkaukseen. HTML-sivulla ja Firefoxissa metodia kutsutaan. Piirrä siksi alkutila aina connectedCallback-metodissa.
Attribuutit ovat aina merkkijonoja. Numero on muunnettava, ja totuusarvo ilmaistaan attribuutin olemassaololla eikä sen arvolla — aivan kuten required ja disabled.
// numero
const askel = Number(this.getAttribute("askel") ?? 1);
// totuusarvo: merkitsevää on, onko attribuutti olemassa
const lukittu = this.hasAttribute("lukittu");
Ominaisuuden heijastaminen
Selaimen omilla elementeillä attribuutti ja javascript-ominaisuus kulkevat
käsi kädessä: input.value ja value-attribuutti.
Samaan pääsee omassa elementissä getterillä ja setterillä.
get arvo() {
return Number(this.getAttribute("arvo") ?? 0);
}
set arvo(uusi) {
// Setter kirjoittaa attribuutin, joka laukaisee
// attributeChangedCallback-metodin ja sitä kautta päivityksen.
// Näin on vain YKSI totuus arvosta.
this.setAttribute("arvo", uusi);
}
Nyt elementtiä voi käyttää kahdella tavalla, ja molemmat toimivat samoin:
<laskuri-elementti arvo="5"></laskuri-elementti>
// tai javascriptista, samalla lopputuloksella
document.querySelector("laskuri-elementti").arvo = 5;
Älä heijasta kaikkea. Attribuutti on järkevä alkuarvoille ja tilalle, jonka haluaa näkyvän merkkauksessa. Suuret datarakenteet ja funktiot kannattaa pitää pelkkinä ominaisuuksina — attribuuttina ne olisivat merkkijonoja.
template ja kloonaus
template-elementin sisältöä ei renderöidä eikä suoriteta. Kuvia ei ladata, skriptejä ei ajeta, ja elementit eivät näy sivulla. Se on rakennuspalikka, joka kloonataan tarvittaessa.
<template id="kortti">
<article class="kortti">
<h3></h3>
<p class="valmistaja"></p>
</article>
</template>
const pohja = document.getElementById("kortti");
// content on DocumentFragment. Ensimmäinen parametri true kopioi myös
// sisällön - ilman sitä saisi tyhjän fragmentin.
const kortti = pohja.content.cloneNode(true);
kortti.querySelector("h3").textContent = auto.malli;
kortti.querySelector(".valmistaja").textContent = auto.valmistaja;
this.replaceChildren(kortti);
Tämä on innerHTML-ominaisuuden oikea vaihtoehto. Rakenne kirjoitetaan kertaalleen merkkaukseen, ja arvot asetetaan textContent-ominaisuudella — jolloin datassa oleva teksti ei voi koskaan muuttua merkkaukseksi. Kts. DOM-luento.
Jos komponentti luo pohjansa itse, se kannattaa tehdä kerran luokan tasolla eikä joka elementille erikseen:
// Moduulin tasolla: suoritetaan kertaalleen, ei joka elementille
const POHJA = document.createElement("template");
POHJA.innerHTML = `
<article class="kortti">
<h3></h3>
<p class="valmistaja"></p>
</article>
`;
Tässä innerHTML on turvallinen, koska merkkaus on kirjoitettu itse eikä se sisällä käyttäjän dataa. Arvot asetetaan vasta kloonin jälkeen textContent-ominaisuudella.
XHTML-sivulla innerHTML hyväksyy vain hyvin muodostettua XML:ää. Sulkematon <br> tai html:n nimetty merkkiviittaus, kuten −, aiheuttaa SyntaxError-virheen. Kirjoita merkit sellaisinaan tai numeerisina viittauksina (−).
Omat tapahtumat
Komponentti kertoo ulospäin tapahtumilla eikä kutsumalla vanhempansa funktioita. Näin sitä voi käyttää mistä tahansa, ilman että käyttäjän tarvitsee tuntea sen sisäistä rakennetta.
// detail-kenttään pakataan mukana kulkeva data
this.dispatchEvent(new CustomEvent("laskuri-muuttui", {
detail: { arvo: this.arvo },
// ilman tätä tapahtuma ei kupli ylöspäin
bubbles: true
}));
document.querySelector("laskuri-elementti")
.addEventListener("laskuri-muuttui", function (e) {
console.log("uusi arvo", e.detail.arvo);
});
Nimeä tapahtumat niin, ettei nimi voi osua selaimen omiin — etuliite komponentin nimestä toimii hyvin. Kts. CustomEvent ja tapahtumaluento.
Kokonainen esimerkki
Tässä on kaikki edellä käsitelty yhdessä: pohja kloonataan kertaalleen luodusta template-elementistä, arvo on heijastettu attribuutiksi, päivitys tapahtuu attributeChangedCallback-metodissa ja muutoksesta kerrotaan omalla tapahtumalla.
// Pohja luodaan kertaalleen moduulin tasolla eikä joka elementille erikseen.
// innerHTML on tässä turvallinen, koska merkkaus on kirjoitettu itse.
const POHJA = document.createElement("template");
POHJA.innerHTML = `
<output class="arvo"></output>
<button type="button" class="vahenna">−</button>
<button type="button" class="kasvata">+</button>
`;
class Laskuri extends HTMLElement {
static get observedAttributes() {
return ["arvo", "askel"];
}
constructor() {
super();
// Käsittelijä sidotaan tähän elementtiin, jotta sen voi myöhemmin
// poistaa samalla viitteellä
this.klikattiin = this.klikattiin.bind(this);
}
get arvo() {
return Number(this.getAttribute("arvo") ?? 0);
}
set arvo(uusi) {
// Setter kirjoittaa attribuutin, joka laukaisee päivityksen.
// Arvosta on siis vain yksi totuus.
this.setAttribute("arvo", uusi);
}
get askel() {
return Number(this.getAttribute("askel") ?? 1);
}
connectedCallback() {
// Sisältö rakennetaan vain kertaalleen: connectedCallback laukeaa
// uudelleen, jos elementti siirretään DOM-puussa.
if (this.naytto === undefined) {
this.replaceChildren(POHJA.content.cloneNode(true));
this.naytto = this.querySelector(".arvo");
this.paivita();
}
this.addEventListener("click", this.klikattiin);
}
disconnectedCallback() {
// Siivous kuuluu tänne
this.removeEventListener("click", this.klikattiin);
}
attributeChangedCallback(nimi, vanha, uusi) {
if (vanha === uusi || this.naytto === undefined) {
return;
}
this.paivita();
}
klikattiin(e) {
const nappi = e.target.closest("button");
if (nappi === null) {
return;
}
// Setterin kautta: se kirjoittaa attribuutin ja päivitys tapahtuu
// attributeChangedCallback-metodissa
this.arvo = nappi.classList.contains("kasvata")
? this.arvo + this.askel
: this.arvo - this.askel;
}
paivita() {
this.naytto.textContent = this.arvo;
// Ulospäin kerrotaan tapahtumalla, ei kutsumalla vanhemman funktioita
this.dispatchEvent(new CustomEvent("laskuri-muuttui", {
detail: { arvo: this.arvo },
bubbles: true
}));
}
}
customElements.define("laskuri-elementti", Laskuri);
<laskuri-elementti arvo="10" askel="5"></laskuri-elementti>
Huomaa attributeChangedCallback-metodin tarkistus
this.naytto === undefined. HTML-sivulla takaisinkutsu laukeaa jo
alkuarvojen asettamisessa, ennen connectedCallback-metodia —
eli ennen kuin näyttöelementtiä on olemassa. Ilman tarkistusta ensimmäinen
kutsu kaatuisi. Alkutila piirretään connectedCallback-metodissa,
joten esimerkki toimii myös Chromessa XHTML-sivulla, jossa takaisinkutsua ei
alkuarvoille kutsuta.
Tyylit
Kun komponentti ei käytä shadow DOMia, se on tyylien kannalta tavallinen elementti: sivun tyylitiedosto pätee sen sisältöön. Oma elementti käyttäytyy oletuksena display: inline -tyyppisenä, joten lohkotasoinen asettelu on kerrottava erikseen.
laskuri-elementti {
display: block;
padding: 0.5rem;
border: 1px solid;
}
/* :defined osuu vasta kun elementti on rekisteröity. Näin voi piilottaa
keskeneräisen sisällön siihen asti. */
laskuri-elementti:not(:defined) {
visibility: hidden;
}
:defined-pseudoluokka ratkaisee välkähdyksen, joka syntyy, kun elementti on sivulla ennen kuin sen määrittely on ladattu.
Shadow DOM
Shadow DOM liittää elementtiin erillisen DOM-puun, jonka tyylit eivät valu ulos eivätkä ulkopuoliset tyylit vaikuta siihen.
class Kortti extends HTMLElement {
constructor() {
super();
// Shadow root saa liittää konstruktorissa - se on elementin omaa
// sisäistä rakennetta eikä ulkopuolista dokumenttia.
const juuri = this.attachShadow({ mode: "open" });
juuri.innerHTML = `
<style>
/* :host on elementti itse */
:host { display: block; border: 1px solid; padding: 0.5rem; }
h3 { margin: 0; }
</style>
<h3><slot name="otsikko">Ei otsikkoa</slot></h3>
<slot></slot>
`;
}
}
<auto-kortti>
<span slot="otsikko">Polo</span>
<p>Volkswagen</p>
</auto-kortti>
slot-elementti kertoo, mihin elementin oma sisältö sijoitetaan. Sen sisältö on varasisältöä, joka näytetään, jos vastaavaa slot-attribuuttia ei löydy.
Mitä shadow DOM rikkoo
Kapselointi on kaksiteräinen miekka. Nämä neljä asiaa kannattaa tietää, ennen kuin ottaa shadow rootin käyttöön.
1. Lomakkeet. Shadow DOMin sisällä oleva input ei osallistu lomakkeen lähetykseen eikä näy FormData-oliossa, eikä submit-tapahtuma pääse shadow DOMin rajan yli. Korjaus vaatii ElementInternals-rajapinnan ja attachInternals-metodin sekä static formAssociated = true -määrittelyn. Se toimii, mutta vaatii selvästi enemmän työtä kuin tavallisen lomakekentän käyttäminen.
2. Saavutettavuusattribuutit eivät ylitä shadow DOMin rajaa. label-elementin for-attribuutti, aria-describedby ja aria-labelledby viittaavat tunnisteilla, ja tunnisteen on löydyttävä samasta puusta. Sivulla oleva nimilappu ei siis voi osoittaa shadow DOMin sisällä olevaan kenttään.
3. document.querySelector ei näe sisään. Tämä vaikeuttaa sekä testaamista että virheiden etsimistä. Sisältöön pääsee vain elementin shadowRoot-ominaisuuden kautta, ja vain jos tila on open.
4. Tapahtumat tarvitsevat composed-asetuksen. Shadow DOMin sisältä lähtevä oma tapahtuma ei kupli shadow DOMin rajan yli ilman sitä:
this.dispatchEvent(new CustomEvent("muuttui", {
bubbles: true,
composed: true // ylittää shadow DOMin rajan
}));
Milloin shadow DOM kannattaa
Shadow root kannattaa ottaa käyttöön silloin, kun julkaiset komponentin muiden käyttöön etkä tiedä, millaiselle sivulle se joutuu. Kapselointi estää sivun tyylejä rikkomasta komponenttia ja komponentin tyylejä rikkomasta sivua.
Oman sovelluksen sisäisissä komponenteissa se on useimmiten tarpeeton. Silloin sivun tyylit ovat omia, kapseloinnista ei ole hyötyä, ja yllä olevat neljä rajoitetta ovat pelkkää haittaa. Oman sovelluksen komponentit voi siksi useimmiten tehdä ilman shadow rootia. Web-komponenttiohjauksessa shadow DOMia kuitenkin harjoitellaan, jotta sen hyödyt ja rajoitukset tulevat tutuiksi käytännössä.
Declarative Shadow DOM
Shadow rootin voi liittää myös suoraan merkkauksessa shadowrootmode-attribuutilla. Silloin palvelin voi lähettää valmiin rakenteen eikä ensimmäinen renderöinti vaadi javascriptia lainkaan:
<auto-kortti>
<template shadowrootmode="open">
<style>:host { display: block; }</style>
<slot></slot>
</template>
<p>Polo</p>
</auto-kortti>
Tämä on shadow DOMin uusin osa, ja sen tuki on nykyselaimissa kattava. Se on kiinnostava lähinnä palvelinpuolen renderöinnissä. Declarative Shadow DOM toimii vain HTML-sivuilla. XHTML-sivulla shadow rootia ei synny Chromessa eikä Firefoxissa, ja template-elementti jää sivulle sellaisenaan.
Web-komponentit vai React
| Web-komponentit | React | |
|---|---|---|
| Mistä tulee | selain | kirjasto |
| Käännösvaihe | ei tarvita | tarvitaan (JSX) |
| Tilan hallinta | oma, käsin tehtävä | koukut ja renderöinti valmiina |
| Uudelleenpiirto | itse päätettävä mitä päivitetään | React hoitaa |
| Vanheneminen | rajapinta ei muutu | versiot ja käytännöt vaihtuvat |
| Toimii toisen kehyksen kanssa | kyllä | ei suoraan |
Ne eivät ole vaihtoehtoja toisilleen samassa mielessä kuin kaksi kehystä. Custom element on elementti, joten se toimii minkä tahansa kehyksen sisällä — myös Reactissa. Siksi valmiit komponenttikirjastot julkaistaan usein web-komponentteina.
Suurin ero on siinä, mitä ei saa valmiina. React huolehtii siitä, että näkymä vastaa tilaa. Web-komponentissa se on oma vastuu: kun data muuttuu, on itse päätettävä mitä DOM-puussa päivitetään. Pienessä komponentissa se on muutama rivi, isossa näkymässä se on juuri se työ, jota varten kehykset ovat olemassa.
Lisätietoa
- Web-komponenttiohjaus
- Web components — MDN
- Using custom elements
- Using shadow DOM
- Using templates and slots
- Custom element best practices
- ElementInternals — lomakkeisiin kytkeytyvät komponentit
- Declarative Shadow DOM
Käyttäjien kommentit