Web-komponentit
Harjoitellaan omien html-elementtien eli web-komponenttien tekemistä. Rakennetaan askel askeleelta sovellus, jossa on laskuri, suodattuva autolistaus, autojen valinta ja poisto sekä lomake uusien autojen lisäämiseen. Lopuksi lisätään komponentteihin reaktiivisuus: näkymä päivittyy itsestään, kun tila muuttuu.
Web-komponentit ovat selaimen omaa tekniikkaa. Mitään kirjastoja tai työkaluja ei tarvitse asentaa: komponentti on JavaScript-luokka, joka periytyy HTMLElement-luokasta, ja valmista komponenttia käytetään sivulla kuten mitä tahansa html-elementtiä.
Tutustu ennen aloittamista:
- Web-komponentit — kurssin luento: omat elementit, elinkaari, attribuutit, template ja shadow DOM
- Using custom elements — oman elementin määrittely ja elinkaarimetodit
- Using shadow DOM — komponentin oma, muusta sivusta erotettu DOM-puu
- Using templates and slots — elementin sisällön näyttäminen komponentin sisällä
Luennolla todetaan, että oman sovelluksen komponentit voi useimmiten tehdä ilman shadow DOMia. Tässä ohjauksessa sitä kuitenkin käytetään, jotta sen hyödyt ja rajoitukset tulevat tutuiksi käytännössä: tyylien kapselointi, composed-tapahtumat ja lomakkeeseen osallistuvat komponentit.
Mallivastaukset ja esimerkit
- Mallivastaus koko tehtävään (malli.js)
- Mallivastaus reaktiivisuudella (malli-reaktiivinen.js, reaktiivisuus.js)
- Mallivastaus fetch-osioon (malli-fetch.js) — autot haetaan palvelimelta
- Template-elementti (template.js) — kortin rakenne ja tyylit sivun pohjassa
- Komponentit ilman omaa JavaScriptiä — laskuri ja autolistaus pelkällä merkkauksella
Pääsy alkuun
- Aloita uusi XHTML-dokumentti seuraavalla pohjalla:
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" lang="fi" xml:lang="fi"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Web-komponentit</title> <script src="oma.js"></script> </head> <body> <h1>Autot</h1> <auto-laskuri></auto-laskuri> </body> </html>Skripti ladataan head-elementissä tavallisena skriptinä. Komponentit on silloin jo määritelty, kun selain jäsentää sivun rungon, joten selain osaa muuttaa <auto-laskuri>-elementin komponentiksi heti. load-tapahtumaa ei tarvitse odottaa.
- Älä käytä type="module"-skriptejä äläkä defer-attribuuttia. Chrome ei suorita moduuliskriptejä XHTML-sivuilla lainkaan, ja defer jätetään XHTML-sivulla huomiotta. Firefoxissa molemmat toimivat, mutta sivun on toimittava kaikissa selaimissa.
- Testaa sivuja sekä Chromessa että Firefoxissa. Selaimet käsittelevät XHTML-sivuja osin eri tavoin, ja tässä ohjauksessa mainitut ansat koskevat pääosin Chromea.
- Oman elementin nimessä on oltava tavuviiva, ja nimi kirjoitetaan pienillä kirjaimilla: auto-laskuri kelpaa, autolaskuri ja AutoLaskuri eivät. Tavuviivasta selain tietää, ettei kyseessä ole html:n oma elementti.
- Sivua voi testata paikallisesti tiedostona. Kehittäjätyökalujen Elements-välilehdellä näkyy komponentin shadow DOM #shadow-root-solmun alla.
Laskuri
- Luo tiedostoon oma.js komponentti, joka näyttää luvun.
"use strict"; class AutoLaskuri extends HTMLElement { // #-alkuinen kenttä on yksityinen: se ei näy komponentin ulkopuolelle. #laskuri = 0; constructor() { super(); // HTMLElementin konstruktori on kutsuttava ensin // Shadow DOM on komponentin oma DOM-puu, johon muun sivun // koodi ja tyylit eivät ulotu. const juuri = this.attachShadow({ mode: "open" }); juuri.textContent = String(this.#laskuri); } } customElements.define("auto-laskuri", AutoLaskuri);customElements.define liittää elementin nimen luokkaan. Selain luo luokasta olion jokaista sivun <auto-laskuri>-elementtiä kohden. Lisää sivulle useampi laskuri ja katso kehittäjätyökaluista, että jokaisella on oma shadow DOM.
Konstruktorissa ei pidä lukea elementin attribuutteja eikä lapsia, koska niitä ei välttämättä ole vielä olemassa. Elementille itselleen ei myöskään saa lisätä lapsia eikä attribuutteja. Shadow DOMiin rakenteen saa luoda.
- Lisää painike, joka kasvattaa laskuria. Rakenne luodaan kerran
konstruktorissa, ja #paivita-metodi muuttaa vain muuttuvat
kohdat:
constructor() { super(); this.#arvo = document.createElement("strong"); this.#painike = document.createElement("button"); this.#painike.type = "button"; this.#painike.textContent = "Lisää"; this.#painike.addEventListener("click", () => this.#lisaa()); const kappale = document.createElement("p"); kappale.append("Autoja laskurissa: ", this.#arvo, " ", this.#painike); const juuri = this.attachShadow({ mode: "open" }); juuri.append(kappale); this.#paivita(); } #lisaa() { this.#laskuri++; this.#paivita(); } #paivita() { this.#arvo.textContent = String(this.#laskuri); }Kentät #arvo ja #painike on esiteltävä luokan alussa kuten #laskuri. Käsittelijä on nuolifunktio, jotta this viittaa komponenttiin eikä painikkeeseen.
Huomaa ongelma, johon palataan ohjauksen lopussa: jokaisen tilan muutoksen jälkeen on muistettava kutsua #paivita-metodia. Jos kutsu unohtuu, ruutu jää näyttämään vanhaa arvoa.
- Lisää laskurille attribuutti lisays, joka kertoo, paljonko
painike kasvattaa laskuria. Attribuutit ovat komponentin tapa ottaa vastaan
tietoa sivulta:
<auto-laskuri lisays="10"></auto-laskuri>// Attribuutit, joiden muutoksista selain ilmoittaa. static observedAttributes = ["lisays"]; /** Attribuutin arvo lukuna. Attribuutit ovat aina merkkijonoja. */ get lisays() { const arvo = this.getAttribute("lisays"); return arvo === null ? 1 : Number(arvo) || 0; } set lisays(arvo) { this.setAttribute("lisays", String(arvo)); } attributeChangedCallback() { this.#paivita(); }Getteri ja setteri tekevät attribuutista myös ominaisuuden: laskuri.lisays = 5 asettaa attribuutin. Näytä lisäyksen määrä painikkeessa (Lisää 10) ja poista painike käytöstä (disabled), kun lisäys on nolla.
- 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.
Laskuri näyttää siksi Chromessa aluksi väärää lisäystä, vaikka myöhemmät
muutokset toimivat.
Piirrä alkutila connectedCallback-metodissa, jota selain kutsuu aina, kun elementti lisätään sivulle:
connectedCallback() { this.#paivita(); } - Laskurin otsikko voisi olla attribuutti, mutta vielä luontevampaa on käyttää
elementin omaa sisältöä. slot-elementti näyttää shadow DOMissa
elementin sisällön:
<auto-laskuri>Henkilöautot</auto-laskuri> <auto-laskuri>Kuorma-autot</auto-laskuri>const otsikko = document.createElement("slot"); otsikko.textContent = "Autoja laskurissa"; // näytetään, jos sisältöä ei ole const kappale = document.createElement("p"); kappale.append(otsikko, ": ", this.#arvo, " ", this.#painike);Nyt laskuria voi käyttää ilman yhtään omaa JavaScript-koodia. Tee sivu, jolla on kolme laskuria liikennelaskentaa varten. Kts. esimerkki.
- Lisää laskurille tyylit. Sivun tyylit eivät ulotu
shadow DOMin sisään, joten komponentti tuo omansa. Periytyvät
ominaisuudet, kuten fontti ja väri, välittyvät silti myös komponentin sisään.
const luoTyylit = function (css) { const tyylit = new CSSStyleSheet(); tyylit.replaceSync(css); return tyylit; }; class AutoLaskuri extends HTMLElement { // Yksi tyylitiedosto kaikille laskureille. static tyylit = luoTyylit(` :host { display: inline-block; padding: 0 0.5rem; border: 1px solid; } `); constructor() { super(); // ... const juuri = this.attachShadow({ mode: "open" }); juuri.adoptedStyleSheets = [AutoLaskuri.tyylit]; // ... } }:host tarkoittaa komponenttia itseään. Tyylitiedosto luodaan kerran luokalle eikä jokaiselle laskurille erikseen, ja samaa tiedostoa voi käyttää useassa komponentissa. Kokeile, vaikuttaako sivun tyylitiedostoon kirjoitettu button { color: red; } laskurin painikkeeseen.
Autolistaus
- Luo komponentti auto-lista, joka näyttää autot listana.
Käytä aineistona seuraavaa taulukkoa:
const AUTOT = [ { id: 1, malli: "Polo", valmistaja: "Volkswagen" }, { id: 2, malli: "Micra", valmistaja: "Nissan" }, { id: 3, malli: "Corsa", valmistaja: "Opel" }, { id: 4, malli: "Kadett", valmistaja: "Opel" }, { id: 5, malli: "1MW Concept", valmistaja: "Toroidion" }, { id: 6, malli: "Fiesta", valmistaja: "Ford" }, { id: 7, malli: "A1", valmistaja: "Audi" }, { id: 8, malli: "Model S", valmistaja: "Tesla" } ];Näytä listassa sekä malli että valmistaja. Jos listassa ei ole yhtään autoa, näytä teksti Ei hakua vastaavia autoja.
- Taulukkoa ei voi antaa tavallisena attribuuttina, koska attribuutit ovat
merkkijonoja. Anna se ominaisuutena, jonka setter piirtää
listan:
#autot = []; get autot() { return this.#autot; } set autot(autot) { this.#autot = autot; this.#paivita(); }document.querySelector("auto-lista").autot = AUTOT;Ensimmäisessä versiossa #paivita voi luoda kaikki rivit joka kerta uudestaan ja korvata vanhat replaceChildren-metodilla.
- Luo komponentti suodatettu-lista, jonka shadow DOMissa on
tekstikenttä ja auto-lista. Suodata vain mallin nimen perusteella.
#autot = []; #suodatin = ""; /** Suodatettu lista lasketaan aina tilasta. Sitä ei talleteta. */ #paivita() { const haku = this.#suodatin.toLowerCase(); this.#lista.autot = this.#autot.filter( (auto) => auto.malli.toLowerCase().includes(haku)); }Suodatettua listaa ei kannata tallettaa omaan muuttujaansa: silloin samasta asiasta olisi kaksi tietoa, jotka pääsevät helposti eri tahtiin. Lasketaan se aina, kun sitä tarvitaan.
Kentän id ja sen label-elementin for toimivat shadow DOMin sisällä normaalisti. Id:n on oltava yksilöllinen vain komponentin oman shadow DOMin sisällä: toisen komponentin samanniminen id ei törmää siihen.
- Autot voi antaa myös attribuuttina, jos attribuutin arvo on
JSON-muotoinen. Silloin listaa voi käyttää ilman omaa
JavaScript-koodia:
<suodatettu-lista autot='[ {"id": 1, "malli": "Polo", "valmistaja": "Volkswagen"}, {"id": 2, "malli": "Micra", "valmistaja": "Nissan"} ]'></suodatettu-lista>Kirjoita attribuutin arvo heittomerkkeihin, jolloin JSONin lainausmerkit voi kirjoittaa sellaisinaan. XHTML-sivulla &-merkki on silti kirjoitettava muodossa &.
Lisää suodatettu-lista-komponenttiin seurattava attribuutti autot, jonka arvo jäsennetään JSON.parse-funktiolla ja sijoitetaan autot-ominaisuuteen. Muista XHTML-sivun ansa: lue attribuutti myös connectedCallback-metodissa. Virheellinen JSON heittää poikkeuksen, joten sieppaa se ja kerro virheestä konsolissa.
Pohdi, milloin kumpikin tapa sopii. Attribuutti sopii alkudatan antamiseen merkkauksessa, mutta jokainen muutos jäsentää koko taulukon uudelleen, eivätkä komponentin tekemät muutokset näy attribuutissa. Ominaisuus sopii ohjelmasta annettavalle ja muuttuvalle datalle.
Valinta ja poisto
- Lisää jokaiselle riville valintapainike ja poistopainike.
Käytä button-elementtejä äläkä span- tai img-elementtejä klikkauksen käsittelijällä. Painike toimii myös näppäimistöllä, ja ruudunlukija kertoo sen painikkeeksi. Anna poistopainikkeelle aria-label-attribuutti, jossa on mukana auton nimi, koska ruudunlukija lukee painikkeet myös irrallaan asiayhteydestään. Valintapainikkeen tilan kertoo aria-pressed-attribuutti.
Rivit syntyvät ja katoavat, joten liitä yksi käsittelijä listaan ja selvitä klikattu painike closest-metodilla (delegointi). Tallenna auton tunniste painikkeen value-ominaisuuteen.
- Valinta toimii kuten valintaruutu: lista vaihtaa auton
valittu-ominaisuuden, piirtää itsensä uudelleen ja kertoo
muutoksesta tapahtumalla. Komponentti kertoo ympäristölleen asioista
tapahtumilla samaan tapaan kuin selaimen omat elementit:
auto.valittu = !auto.valittu; this.#paivita(); this.dispatchEvent(new CustomEvent("valinta", { detail: { auto: auto }, // tapahtuman mukana kulkeva tieto bubbles: true, // kuplii ylöspäin composed: true // ylittää shadow DOMin rajan }));Kokeile, mitä tapahtuu ilman composed-asetusta. Tapahtuma ei pääse ulos shadow DOMista, joten auto-lista-komponentin ulkopuolella sitä ei kuule kukaan.
- Kokeile valintaa näppäimistöllä: siirry sarkaimella valintapainikkeeseen
ja paina Enteriä. Jos #paivita luo rivit joka kerta uudestaan,
kohdistus katoaa painikkeesta, koska painike poistettiin sivulta.
Korjaa asia käyttämällä rivejä uudelleen. Pidä kirjaa riveistä auton tunnisteen perusteella ja siirrä riviä vain, jos se ei jo ole oikealla paikallaan:
/** Auton id -> listan rivi */ #rivit = new Map(); #paivita() { const nakyvat = new Set(); for (const [sijainti, auto] of this.#autot.entries()) { let rivi = this.#rivit.get(auto.id); if (rivi === undefined) { rivi = this.#luoRivi(auto.id); this.#rivit.set(auto.id, rivi); } // ... päivitä rivin tekstit ja aria-pressed if (this.#lista.children[sijainti] !== rivi) { this.#lista.insertBefore(rivi, this.#lista.children[sijainti] ?? null); } nakyvat.add(auto.id); } // Poistetaan rivit, joita ei enää näytetä. for (const [id, rivi] of this.#rivit) { if (!nakyvat.has(id)) { rivi.remove(); this.#rivit.delete(id); } } }Tunnisteena on auton id eikä taulukon indeksi, koska indeksi muuttuu suodatuksen ja poistojen mukana.
- Poistosta lista vain kertoo poisto-tapahtumalla. Auto voi
muuttaa itseään, mutta sen poistaminen taulukosta kuuluu taulukon
omistajalle, joka tässä on suodatettu-lista. Kuuntele
tapahtumaa sen shadow DOMissa:
juuri.addEventListener("poisto", (e) => { const sijainti = this.#autot.indexOf(e.detail.auto); if (sijainti !== -1) { this.#autot.splice(sijainti, 1); this.#paivita(); } });Tapahtuma jatkaa matkaansa ylöspäin, joten myös sivu tai ylempi komponentti voi reagoida siihen.
- Kokeile JSON-attribuutilla annettua listaa sivulla, jolla ei ole omaa JavaScript-koodia. Suodatuksen, valinnan ja poiston pitäisi toimia.
Sovellus
Kootaan komponentit yhdeksi sovellukseksi. Myös sovellus on komponentti, ja se luo muut komponentit omaan shadow DOMiinsa:
<auto-sovellus></auto-sovellus>
<auto-sovellus> tila: autotaulukko
<auto-laskuri> attribuutti lisays, sisältö otsikkona
<auto-lomake> tapahtuma lisays (tehdään seuraavaksi)
<suodatettu-lista> ominaisuus autot, tapahtuma poisto
<auto-lista> ominaisuus autot, tapahtumat valinta ja poisto
Tieto kulkee alaspäin attribuutteina ja ominaisuuksina ja ylöspäin tapahtumina.
- Luo komponentti auto-sovellus, jonka tilassa on autotaulukko.
Tila sijoitetaan siihen komponenttiin, jonka alapuolella kaikki sitä
tarvitsevat komponentit ovat.
#autot = AUTOT.map((auto) => ({ ...auto })); // kopiot, alkuperäiset säilyvät constructor() { super(); this.#laskuri = document.createElement("auto-laskuri"); this.#laskuri.textContent = "Valittuja autoja laskurissa"; // slotin sisältö this.#lista = document.createElement("suodatettu-lista"); this.#yhteenveto = document.createElement("p"); const juuri = this.attachShadow({ mode: "open" }); juuri.append(this.#laskuri, this.#lista, this.#yhteenveto); juuri.addEventListener("valinta", () => this.#paivita()); juuri.addEventListener("poisto", () => this.#paivita()); } connectedCallback() { this.#paivita(); }Taulukko annetaan listalle viittauksena. Lista muuttaa samaa taulukkoa, ja tapahtumat kertovat sovellukselle, että muuta näkymää on päivitettävä.
- Aseta laskuri kasvamaan valittujen autojen lukumäärällä ja näytä
sovelluksessa teksti Valittuna 2/8 autoa.
On houkuttelevaa lisätä uusi muuttuja valittujen määrälle. Älä tee sitä: silloin samasta asiasta olisi kaksi tietoa. Laske määrä tilasta aina tarvittaessa:
#paivita() { const valittuja = this.#autot.filter((auto) => auto.valittu).length; this.#laskuri.lisays = valittuja; this.#lista.autot = this.#autot; this.#yhteenveto.textContent = "Valittuna " + valittuja + "/" + this.#autot.length + " autoa."; } - Määrittelyjärjestys ratkaisee. Jos ominaisuuden asettaa
elementille, jonka komponenttia ei ole vielä määritelty, elementille syntyy
tavallinen oma ominaisuus. Se peittää luokan setterin myös sen jälkeen, kun
komponentti on määritelty, eikä setteriä kutsuta koskaan. Määrittele siksi
komponentit ennen komponentteja, jotka käyttävät niitä:
auto-sovellus tiedoston viimeisenä.
Kokeile siirtää auto-lista-komponentin määrittely tiedoston loppuun ja katso, mitä tapahtuu.
- Kerro muutoksista myös niille, jotka eivät näe niitä. Lisää sovellukseen kappale, jolla on attribuutti aria-live="polite" ja joka on piilotettu näkyviltä mutta ei ruudunlukijalta. Kirjoita siihen esimerkiksi Polo poistettu. Poistettu auto saadaan tapahtuman detail-ominaisuudesta.
Lomake
Lisää lomake, jolla voi lisätä uusia autoja listaan.
- Tee lomakkeesta oma komponentti auto-lomake. Lomake,
kentät ja painike ovat sen shadow DOMissa. Lue kenttien arvot lähetettäessä
FormData-oliosta ja kerro lisäyksestä
lisays-tapahtumalla:
#laheta(e) { e.preventDefault(); const tiedot = new FormData(this.#lomake); const malli = String(tiedot.get("malli")).trim(); // ... tarkistus this.dispatchEvent(new CustomEvent("lisays", { detail: { malli: malli, valmistaja: String(tiedot.get("valmistaja")) }, bubbles: true, composed: true })); this.#lomake.reset(); this.#malli.focus(); }Lomake tyhjennetään sen omalla reset-metodilla, ja kohdistus siirretään mallikenttään, jotta seuraavan auton voi kirjoittaa heti.
- Lisää virheenkäsittely: jos mallikenttä on tyhjä, älä tee lisäystä vaan
näytä virheilmoitus. Merkitse kenttä pakolliseksi
(required), jolloin selain estää tyhjän lomakkeen lähettämisen.
Pelkkiä välilyöntejä selain ei kuitenkaan tunnista tyhjäksi.
Oman ilmoituksen voi asettaa setCustomValidity-metodilla, ja selaimen muotoilema ilmoitus löytyy validationMessage-ominaisuudesta. Näytä ilmoitus kentän vieressä ja yhdistä se kenttään aria-describedby-attribuutilla. Muista tyhjentää ilmoitus, kun käyttäjä muuttaa kenttää: muuten kenttä jää virheelliseksi eikä lomaketta voi lähettää.
/* :user-invalid osuu vasta, kun käyttäjä on itse muokannut kenttää. Pelkkä :invalid värittäisi tyhjän pakollisen kentän heti. */ input:user-invalid { border: 2px solid #a00000; } - Uuden auton tunniste on laskettava huolella. Taulukon pituus ei kelpaa:
poistojen jälkeen se tuottaa jo käytössä olevan arvon, ja kahdella autolla
olisi sama tunniste.
const uusiId = this.#autot.reduce((suurin, auto) => Math.max(suurin, auto.id), 0) + 1; - Tee valmistajan valinnasta oma komponentti valinta-lista, jonka
shadow DOMissa on select-elementti. Anna vaihtoehdot
ominaisuutena:
const valmistaja = document.createElement("valinta-lista"); valmistaja.setAttribute("name", "valmistaja"); valmistaja.setAttribute("otsikko", "Valmistaja"); valmistaja.vaihtoehdot = VALMISTAJAT;const VALMISTAJAT = [ "Audi", "Ford", "Nissan", "Opel", "Tesla", "Toroidion", "Volkswagen" ];Shadow DOMissa oleva select ei kuulu ympäröivään lomakkeeseen, joten sen arvo ei tule FormDataan. Tee komponentista lomakkeeseen osallistuva, jolloin se toimii lomakkeessa kuten selaimen omat kentät:
class ValintaLista extends HTMLElement { static formAssociated = true; #sisaiset = this.attachInternals(); // Aina kun valinta muuttuu: // this.#sisaiset.setFormValue(this.#valinta.value); /** Selain kutsuu, kun lomake palautetaan alkutilaan. */ formResetCallback() { this.value = this.#valinta.options[0]?.value ?? ""; } }Arvo tulee FormDataan komponentin name-attribuutin nimellä, ja lomakkeen reset palauttaa valinnan alkuun. Lisää komponentille myös value-ominaisuus.
- Selaimen change-tapahtuma ei ylitä shadow DOMin rajaa.
Jos komponentin käyttäjän pitää tietää valinnan muuttumisesta, komponentin on
lähetettävä oma tapahtumansa:
this.#valinta.addEventListener("change", () => { this.#sisaiset.setFormValue(this.#valinta.value); this.dispatchEvent(new Event("change", { bubbles: true })); }); - Lisää auto-sovellus-komponenttiin lomake ja kuuntele sen lisays-tapahtumaa. Lisää uusi auto taulukkoon ja kutsu #paivita-metodia.
Fetch ja elinkaari
Tämä osa on vapaaehtoinen. Haetaan autolistaus palvelimelta sen sijaan, että se kirjoitettaisiin koodiin.
- Hae autot tiedostosta autot.json auto-sovellus-komponentin connectedCallback-metodissa. Fetch ei toimi file:-osoitteesta, joten sivu on testattava www-palvelimella.
- Lisää kolme asiaa, jotka jäävät helposti tekemättä: HTTP-virheen
tarkistus (fetch ei hylkää lupausta virhekoodista),
latausilmoitus ja haun peruutus, jos komponentti poistetaan sivulta ennen
kuin vastaus saapuu.
#ohjain = null; connectedCallback() { this.#ohjain = new AbortController(); this.#hae(this.#ohjain.signal); } /** Selain kutsuu, kun elementti poistetaan sivulta. */ disconnectedCallback() { this.#ohjain.abort(); } async #hae(signaali) { // ... fetch("autot.json", { signal: signaali }) }Perutun haun lupaus hylätään AbortError-virheellä. Se ei ole oikea virhe, joten älä näytä siitä virheilmoitusta. Selain voi kutsua connectedCallback-metodia useammin kuin kerran, jos elementti siirretään sivulla. Jokainen kutsu aloittaa uuden haun, ja disconnectedCallback peruu edellisen.
- Lisää painike, joka hakee datan uudelleen. Peru edellinen haku, jos se on vielä kesken: muuten hitaasti saapuva vanha vastaus voisi korvata uudemman. Kts. mallivastaus.
Reaktiivisuus
Sovelluksessa on toistuva kuvio: tila muuttuu, ja sen jälkeen on muistettava kutsua #paivita-metodia. Sovellus kuuntelee valinta-tapahtumaa pelkästään siksi, että se osaisi päivittää laskurin. Reaktiivisuus kääntää asetelman: näkymä päivittyy itsestään aina, kun sen käyttämä tila muuttuu.
- Tutustu kirjastoon reaktiivisuus.js.
Se on lyhyt ja runsaasti kommentoitu kirjasto, joka perustuu
Proxy-olioihin:
const { reactive, effect, computed, bind } = Reactivity; const tila = reactive({ laskuri: 0 }); // Ajetaan heti ja uudelleen aina, kun tila.laskuri muuttuu. effect(() => { arvo.textContent = String(tila.laskuri); }); tila.laskuri++; // näkymä päivittyy itsestäänreactive käärii olion Proxy-olioon. Kun efekti lukee olion ominaisuuden, Proxy kirjaa, että efekti riippuu siitä. Kun ominaisuutta muutetaan, Proxy ajaa uudelleen ne efektit, jotka lukivat sen. Efektit ajetaan vasta tapahtumankäsittelijän jälkeen, joten monta muutosta aiheuttaa vain yhden päivityksen.
Funktioiden nimet ovat englanniksi, koska samat käsitteet ja nimet (reactive, effect, computed, bind) toistuvat yleisissä JavaScript-kirjastoissa.
Lataa kirjasto ennen omaa skriptiäsi:
<script src="reaktiivisuus.js"></script> <script src="oma.js"></script> - Tee laskurista yksisuuntaisesti reaktiivinen: tila
vaikuttaa näkymään, mutta näkymä ei kirjoita tilaan muuten kuin
tapahtumankäsittelijän kautta. Siirrä laskurin tila reaktiiviseen olioon ja
korvaa #paivita-metodi efektillä:
#tila = reactive({ laskuri: 0, lisays: 1 }); constructor() { super(); // ... rakenne kuten ennenkin painike.addEventListener("click", () => { this.#tila.laskuri += this.#tila.lisays; }); effect(() => { arvo.textContent = String(this.#tila.laskuri); painike.textContent = "Lisää " + this.#tila.lisays; painike.disabled = this.#tila.lisays === 0; }); } attributeChangedCallback() { const arvo = this.getAttribute("lisays"); this.#tila.lisays = arvo === null ? 1 : Number(arvo) || 0; }Painike ja attribuutti muuttavat nyt vain tilaa. #paivita-metodia ei ole, joten sen kutsua ei voi unohtaa.
- Tee autovalinnasta kaksisuuntaisesti reaktiivinen: lista
sekä lukee sovelluksen tilaa että muuttaa sitä, ja muutokset näkyvät molempiin
suuntiin.
Aloita suodatuskentästä. bind sitoo lomakkeen kentän reaktiivisen olion ominaisuuteen: kirjoittaminen muuttaa tilaa ja tilan muuttaminen kenttää.
#tila = reactive({ suodatin: "", autot: [] }); // konstruktorissa bind(kentta, this.#tila, "suodatin"); effect(() => { const haku = this.#tila.suodatin.toLowerCase(); lista.autot = this.autot.filter((auto) => auto.malli.toLowerCase().includes(haku)); });Lisää komponentille suodatin-ominaisuus, joka lukee ja kirjoittaa tilaa. Kokeile kehittäjätyökalujen konsolissa: kun asetat ominaisuudelle arvon, kentän teksti muuttuu.
- Sido sovelluksen autotaulukko listaan. Lisää
suodatettu-lista-komponenttiin metodi
bind(olio, avain). Sen kutsumisen jälkeen lista lukee autot olion
ominaisuudesta oman taulukkonsa sijaan:
// auto-sovellus #tila = reactive({ autot: AUTOT.map((auto) => ({ ...auto })) }); constructor() { // ... lista.bind(this.#tila, "autot"); effect(() => { const valittuja = this.#tila.autot.filter((auto) => auto.valittu).length; laskuri.lisays = valittuja; yhteenveto.textContent = "Valittuna " + valittuja + "/" + this.#tila.autot.length + " autoa."; }); } #lisaa(malli, valmistaja) { // push riittää: Proxy huomaa uuden alkion, ja lista päivittyy. this.#tila.autot.push({ id: uusiId, malli: malli, valmistaja: valmistaja }); }Tee myös auto-lista-komponentista reaktiivinen: listan piirtävä efekti lukee jokaisen auton valittu-ominaisuuden, joten lista päivittyy, kun valinta muuttuu. Valinta muuttaa auton valittu-ominaisuutta suoraan, eikä sovelluksen tarvitse enää kuunnella valinta-tapahtumaa eikä kutsua #paivita-metodia. Kts. mallivastaus.
- Pohdi seuraavia kysymyksiä:
- Sovelluksen efekti lukee jokaisen auton valittu-ominaisuuden filter-kutsussa. Miksi se riittää siihen, että efekti ajetaan uudelleen, kun lista muuttaa valintaa?
- Mitä tapahtuu, jos efekti muuttaa tilaa, jota se itse lukee?
- Efektejä ei koskaan pysäytetä. Mitä seuraa, jos komponentteja lisätään ja poistetaan sivulta jatkuvasti?
- Kumpi on helpompi ymmärtää: tapahtumat ja #paivita-kutsut vai reaktiivinen tila? Kummassa virhe on helpompi löytää?
Template-elementti
Tähän asti komponenttien rakenne on luotu createElement-kutsuilla. Rakenteen voi kirjoittaa myös tavallisena merkkauksena template-elementtiin, jonka komponentti kloonaa itselleen. Tehdään komponentti auto-kortti, joka näyttää auton tiedot kortissa. Kts. esimerkki.
- Kirjoita kortin rakenne ja tyylit sivun head-elementtiin
ennen skriptiä:
<template id="auto-kortti-pohja"> <style> :host { display: inline-block; padding: 0.5rem 1rem; border: 1px solid; } .malli { margin: 0; font-weight: bold; } </style> <p class="malli"><slot name="malli">Nimetön malli</slot></p> <p><slot name="valmistaja">Valmistaja ei tiedossa</slot></p> <div><slot></slot></div> </template>template-elementin sisältö ei näy sivulla. Selain jäsentää sen, mutta ei piirrä sitä, lataa sen kuvia eikä suorita sen skriptejä. Koska pohja on osa sivun merkkausta, validaattori tarkistaa sen kuten muunkin XHTML:n.
Tyylit voi kirjoittaa pohjan sisään style-elementtiin. Kun pohja kloonataan shadow DOMiin, tyylit koskevat vain komponenttia.
- Kloonaa pohja komponentin shadow DOMiin:
class AutoKortti extends HTMLElement { constructor() { super(); const pohja = document.getElementById("auto-kortti-pohja"); this.attachShadow({ mode: "open" }).append(pohja.content.cloneNode(true)); } } customElements.define("auto-kortti", AutoKortti);Pohjan sisältö on content-ominaisuudessa (DocumentFragment), ja cloneNode(true) kopioi sen kokonaan. Kokeile lisätä sivulle kaksi korttia ja jättää kloonaus pois. Mitä tapahtuu ja miksi?
- Kortin tiedot annetaan elementin sisältönä nimettyihin
slotteihin. Sisältö, jolla ei ole slot-attribuuttia,
näkyy nimettömässä slotissa:
<auto-kortti> <span slot="malli">Polo</span> <span slot="valmistaja">Volkswagen</span> Pieni ja suosittu perheauto. </auto-kortti>Jos kortilta puuttuu tieto, slotissa näkyy sen oletussisältö. Elementin omaa sisältöä, joka näkyy slotissa, voi muotoilla komponentin tyyleissä ::slotted-valitsimella:
::slotted([slot="valmistaja"]) { text-transform: uppercase; }Nyt korttia voi käyttää ilman yhtään omaa JavaScript-koodia, kuten laskuria ja autolistausta.
- Siirrä template-elementti sivun loppuun. Kortit jäävät tyhjiksi, koska selain luo kortin heti, kun se jäsentää elementin. Myöhemmin tulevaa pohjaa ei silloin ole vielä olemassa. Tarkista komponentissa, että pohja löytyy, ja kerro virheestä konsolissa.
- XHTML-sivun ansa: verkosta löytyy ohjeita deklaratiivisesta shadow DOMista, jossa shadow root kirjoitetaan suoraan sivulle (<template shadowrootmode="open">). Se toimii vain HTML-sivulla. XHTML-sivulla shadow rootia ei synny Chromessa eikä Firefoxissa, ja template-elementti jää sivulle sellaisenaan.
- Pohdi, milloin rakenne kannattaa kirjoittaa sivun pohjaan ja milloin komponentin koodiin. Sivun pohja on helppo lukea ja muokata, mutta komponentti toimii vain sivulla, jolla pohja on. Koodissa luotu rakenne kulkee komponentin mukana kaikille sivuille.
Lopuksi
- Kertaa, millä keinoilla komponentti on yhteydessä ympäristöönsä:
Keino Suunta Esimerkki attribuutti sivulta komponenttiin, merkkijono lisays="10" ominaisuus ohjelmasta komponenttiin, mikä tahansa arvo lista.autot = taulukko sisältö ja slot sivulta komponenttiin, merkkaus <auto-laskuri>Henkilöautot</auto-laskuri> template-elementti sivulta komponenttiin, rakenne ja tyylit auto-kortti-pohja tapahtuma komponentista ympäristöön poisto, lisays lomakkeeseen osallistuminen komponentista lomakkeeseen valinta-lista sidonta kumpaankin suuntaan lista.bind(tila, "autot") - Kertaa XHTML-sivujen ansat. Chrome ei suorita moduuliskriptejä eikä noudata defer-attribuuttia XHTML-sivulla. Chromessa merkkaukseen kirjoitetut attribuutit eivät laukaise attributeChangedCallback-metodia, joten alkutila luetaan connectedCallback-metodissa. Deklaratiivinen shadow DOM ei toimi XHTML-sivulla missään selaimessa.
Lisätietoa
- Web components (MDN)
- CustomEvent() ja Event.composed
- ShadowRoot.adoptedStyleSheets
- ::slotted()
- ElementInternals — lomakkeeseen osallistuvat komponentit
- Proxy
Käyttäjien kommentit