Custom elements
I kursvecka 8 skapade du ett första eget element, och i kursvecka 9 lärde du dig ändra i DOM:en och skriva HTML som mallar. Nu sätter du ihop delarna. Den här artikeln handlar om själva elementet: vad som händer när det registreras, när webbläsaren anropar dess metoder och hur det tar emot data utifrån. Nästa artikel ger elementet ett eget inre med shadow DOM, och den sista visar hur en komponent organiseras i filer.
Tidsåtgång: Ungefär 15 minuter.
Några korta frågor finns insprängda i texten. Försök svara innan du klickar fram lösningen.
Ett element bland andra
Section titled “Ett element bland andra”Ett custom element är en klass som ärver från HTMLElement och registreras under ett namn:
class UserCard extends HTMLElement { connectedCallback() { console.log('user-card is now in the document') }}
customElements.define('user-card', UserCard)Från och med att define() har körts är <user-card> ett element som alla andra. Det kan stå i
HTML-koden, skapas med document.createElement('user-card'), väljas ut med querySelector() och
stylas med CSS. Varje <user-card> på sidan är en instans av klassen, så instanceof UserCard ger
true, och metoder du skriver i klassen kan anropas på elementet.
Reglerna för namnet, och varför det måste innehålla ett bindestreck, gick du igenom i introduktionen i kursvecka 8. Delen före bindestrecket fungerar som ett prefix som minskar risken att ditt element krockar med någon annans. Två regler till är bra att känna till:
- Ett namn kan bara registreras en gång per sida, och samma klass kan bara registreras under ett
namn. Ett andra anrop till
define()med samma namn kastar ettNotSupportedError. I den sista artikeln ser du hur kursens komponenter skyddar sig mot det. customElements.get('user-card')ger klassen om namnet är registrerat, annarsundefined.
Innan elementet är definierat
Section titled “Innan elementet är definierat”HTML-koden kan innehålla <user-card> långt innan define() har körts. Webbläsaren skapar då ändå
ett element i trädet, men ett odefinierat element: ett vanligt HTMLElement utan ditt beteende.
Det syns i trädet, men har inga av klassens metoder.
När define() sedan körs letar webbläsaren upp alla <user-card> som redan finns i dokumentet och
uppgraderar dem. Konstruktorn körs, och elementet blir en instans av din klass. Element som
skapas efter define() blir instanser direkt.
Ett namn utan bindestreck, till exempel <usercard>, blir däremot ett HTMLUnknownElement och kan
aldrig uppgraderas.
Fråga: varför finns inte metoden?
Section titled “Fråga: varför finns inte metoden?”Klassen UserCard har en metod clear(). Sidan laddar två moduler i den här ordningen:
<script type="module" src="js/app.js"></script><script type="module" src="js/components/user-card/index.js"></script>document.querySelector('user-card').clear()<user-card> står i HTML-koden, och index.js registrerar komponenten. Ändå kastar app.js
TypeError: ... clear is not a function. Varför?
Moduler körs i den ordning de står i dokumentet. När app.js körs har index.js ännu inte körts,
så define() har inte anropats. Elementet är fortfarande odefinierat, ett vanligt HTMLElement,
och har ingen metod clear().
Lösningen är inte att byta plats på skripten, utan att göra beroendet synligt i koden:
import './components/user-card/index.js'
document.querySelector('user-card').clear()En modul körs först när alla dess importer har körts, så komponenten är garanterat definierad när
resten av app.js körs. Det är så kursens övningsuppgifter laddar sina komponenter. Mer om det i
artikeln En komponents filer.
Odefinierade element kan för övrigt väljas ut i CSS med :not(:defined), till exempel för att dölja
dem tills de har uppgraderats och inte visa ett halvfärdigt element medan sidan laddas.
Livscykeln
Section titled “Livscykeln”Webbläsaren anropar vissa metoder i klassen vid bestämda tillfällen. De kallas lifecycle callbacks:
| Metod | Anropas när |
|---|---|
constructor() |
elementet skapas eller uppgraderas |
connectedCallback() |
elementet läggs in i dokumentet |
disconnectedCallback() |
elementet tas bort ur dokumentet |
attributeChangedCallback() |
ett observerat attribut läggs till, ändras eller tas bort |
adoptedCallback() |
elementet flyttas till ett annat dokument, vilket sällan händer |
Du skriver bara de metoder du behöver. Alla är frivilliga.
Konstruktorn
Section titled “Konstruktorn”I konstruktorn förbereder du elementet: anropar super() först av allt, skapar elementets shadow
DOM och sparar referenser till det du behöver senare. Standarden ställer några krav på vad
konstruktorn inte får göra:
- Den får inte lägga till barn eller attribut på elementet självt, till exempel med
this.innerHTMLellerthis.setAttribute(). - Den bör inte läsa elementets attribut eller barn. Beroende på hur elementet skapas finns de ännu inte.
Bryter du mot det första kravet kastar document.createElement('user-card') felet The result must
not have children. För ett element som i stället uppgraderas ser det ut att fungera, vilket gör
felet svårt att hitta. Regeln finns för att elementet ska bete sig likadant oavsett hur det skapas,
och den är skälet till att komponentens innehåll läggs i en shadow DOM, som du läser om i
nästa artikel.
Ansluten och frånkopplad
Section titled “Ansluten och frånkopplad”connectedCallback() anropas varje gång elementet läggs in i dokumentet, och
disconnectedCallback() varje gång det tas bort. Det kan alltså hända flera gånger under elementets
liv, till exempel om det flyttas med append(). Därför är connectedCallback() platsen för sådant
som ska vara igång bara medan elementet syns, som händelselyssnare och timers, och
disconnectedCallback() platsen där de stängs av.
Kursens övningsuppgifter använder ett AbortController för att stänga av alla lyssnare på en gång.
Så här ser det ut i komponenten tiny-tune i övningsuppgiften Tiny Tunes, något förkortat:
export class TinyTune extends HTMLElement { #button #abortController
constructor() { super() // ... create the shadow DOM (see the next article) this.#button = this.shadowRoot.querySelector('button') }
connectedCallback() { this.#abortController = new AbortController()
this.#button.addEventListener('click', () => this.#playTunes(), { signal: this.#abortController.signal }) }
disconnectedCallback() { this.#abortController.abort() }
// ...}Varje lyssnare som läggs till med signal tas bort när abort() anropas. Du slipper spara en
referens till varje lyssnarfunktion för att kunna anropa removeEventListener(). Ett nytt
AbortController skapas vid varje anslutning, eftersom ett som redan har avbrutits inte kan
användas igen. Händelser är huvudämnet i kursvecka 11; här räcker det att du känner igen mönstret.
Fråga: konstruktorn eller connectedCallback()?
Section titled “Fråga: konstruktorn eller connectedCallback()?”I steg 9 i Tiny Tunes ska komponenten tiny-time visa klockslaget då den lades till i dokumentet.
Spelar det någon roll om tiden läses av i konstruktorn eller i connectedCallback()?
const time = document.createElement('tiny-time')// ... other code that takes a whiledocument.body.append(time)Ja. Konstruktorn körs när createElement() anropas, och connectedCallback() när elementet läggs
in med append(). Uppgiften frågar efter tiden då komponenten lades till, så tiden ska läsas av
i connectedCallback(). Oftast skiljer det bara millisekunder, men elementet kan skapas långt innan
det används, eller tas bort och läggas in igen. Då är det bara connectedCallback() som ger rätt
tid.
Attribut
Section titled “Attribut”Ett element får data utifrån på samma sätt som de inbyggda: genom attribut.
<user-card name="Ada Lovelace"></user-card>För att reagera när ett attribut ändras talar du om vilka attribut elementet ska observera, och
skriver metoden attributeChangedCallback():
export class UserCard extends HTMLElement { #name
constructor() { super() // ... create the shadow DOM this.#name = this.shadowRoot.querySelector('.name') }
static observedAttributes = ['name']
attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) { return }
if (name === 'name') { this.#name.textContent = newValue ?? 'Unknown' } }}Några saker att lägga märke till:
attributeChangedCallback()anropas bara för attribut som finns i listan frånobservedAttributes. Listan ärstatic, eftersom den gäller klassen och läses av webbläsaren närdefine()körs. I äldre kod, och i en del av kursens övningsuppgifter, skrivs den som en statisk getter,static get observedAttributes() { return ['name'] }. Det fungerar likadant.- Metoden anropas även när attributet sätts till samma värde som det redan har. Jämförelsen med
oldValuesparar onödigt arbete. newValueärnullnär attributet tas bort. Samma sak gergetAttribute()för ett attribut som saknas, vilket gör??till ett naturligt sätt att ange ett standardvärde. Men saknas attributet redan när elementet skapas anropas metoden aldrig, eftersom inget har ändrats. Därför står standardvärdet också i komponentens mall, som du ser i nästa artikel.- Attributvärden är alltid strängar. Ett attribut som
speed="50"måste göras om till ett tal, till exempel medNumber(this.getAttribute('speed')). - Texten sätts med
textContent, inte medinnerHTML. Attributet kommer utifrån, och ska därför behandlas som data, precis som i kursvecka 9.
Fråga: vilken metod körs först?
Section titled “Fråga: vilken metod körs först?”UserCard har både attributeChangedCallback() och connectedCallback(), och define() har redan
körts. I vilken ordning anropas metoderna när webbläsaren läser den här raden i HTML-koden?
<user-card name="Ada Lovelace"></user-card>constructor()attributeChangedCallback('name', null, 'Ada Lovelace')connectedCallback()
Attribut som redan finns när elementet skapas räknas som ändrade från null, och de rapporteras
innan elementet ansluts. Det betyder att du kan lita på att attributeChangedCallback() har körts
för alla observerade attribut när connectedCallback() anropas. Det betyder också att
attributeChangedCallback() kan anropas innan elementet finns i dokumentet, så allt metoden behöver
måste vara förberett i konstruktorn.
Gemener i attributnamn
Section titled “Gemener i attributnamn”HTML skiljer inte på versaler och gemener i attributnamn. Webbläsaren gör om dem till gemener när
den läser HTML-koden, och setAttribute() gör likadant. Ett attribut som står i
observedAttributes som 'timeLimit' matchar därför aldrig något, eftersom attributet heter
timelimit när det når elementet. Skriv attributnamn med bara gemener, och använd bindestreck om
namnet har flera ord, som time-limit.
Attribut och metoder, inte egenskaper
Section titled “Attribut och metoder, inte egenskaper”Inbyggda element har ofta både ett attribut och en egenskap för samma sak: <img src="..."> och
img.src. Det ligger nära till hands att ge ditt element samma sak, med en getter och en setter
som läser och skriver attributet. Kursens komponenter gör medvetet inte det, på grund av en fälla
som hänger ihop med uppgraderingen.
Fråga: varför sätts inget namn?
Section titled “Fråga: varför sätts inget namn?”UserCard har en setter som skriver attributet name:
set name(value) { this.setAttribute('name', value)}Men app.js hinner köras innan komponenten är definierad:
const card = document.querySelector('user-card')card.name = 'Grace Hopper'Inget fel kastas. När define() sedan körs och elementet uppgraderas visas ändå inget namn, och
elementet saknar attributet name. Varför?
När raden körs är elementet odefinierat, ett vanligt HTMLElement utan din setter. Tilldelningen
skapar därför en vanlig egenskap name direkt på objektet.
När elementet sedan uppgraderas blir det en instans av UserCard, men egenskapen som redan finns på
objektet skuggar setterns egenskap i klassen. Settern körs aldrig, setAttribute() anropas
aldrig och attributeChangedCallback() får aldrig veta något. Värdet hamnar i en egenskap som
komponenten inte läser.
Med card.setAttribute('name', 'Grace Hopper') hade det fungerat. Ett attribut finns kvar på
elementet vid uppgraderingen, och attributeChangedCallback() anropas för det innan
connectedCallback().
Fällan går att komma runt, men det är enklare att undvika den. Kursens komponenter följer därför två regler:
- Värden som går att skriva som text, som namn, tal och av/på, skickas som attribut med
setAttribute(). Det fungerar likadant före och efter uppgraderingen. - Handlingar hanteras med metoder, som
clear()i övningsuppgiften bart-board. Anropas en metod innan elementet är definierat blir det ettTypeError, ett fel som syns, till skillnad från en egenskap som tyst hamnar fel.
Reglerna gäller dina egna element. Inbyggda element, även de i komponentens shadow root, är
alltid definierade, så fällan finns inte där. För dem använder du egenskaperna, som i
kursvecka 9: image.src,
input.value, button.disabled.
Data skickas alltså ner till en komponent via attribut och metoder. Hur komponenten skickar data upp, till den som använder den, är ämnet för kursvecka 11: med egna händelser.
Att ta med sig
Section titled “Att ta med sig”- Ett custom element är en klass som ärver från
HTMLElementoch registreras en gång medcustomElements.define(). - Element som finns i dokumentet innan
define()uppgraderas. Importera komponenten i den modul som använder den, så är den definierad i tid. - Konstruktorn förbereder, men lägger inte till barn eller attribut på elementet. Lyssnare och
timers startas i
connectedCallback()och stängs av idisconnectedCallback(). attributeChangedCallback()anropas för attributen iobservedAttributes, även för dem som finns när elementet skapas. Attributvärden är strängar, och attributnamn skrivs med gemener.- Data skickas in med
setAttribute()och handlingar med metoder. Getters och setters för attributvärden kan tyst sluta fungera om de används innan elementet är uppgraderat.
Vidare läsning
Section titled “Vidare läsning”- Läsanvisningarna för veckan, i synnerhet kapitel 15.6.3 i kursboken.
- Using custom elements på MDN.
- :defined på MDN.