Hoppa till innehåll

Händelser i DOM:en

I förra artikeln såg du att en händelse blir en task i kön, som händelseloopen kör när det är dess tur. Den här artikeln handlar om din sida av saken: hur du talar om för webbläsaren vilken kod som ska köras när något händer, vad du får veta om händelsen och vägen den tar genom DOM-trädet.

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

Alla noder i DOM:en ärver från EventTarget, och därmed har alla metoden addEventListener(). Den tar namnet på händelsen och en funktion, en lyssnare (event listener), som webbläsaren anropar varje gång händelsen inträffar:

const button = document.querySelector('button')
button.addEventListener('click', () => {
console.log('The button was clicked.')
})

Händelserna kommer från olika håll. Användaren utlöser click, input och keydown. Webbläsaren själv utlöser DOMContentLoaded när dokumentet är tolkat och load när en bild har laddats. Och din egen kod kan utlösa egna händelser, vilket är ämnet för nästa artikel. Händelsernas namn skrivs alltid med gemener.

Samma element kan ha hur många lyssnare som helst för samma händelse. De anropas i den ordning de lades till.

I äldre kod ser du ibland lyssnaren skriven direkt i ett attribut, eller tilldelad till en egenskap:

<button onclick="save()">Spara</button>
button.onclick = save

Undvik båda. Attributet blandar ihop struktur och beteende, kräver att save är en global funktion och blockeras av många webbplatsers säkerhetsinställningar (Content Security Policy). Egenskapen rymmer bara en funktion, så nästa tilldelning skriver över den förra. addEventListener() har ingen av de nackdelarna.

function greet() {
console.log('Hej!')
}
button.addEventListener('click', greet())

Hej! skrivs ut en gång när sidan laddas, men aldrig när knappen klickas. Varför?

Parenteserna efter greet anropar funktionen direkt, när raden körs. Det som skickas till addEventListener() är funktionens returvärde, undefined, och inte funktionen. Webbläsaren har alltså ingen lyssnare att anropa vid klick, och den säger inte heller ifrån.

Skicka en referens till funktionen, addEventListener('click', greet), eller en pilfunktion som anropar den, addEventListener('click', () => greet()).

Lyssnaren får ett argument: ett händelseobjekt med information om vad som hänt. Vilka egenskaper det har beror på händelsen. Ett MouseEvent har till exempel muspekarens position, och ett KeyboardEvent vilken tangent som trycktes ned. Några egenskaper har alla händelser:

Egenskap Innehåll
type Händelsens namn, till exempel 'click'.
target Elementet där händelsen inträffade, till exempel det som klickades på.
currentTarget Elementet som lyssnaren sitter på.

target och currentTarget är samma element när du lyssnar direkt på det som klickas. Skillnaden blir viktig när händelsen har färdats genom trädet, som du ser längre ned.

I en lyssnare skriven som en vanlig funktion är this elementet som lyssnaren sitter på, samma som event.currentTarget. En pilfunktion har inget eget this, utan använder this från koden runt omkring. I en klass, där this ska vara objektet och inte elementet, är det precis vad du vill:

class Counter {
#count = 0
constructor(button) {
button.addEventListener('click', () => this.increment())
}
increment() {
this.#count++
}
}

Med en vanlig funktion hade this.increment letats upp på knappen och inte hittats. Äldre kod löser det med const that = this eller .bind(this). Med pilfunktioner behövs ingetdera, och kursens kod använder pilfunktioner för lyssnare i klasser. Vill du veta vilket element lyssnaren sitter på använder du event.currentTarget.

En lyssnare tas bort med removeEventListener(), som kräver exakt samma funktion som lades till. En funktion som ser likadan ut räcker inte, eftersom två funktioner bara är lika om de är samma objekt:

// Not removed – these are two different functions.
button.addEventListener('click', () => save())
button.removeEventListener('click', () => save())
// Removed – the same function.
button.addEventListener('click', save)
button.removeEventListener('click', save)

En pilfunktion skriven direkt i anropet går alltså inte att ta bort med removeEventListener().

Som tredje argument tar addEventListener() ett objekt med alternativ. Två av dem gör det enklare att sluta lyssna:

button.addEventListener('click', () => save(), { once: true })

Med once: true tas lyssnaren bort automatiskt efter första anropet.

const controller = new AbortController()
const { signal } = controller
button.addEventListener('click', () => save(), { signal })
form.addEventListener('submit', (event) => send(event), { signal })
window.addEventListener('resize', () => resize(), { signal })
// Later: removes all three at once.
controller.abort()

Med signal kopplas lyssnaren till ett AbortController. När abort() anropas tas alla lyssnare som fått samma signal bort, och du behöver inte spara en referens till varje funktion. Det är mönstret du såg i kursvecka 10, och i den sista artikeln ser du varför det passar så bra i en komponent.

En händelse som inträffar på ett element stannar inte där. Klickar du på en knapp i en lista har du ju också klickat på listan, och på sidan. Händelsen färdas därför genom trädet i tre faser:

  1. Capture: från window ned genom förfäderna till målet.
  2. Målet: händelsen når elementet där den inträffade, event.target.
  3. Bubble: från målet upp genom förfäderna till window igen.
Sekvensdiagram över en click-händelses väg. I capture-fasen går händelsen från window till document, ul, li och button. Vid målet körs lyssnare på button. I bubble-fasen går den tillbaka från button till li, ul, document och window. En anteckning säger att lyssnare registrerade med capture true körs på vägen ned, övriga på vägen upp, och att händelser som inte bubblar, som focus och mouseleave, stannar vid målet.

Lyssnare anropas som standard i bubble-fasen. Vill du fånga händelsen på vägen ned skickar du med { capture: true }. Det behövs sällan.

Inte alla händelser bubblar. focus, blur, mouseenter och mouseleave stannar vid målet. Vill du lyssna på fokus längre upp i trädet finns focusin och focusout, som bubblar. MDN anger för varje händelse om den bubblar.

<ul>
<li><button>Ta bort</button></li>
</ul>
document.querySelector('ul').addEventListener('click', () => console.log('ul'))
document.querySelector('li').addEventListener('click', () => console.log('li'))
document.querySelector('button').addEventListener('click', () => console.log('button'))

I vilken ordning skrivs namnen ut när knappen klickas? Och om lyssnaren på ul i stället läggs till med { capture: true }?

Utan capture: button, li, ul. Händelsen når först målet och bubblar sedan uppåt.

Med capture på ul: ul, button, li. Lyssnaren på ul anropas på vägen ned, före målet. De andra två anropas som förut.

Bubblandet gör det möjligt att lyssna på många element med en enda lyssnare. I stället för att ge varje knapp i en lista en egen lyssnare lyssnar du på listan och tar reda på vilken knapp som klickades:

const list = document.querySelector('#todo-list')
list.addEventListener('click', (event) => {
const button = event.target.closest('button')
if (!button) {
return
}
button.closest('li').remove()
})

Det kallas delegering (event delegation) och har två fördelar. En lyssnare är billigare än hundra, och knappar som läggs till i listan senare fungerar direkt, utan att någon behöver komma ihåg att ge dem en lyssnare.

Här syns skillnaden mellan egenskaperna. event.currentTarget är listan, där lyssnaren sitter. event.target är det innersta elementet som klickades på, och det är inte alltid knappen. closest() från kursvecka 9 letar sig uppåt från det elementet till närmaste knapp, eller ger null om klicket inte var på en knapp.

Utan closest() ser lyssnaren ut så här:

list.addEventListener('click', (event) => {
if (event.target.tagName === 'BUTTON') {
event.target.parentElement.remove()
}
})

Knapparna innehåller en ikon, <button><span class="icon">🗑</span> Ta bort</button>. Ibland försvinner raden vid klick, ibland inte. Varför?

Klickar användaren på ikonen är event.target elementet <span>, inte <button>. Villkoret blir falskt och ingenting händer. Klickar användaren på texten bredvid ikonen är målet knappen, och raden försvinner.

event.target.closest('button') hittar knappen i båda fallen, eftersom closest() börjar med elementet självt och sedan går uppåt.

Två metoder på händelseobjektet låter som om de gör samma sak, men gör det inte.

preventDefault() stoppar webbläsarens förvalda beteende: att en länk navigerar, att en kryssruta kryssas i eller att ett formulär skickas och sidan laddas om. Händelsen fortsätter att färdas genom trädet som vanligt.

form.addEventListener('submit', (event) => {
event.preventDefault()
send(new FormData(form))
})

stopPropagation() stoppar händelsens väg genom trädet. Lyssnare längre bort på vägen anropas inte. Det förvalda beteendet påverkas inte.

Behovet av preventDefault() är vanligt. Behovet av stopPropagation() är ovanligt, och det är lätt att ställa till det med den. En delegerad lyssnare längre upp, en meny som stängs vid klick utanför den eller ett analysverktyg som räknar klick slutar fungera utan att det syns varför. Använd den inte utan ett tydligt skäl.

form.addEventListener('submit', () => {
console.log('Skickar...')
})

När formuläret skickas syns Skickar... i konsolen ett ögonblick och försvinner sedan. Vad hände?

Lyssnaren kördes, men sedan gjorde webbläsaren det förvalda: skickade formuläret och laddade om sidan. Konsolen töms när sidan laddas om, och alla variabler och all status från skriptet är borta.

event.preventDefault() först i lyssnaren stoppar omladdningen. Vill du behålla konsolens innehåll mellan sidladdningar kan du slå på Preserve log i utvecklarverktygen.

  • Lyssna med addEventListener() och skicka en funktion, inte resultatet av ett anrop. Undvik onclick i HTML-koden.
  • event.target är elementet där händelsen inträffade, event.currentTarget elementet där lyssnaren sitter. Använd pilfunktioner för lyssnare i klasser.
  • removeEventListener() kräver samma funktion. Med { signal } och ett AbortController tar ett anrop till abort() bort många lyssnare på en gång.
  • En händelse färdas ned till målet och bubblar sedan upp, om den bubblar. Det gör delegering möjlig: en lyssnare på föräldern och event.target.closest().
  • preventDefault() stoppar webbläsarens förvalda beteende. stopPropagation() stoppar händelsens väg och behövs sällan.
CCBY