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.
Var lyssnarna hör hemma
Section titled “Var lyssnarna hör hemma”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.
Lyssnare på sådant utanför komponenten
Section titled “Lyssnare på sådant utanför komponenten”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
Section titled “Lyssnare på komponenten själv”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.
Fråga: varför slutar tavlan skriva?
Section titled “Fråga: varför slutar tavlan skriva?”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.
Timers
Section titled “Timers”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.
Händelser från shadow DOM
Section titled “Händelser från shadow DOM”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.
Fråga: vad är target?
Section titled “Fråga: vad är target?”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.
Lyssnaren gör lite, metoden gör jobbet
Section titled “Lyssnaren gör lite, metoden gör jobbet”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.
Egna händelser
Section titled “Egna händelser”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.
Kursens händelsekonvention
Section titled “Kursens händelsekonvention”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 somtimeIsUpä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. bubblesochcomposedlä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: truebehö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: truebehö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.
Fråga: behövs bubbles eller composed?
Section titled “Fråga: behövs bubbles eller composed?”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.
Dokumentera händelserna
Section titled “Dokumentera händelserna”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. |Att ta med sig
Section titled “Att ta med sig”- Lyssnare läggs till i
connectedCallback()med ett nyttAbortControlleroch tas bort medabort()idisconnectedCallback(). 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(); ettAbortControllerstoppar 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 ochbubblesochcomposedbara när de behövs.
Vidare läsning
Section titled “Vidare läsning”- Läsanvisningarna för veckan, i synnerhet kapitel 15.2.6 i kursboken om egna händelser.
- CustomEvent, Event.composed och Event.composedPath() på MDN.
- Exemplet
Klickräknaren – händelser,
där
<click-counter>från kursvecka 10 steg för steg börjar skicka händelsencountchange. - Exemplet RSVP, en komponent med ett formulär som berättar att det har skickats med en egen händelse.
- Komponentens
README.mdochARCHITECTURE.mdi övningsuppgiften Bart Board.