Hoppa till innehåll

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 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 ett NotSupportedError. I den sista artikeln ser du hur kursens komponenter skyddar sig mot det.
  • customElements.get('user-card') ger klassen om namnet är registrerat, annars undefined.

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.

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>
js/app.js
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:

js/app.js
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.

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.

Tillståndsdiagram över ett custom elements livscykel. Ett element som står i HTML-koden innan define har körts är odefinierat, ett vanligt HTMLElement. customElements.define uppgraderar det till tillståndet Skapat, där constructor körs. Ett element som skapas med createElement efter define hamnar direkt i Skapat. När elementet läggs in i dokumentet blir det Anslutet och connectedCallback körs. När det tas bort blir det Frånkopplat och disconnectedCallback körs, och läggs det in igen blir det Anslutet på nytt. En anteckning säger att attributeChangedCallback anropas när ett observerat attribut ändras, oavsett tillstånd, och för attribut som redan finns när elementet skapas, före connectedCallback.

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.innerHTML eller this.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.

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 while
document.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.

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ån observedAttributes. Listan är static, eftersom den gäller klassen och läses av webbläsaren när define() 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 oldValue sparar onödigt arbete.
  • newValue är null när attributet tas bort. Samma sak ger getAttribute() 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 med Number(this.getAttribute('speed')).
  • Texten sätts med textContent, inte med innerHTML. Attributet kommer utifrån, och ska därför behandlas som data, precis som i kursvecka 9.

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>
  1. constructor()
  2. attributeChangedCallback('name', null, 'Ada Lovelace')
  3. 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.

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.

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.

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 ett TypeError, 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.

  • Ett custom element är en klass som ärver från HTMLElement och registreras en gång med customElements.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 i disconnectedCallback().
  • attributeChangedCallback() anropas för attributen i observedAttributes, ä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.
CCBY