Hoppa till innehåll

Händelser i webbkomponenter

En komponent har två sorters kontakt med händelser. Den reagerar på sådant som händer den: användaren klickar, trycker ned musknappen eller skriver. Och den berättar för omvärlden att något har hänt inuti den: tavlan är full, tiden är slut, användaren har svarat. Den här artikeln handlar om båda, och bygger vidare på custom elements i kursvecka 10 och de två föregående artiklarna.

Exemplen kommer från veckans övningsuppgift, bart-board, och från komponenten user-card från kursvecka 10, som här har fått en knapp, Follow, i sitt shadow DOM.

Tidsåtgång: Ungefär 15 minuter.

I kursvecka 10 såg du regeln: lyssnare och timers startas i connectedCallback() och stängs av i disconnectedCallback(), med ett AbortController. Här är skälen.

En komponent som reagerar på kortkommandon lyssnar på document, eftersom tangenttryckningar inte går till komponenten:

connectedCallback() {
this.#abortController = new AbortController()
document.addEventListener('keydown', (event) => this.#onKeyDown(event), {
signal: this.#abortController.signal
})
}
disconnectedCallback() {
this.#abortController.abort()
}

document finns kvar så länge sidan är öppen, och därmed lyssnaren också. Lyssnaren är en pilfunktion som refererar till this, alltså komponenten. Tas komponenten bort utan att lyssnaren tas bort händer två saker: komponenten fortsätter att reagera på tangenttryckningar fast den inte syns, och skräpsamlaren kan aldrig städa bort den, eftersom document fortfarande håller en referens till den. Lägger sidan till och tar bort komponenten många gånger växer minnet för varje gång.

Lyssnare på document, window och andra element utanför komponenten måste alltså tas bort när komponenten tas bort, och läggas till igen om den läggs in igen. Det är precis vad disconnectedCallback() och connectedCallback() är till för.

Lyssnare på komponenten själv, this, eller på element i dess shadow DOM är annorlunda. De refererar bara inåt, och försvinner tillsammans med komponenten. Tekniskt sett skulle de kunna läggas till i konstruktorn och aldrig tas bort, och det ser du i en del kod, bland annat i kursboken.

Kursens komponenter gör ändå likadant med alla lyssnare. En regel som gäller alltid är lättare att följa än en som kräver att du först avgör vart varje lyssnare pekar, och en lyssnare som senare flyttas från shadow DOM till document fortsätter att städas bort korrekt. Det finns också en fälla i att blanda, som nästa fråga visar.

En variant av bart-board lägger till sina lyssnare i konstruktorn, men tar ändå bort dem i disconnectedCallback(), för säkerhets skull:

constructor() {
super()
// ...
this.#abortController = new AbortController()
this.addEventListener('mousedown', () => this.#startWriting(), {
signal: this.#abortController.signal
})
}
disconnectedCallback() {
this.#abortController.abort()
}

Allt fungerar, tills sidan flyttar tavlan till en annan del av sidan med otherSection.append(board). Efter det reagerar tavlan inte längre på musen. Varför?

append() på ett element som redan finns i dokumentet flyttar det. För komponenten betyder en flytt att den först tas bort och sedan läggs in igen, så disconnectedCallback() anropas och därefter connectedCallback().

disconnectedCallback() anropar abort(), och lyssnaren tas bort. Men konstruktorn körs bara en gång, när elementet skapas, så ingen lägger till lyssnaren igen när elementet ansluts på nytt.

Lyssnare som tas bort i disconnectedCallback() måste läggas till i connectedCallback(). Då hålls de två i takt, oavsett hur många gånger komponenten flyttas.

Ett AbortController stoppar lyssnare, men inte timers. En komponent som startar ett intervall måste själv stoppa det:

disconnectedCallback() {
this.#abortController.abort()
clearInterval(this.#timerId)
}

Annars fortsätter intervallet att köras efter att komponenten har tagits bort, precis som lyssnaren på document ovan. Detsamma gäller allt annat komponenten har startat och som lever vidare utan den.

bart-board lyssnar på mousedown på sig själv, på this. Men det användaren trycker på är textraden, ett <p>-element i komponentens shadow DOM. Lyssnaren anropas ändå, eftersom händelser som användaren utlöser, som click, mousedown och keydown, både bubblar och korsar gränsen från shadow-trädet ut till värden.

Men utanför komponenten ser händelsen annorlunda ut. Webbläsaren riktar om händelsen (retargeting) när den passerar gränsen, så att den ser ut att komma från värden. Det skyddar inkapslingen: kod utanför komponenten ska inte behöva känna till eller kunna komma åt hur den är byggd inuti.

document.addEventListener('click', (event) => {
console.log(event.target)
})

Användaren klickar på knappen Follow, en <button> i shadow DOM-trädet hos en <user-card>. Vad skrivs ut?

<user-card>-elementet, inte knappen.

För lyssnare inuti komponentens shadow-träd är event.target knappen. För lyssnare utanför är den värden. Behöver du ändå se hela vägen kan du använda event.composedPath(), som ger alla noder händelsen passerar, även de i ett öppet shadow-träd. Det behövs sällan, och kod som gör det börjar bero på komponentens insida.

Händelser som inte bubblar eller inte korsar gränsen, som submit från ett formulär i komponentens shadow DOM, når aldrig ut. Vill komponenten berätta att formuläret skickats gör den det med en egen händelse.

bart-board har tre lyssnare:

this.addEventListener('mousedown', () => this.#startWriting(), { signal })
this.addEventListener('mouseup', () => this.stopWriting(), { signal })
this.addEventListener('mouseleave', () => this.stopWriting(), { signal })

Lägg märke till namnen. Metoderna heter efter vad komponenten gör, stopWriting(), och inte efter vad som utlöste dem, onMouseUp(). Två olika händelser leder till samma beteende, och den som läser koden förstår vad som händer utan att leta upp metoden. stopWriting() är dessutom publik, så att även den som använder komponenten kan stoppa skrivandet.

Samma tanke gäller det som rör själva händelsen. Behöver lyssnaren anropa preventDefault() eller läsa något ur händelseobjektet gör den det, och skickar sedan bara vidare de data metoden behöver:

this.#form.addEventListener('submit', (event) => {
event.preventDefault()
this.#saveName(this.#input.value)
}, { signal })

#saveName() vet inte att den anropades av en händelse. Den blir lättare att förstå, och lättare att testa, eftersom testet inte behöver tillverka ett händelseobjekt.

Data skickas ned till en komponent med attribut och metoder. För att skicka data upp, till den som använder komponenten, utlöser komponenten en egen händelse:

#follow() {
const name = this.getAttribute('name') ?? 'Unknown'
this.dispatchEvent(new CustomEvent('follow', {
detail: { name }
}))
}

Den som använder komponenten lyssnar som på vilken händelse som helst:

const card = document.querySelector('user-card')
card.addEventListener('follow', (event) => {
console.log(`You are now following ${event.detail.name}.`)
})

Komponenten vet inte vem som lyssnar eller vad som ska hända, och det ska den inte heller veta. Ett <form> anropar ingen funktion när det skickas; det utlöser submit, och den som bryr sig lyssnar. Det gör komponenten återanvändbar. Ett alternativ som att ta emot en funktion att anropa, card.onFollow = ..., hade bara rymt en mottagare och saknar motsvarighet bland webbens egna element.

Sekvensdiagram med användaren, js/index.js och bart-board. index.js sätter attributet speed till 50 på bart-board, data ner via attribut. Användaren trycker ned musknappen och tavlan börjar skriva; användaren släpper och stopWriting anropas. Användaren trycker ned igen och tavlan skriver tills den är full och skickar händelsen filled upp till index.js. index.js anropar då clear på bart-board, data ner via metod.

Kursens komponenter följer samma konvention för egna händelser, samma som i övningsuppgifterna och i examinationsuppgift B2:

  • Utlös händelsen på komponenten själv, this.dispatchEvent(...), inte på ett element i dess shadow DOM och inte på document.
  • Namnet skrivs med gemener, som de inbyggda: filled, timeout. Ett namn som timeIsUp är lätt att stava fel när man lyssnar, eftersom alla andra händelsenamn skrivs med gemener. Döp händelsen efter vad som har hänt, inte efter vad någon annan borde göra.
  • Data skickas i detail, som kan vara ett värde eller ett objekt. Namnet är fast; sätt inte egna egenskaper på händelseobjektet.
  • bubbles och composed lämnas som standard, false, så länge föräldern lyssnar direkt på komponenten. Det gör den nästan alltid, eftersom den har skapat eller hämtat komponenten och har en referens till den.
  • bubbles: true behövs bara när föräldern inte lyssnar på komponenten utan på något längre upp, till exempel en delegerad lyssnare på en lista med många kort.
  • composed: true behövs bara när händelsen måste ut ur ett omgivande shadow-träd. Eftersom händelsen utlöses på this är den redan utanför komponentens eget shadow-träd.
  • Ingen stopPropagation() utan ett tydligt skäl, av samma anledning som i förra artikeln.

En händelse som inte bubblar kan heller inte förväxlas med en händelse med samma namn från en annan komponent längre ned i trädet.

I B2:s förslag på arkitektur ligger <quiz-question> i <quiz-app>s shadow DOM, och <countdown-timer> i <quiz-question>s. countdown-timer utlöser timeout när tiden är slut, och quiz-question lyssnar så här:

this.#timer = this.shadowRoot.querySelector('countdown-timer')
this.#timer.addEventListener('timeout', () => this.#onTimeout(), { signal })

Behöver timeout ha bubbles eller composed? Och vad krävs om quiz-app i stället skulle lyssna direkt på countdown-timers händelse med en lyssnare på sitt eget shadow-träd?

I det första fallet ingetdera. quiz-question lyssnar direkt på countdown-timer, och händelsen utlöses just där.

I det andra fallet krävs båda. Händelsen måste bubbla upp genom quiz-questions shadow-träd och sedan korsa gränsen ut till quiz-question och vidare upp i quiz-apps träd. Men då vet quiz-app om en komponent två nivåer ned, som den inte har skapat. Det är därför B2:s förslag i stället låter quiz-question lyssna och skicka en egen timeout vidare uppåt. Varje komponent känner bara till sina egna barn.

Händelserna är en del av komponentens publika gränssnitt, precis som attributen och metoderna. Därför står de i komponentens README.md, med namn, när de utlöses och vad som finns i detail. Så här ser avsnittet ut i bart-board:

## Events
| Event Name | Fired When |
| ---------- | ------------------------------ |
| `filled` | The board is filled with text. |
  • Lyssnare läggs till i connectedCallback() med ett nytt AbortController och tas bort med abort() i disconnectedCallback(). Det är nödvändigt för lyssnare på sådant utanför komponenten, och kursens regel för alla lyssnare.
  • Timers stoppas för sig i disconnectedCallback(); ett AbortController stoppar dem inte.
  • Händelser från komponentens shadow DOM riktas om, så att de utifrån ser ut att komma från värden.
  • Låt lyssnaren hantera händelsen och en namngiven metod göra jobbet. Döp metoder efter vad de gör.
  • Data ned via attribut och metoder, data upp via egna händelser: this.dispatchEvent(new CustomEvent('namn', { detail })), med gemener i namnet och bubbles och composed bara när de behövs.
CCBY