Översikt
Förra veckan var en intensiv vecka med examinationer och webbkomponenter. Ingen rast, ingen ro. Denna vecka tar du dig an en mycket viktig del när det kommer till programmering i JavaScript, händelsestyrd programmering! Information om veckans upplägg annonseras löpande i kursens Zulip-kanal.
Stödjande inspelningar
Section titled “Stödjande inspelningar”Samtidighetsmodellen och eventdriven programmering
Section titled “Samtidighetsmodellen och eventdriven programmering”- Introduktion
- Samtidighetsmodellen
- Stack och heap
- Händelsekön (The Task Queue)
- Timers och Interval
- Händelsestyrd programmering
- Webbläsarens inbyggda händelser (Event)
- Antipattern: onclick=“”
- Lyssna på händelser (addEventListener)
- Exempel: webbläsaren fryser
- Ta bort händelsehanterare (removeEventListener)
- Händelser som utlöses en gång (option: once)
- Vad utlöste händelsen? (this, event.target)
- Closures
- Delegat (Event delegation)
- Stoppa det förvalda beteendet. (event.preventDefault)
- Propagation (capture, bubble)
- Web Worker API
- Avslutning
Varför behövs en samtidighetsmodell?
Section titled “Varför behövs en samtidighetsmodell?”En samtidighetsmodell (concurrency model) beskriver hur ett system hanterar att flera saker kan behöva ske samtidigt utan att data blir inkonsekvent – samma grundproblem som uppstår när två klienter försöker skriva till samma rad i en databas samtidigt. JavaScript brukar beskrivas som entrådat: webbläsaren har en huvudtråd (main thread) som sköter både att köra JavaScript och att rendera om sidan, vilket är samma tråd som parsar HTML-dokumentet och bygger DOM-trädet. Att JavaScript exekverar i samma tråd som renderingen är förklaringen till varför ett <script> mitt i dokumentet pausar HTML-parsningen tills skriptet är klart.
Utöver huvudtråden går det att skapa egna worker-trådar (se sist i denna sammanfattning) för att göra tungt arbete parallellt – de har dock ingen tillgång till DOM:en, bara huvudtråden får läsa och skriva där.
Call stack, heap och garbage collector
Section titled “Call stack, heap och garbage collector”Huvudtråden har en egen stack – en LIFO-struktur (last in, first out) som håller reda på vilka funktioner som är under exekvering just nu, en så kallad stack frame per anrop. När en funktion anropar en annan läggs en ny frame på toppen; när funktionen returnerar plockas den bort. Ett kastat undantag som inte fångas visar denna kedja som en stack trace i konsolen.
function foo (b) { const a = 10 return a + b + 11}
function bar (x) { const y = 3 return foo(x * y)}
let res = console.log(bar(7))Själva funktionsdefinitionerna och andra referenstyper lagras på heapen, medan stacken håller värdetyperna och referenserna dit. När stacken töms och inget längre refererar till ett objekt på heapen städar webbläsarens garbage collector bort det automatiskt – inget man behöver hantera manuellt, men bra att veta att det finns.
Task queue och händelseloopen
Section titled “Task queue och händelseloopen”Utöver stacken finns en task queue (även kallad message queue) – en FIFO-kö (first in, first out, till skillnad från stacken). Kod läggs till i den här kön när t.ex. ny JavaScript-kod hittas att köra, en händelse utlöses, en timer eller ett intervall triggar, eller en worker skickar ett meddelande.
Händelseloopen (the event loop) är den mekanism som hela tiden kollar: är stacken tom? Om ja, plocka nästa meddelande från task queue och kör det på stacken. Det är därför en synkron, blockerande operation (t.ex. en while (true)-loop eller ett tungt beräkningsanrop) fryser hela sidan – stacken töms aldrig, så händelseloopen kan aldrig plocka nästa uppgift eller släppa fram en rendering. En markör som slutar blinka i ett inputfält är ett konkret tecken på exakt detta.
Utöver task queue finns en animation queue (webbläsarens egna renderingshändelser) och en microtask queue (kopplad till Promises, kommer i ett senare avsnitt). Skillnaden är att microtask-kön töms helt varje gång händelseloopen tittar på den – fylls den på snabbare än den hinner tömmas kan även microtasks blockera rendering, vilket inte händer på samma sätt för den vanliga task queue.
Timers: setTimeout och setInterval
Section titled “Timers: setTimeout och setInterval”setTimeout schemalägger att en funktion läggs till task queue efter en angiven fördröjning i millisekunder – det är alltså inte en garanti för exakt tajming, bara en garanti för att koden tidigast läggs i kön efter den tiden. Är kön full av annat arbete kan det dröja längre.
const timer = window.setTimeout(() => { console.log('Minst 3 sekunder har passerat..')}, 3000)
window.clearTimeout(timer) // avbryter timern innan den hinner triggassetInterval är samma sak fast upprepat – och betydligt farligare att använda oförsiktigt. Om koden i intervallet tar längre tid att köra än själva intervalltiden fylls task queue snabbt på med fler och fler väntande uppgifter, vilket gör sidan allt segare och till slut kan tömma tillgängligt minne. Ett vanligt, säkrare mönster är att istället låta en setTimeout schemalägga nästa setTimeout i slutet av sin egen körning – då kan aldrig mer än en instans ligga och vänta i kön samtidigt. Precis som med timeouts är det viktigt att komma ihåg clearInterval när intervallet inte längre behövs, annars fortsätter det köra även efter att exempelvis den komponent som skapade det tagits bort.
Händelser: fyra kategorier
Section titled “Händelser: fyra kategorier”Händelser (events) är det som gör att program kan reagera på saker som inte går att förutse i förväg – användarens klick, webbläsarens egna signaler, nätverkssvar och egendefinierade händelser. De brukar delas in i fyra kategorier:
- User initialized – musklick, scroll, tangenttryckningar
- Browser initialized – sidan är färdigladdad, DOM:en har ändrats
- Network – en bild har laddat klart, ett svar har kommit från servern
- Synthetic events – egna, självdefinierade händelser (custom events)
Koppla händelselyssnare – och vad man ska undvika
Section titled “Koppla händelselyssnare – och vad man ska undvika”Att skriva händelsehantering direkt i HTML-attribut (onclick="doSomething()") är ett anti-pattern: det blandar ihop struktur och beteende, blir svårt att underhålla i större skala och är samma princip som gäller för varför JavaScript inte ska skrivas inline i HTML överhuvudtaget.
<a href="page.html" onclick="doSomething()">Gör inte såhär</a>Rätt väg är addEventListener, som finns tillgänglig på alla DOM-element (och på egna webbkomponenter, som ju ärver från EventTarget):
const a = document.querySelector('#theatag')
a.addEventListener('click', (event) => { console.log('Länken klickades!')})Notera att det är en referens till funktionen som skickas in, aldrig ett anrop av den (buttonClicked, inte buttonClicked()). Namngiven funktion, anonym funktion eller arrow function fungerar likvärdigt för att lyssna – skillnaden mellan dem gäller framför allt hur this beter sig (se nedan) och hur enkelt lyssnaren går att ta bort igen.
För att ta bort en lyssnare med removeEventListener krävs exakt samma funktionsreferens som skickades in vid addEventListener – det räcker inte att koden ser likadan ut. Det är därför en anonym funktion eller arrow function inte går att ta bort i efterhand; ska en lyssnare kunna tas bort måste den sparas i en namngiven variabel eller skrivas som en namngiven funktion. I moderna webbläsare städar motorn ofta bort döda lyssnare av sig själv (t.ex. när elementet de satt på tas bort ur DOM:en), men det är fortfarande god praxis (best practice) att ta bort lyssnare man inte längre behöver – särskilt om syftet är att lyssnaren bara ska köras en gång, vilket också går att uttrycka direkt via tredje argumentet: addEventListener('click', handler, { once: true }).
Vad var det som utlöste händelsen?
Section titled “Vad var det som utlöste händelsen?”Inuti en händelsehanterare vill man ofta veta vilket element som utlöste händelsen. Det säkraste sättet är event.target, som alltid pekar på det element användaren faktiskt interagerade med.
const a = document.querySelector('#atag')
a.addEventListener('click', function (event) { a === this // true a === event.target // true this === event.target // true})Skrivs hanteraren som en vanlig function-funktion refererar this till elementet händelsen kopplades på – vilket råkar sammanfalla med event.target i ett enkelt exempel som ovan, men blir fel så fort samma kod ligger som en metod i en klass: då vill man normalt att this ska peka på klassinstansen, inte på det klickade elementet. Historiskt löstes det antingen genom att spara undan this i en variabel (const that = this, ett mönster som inte längre behövs) eller genom .bind(this), som skapar en ny så kallad exotic function med this omdirigerat. Bind-varianten har en egen fälla: den skapade bundna funktionen är en annan referens än originalfunktionen, vilket gör den svårare att ta bort med removeEventListener om man inte sparar undan den bundna versionen separat.
Arrow functions löser hela problemet enklare: de binder aldrig om this alls, utan ärver this från den omgivande koden automatiskt – exakt det beteende man annars var tvungen att uppnå manuellt med bind. Det är därför arrow functions är det rekommenderade valet för händelselyssnare idag.
Closures-fällan i loopar
Section titled “Closures-fällan i loopar”Ett klassiskt misstag är att koppla händelselyssnare i en vanlig for-loop och förvänta sig att varje lyssnare ska komma ihåg “sitt eget” loop-index:
const aTags = document.querySelectorAll('#links a')
for (var i = 0; i < aTags.length; i += 1) { aTags[i].addEventListener('click', (event) => { console.log(i) // blir alltid längden på listan, oavsett vilken länk som klickades })}Orsaken är closures: den inre funktionen refererar till samma yttre variabel i, och när i väl läses (vid klick, långt senare) har loopen redan hunnit klart och i har sitt sista värde. Lösningen är att ge varje iteration sin egen variabel-instans, till exempel genom forEach (eller let istället för var i en vanlig for-loop, vilket skapar en ny bindning per varv):
const aTags = document.querySelectorAll('#links a')
aTags.forEach((a, i) => { a.addEventListener('click', (event) => { console.log(i) // korrekt: 0, 1, 2, 3... })})Event delegation
Section titled “Event delegation”Att sätta en egen lyssnare per element skalar dåligt – ett bräde med hundratals rutor (typexemplet: Minesweeper) skulle innebära hundratals separata lyssnare för webbläsaren att hålla reda på, vilket märkbart kan sakta ner sidan när antalet växer. Lösningen bygger på att händelser bubblar uppåt genom DOM-trädet: sätt istället en enda lyssnare på ett gemensamt förälderelement och läs av event.target för att avgöra vilket barn som faktiskt utlöste händelsen.
const board = document.querySelector('#gameboard')
board.addEventListener('click', (event) => { console.log(event.target) // <button id="m32"> event.currentTarget === board // true – board är det som "äger" lyssnaren})event.target är alltid det element användaren interagerade med, medan event.currentTarget är det element lyssnaren faktiskt sitter på – i vanliga fall samma element, men inte nödvändigtvis vid delegation.
Propagation: capture och bubble
Section titled “Propagation: capture och bubble”En händelse rör sig i två faser genom DOM-trädet: först en capture-fas (uppifrån och ner, från document mot målelementet) och sedan en bubble-fas (nerifrån och upp igen). addEventListener lyssnar som standard i bubble-fasen; för att fånga händelsen redan i capture-fasen skickas ett tredje argument med { capture: true } (att skicka in true direkt istället för ett options-objekt fungerar också men är svårare att läsa, och avrådd stil idag).
a.addEventListener('click', (event) => { console.log('Länken klickades!') event.stopPropagation() // stoppar fortsatt bubbling uppåt i trädet})
p.addEventListener('click', (event) => { console.log('Den här körs aldrig om a stoppar propagationen')})stopPropagation() stoppar en händelse från att fortsätta bubbla vidare uppåt (eller nedåt, om den satts i capture-fasen). Det är en annan sak än preventDefault(), som istället stoppar webbläsarens eget standardbeteende för händelsen – till exempel att en länk navigerar iväg eller att ett formulär skickas och laddar om sidan. Att glömma preventDefault() på en formulär-submit är en vanlig fälla: den egna koden hinner köra, men sedan tar webbläsaren över och laddar om sidan ändå, vilket nollställer hela skriptets exekvering.
Web Workers API
Section titled “Web Workers API”När arbete är för tungt för att köras direkt i huvudtråden utan att blockera kön kan det läggas i en separat tråd via Web Workers API. En worker måste ligga i en egen fil (går inte att peka ut en funktion i den egna modulen) och har en egen stack, en egen kö och en egen händelseloop – helt separat från huvudtrådens.
let n = 1search: while (true) { n += 1 for (let i = 2; i <= Math.sqrt(n); i += 1) { if (n % i === 0) continue search } postMessage(n)}// huvudtrådenconst worker = new window.Worker('worker.js')
worker.addEventListener('message', (event) => { document.querySelector('#pn').textContent = event.data})En worker har ingen tillgång till DOM-API:er – den kan alltså aldrig skriva direkt till sidan. Kommunikation mellan huvudtråden och en worker sker uteslutande via meddelanden: postMessage skickar, och en message-lyssnare tar emot på andra sidan. Vissa API:er, som indexedDB, är tillgängliga från båda trådarna samtidigt och har inbyggt stöd för transaktioner för att hantera att båda sidor kan försöka skriva samtidigt – samma typ av race condition-problem som samtidighetsmodellen inledningsvis illustrerade med en delad databas.
Webbkomponenter - Händelser
Section titled “Webbkomponenter - Händelser”📕 Errata
Varför händelser hör hemma i webbkomponenter
Section titled “Varför händelser hör hemma i webbkomponenter”En webbkomponent behöver ofta göra två saker med händelser: reagera på sådant som händer den utifrån (användaren klickar, drar, skriver) och kunna berätta utåt att något har hänt inuti den (data har kommit från en server, en tavla har blivit full). Det andra fallet skulle kunna lösas genom att komponenten anropar en funktion som den som använder komponenten skickat in, men det bryter mot samma princip som gör de inbyggda HTML-elementen användbara: en knapp eller ett formulär talar om att något hänt genom att utlösa en händelse, inte genom att ringa upp en specifik funktion den fått tilldelad. Webbkomponenter ska bygga vidare på det mönstret, inte hitta på ett eget.
Koppla händelser i connectedCallback, inte i konstruktorn
Section titled “Koppla händelser i connectedCallback, inte i konstruktorn”Av livscykelmetoderna (constructor, attributeChangedCallback, connectedCallback, disconnectedCallback, adoptedCallback – den sistnämnda för det ovanliga fallet att ett element flyttas mellan dokument) är connectedCallback den naturliga platsen att koppla händelsehanterare: den körs först när komponenten faktiskt sitter i DOM-trädet, vilket är exakt när det blir meningsfullt att lyssna på interaktion. Att koppla en listener i konstruktorn fungerar tekniskt, men är mindre korrekt eftersom komponenten då ännu inte finns i dokumentet – kurslitteraturen lägger visserligen koden i konstruktorn, men de flesta exempel och guider ute på webben rekommenderar connectedCallback.
connectedCallback () { this.addEventListener('mousedown', (event) => this.#onWrite()) this.addEventListener('mouseup', (event) => this.stopWriting()) this.addEventListener('mouseleave', (event) => this.stopWriting())}Lyssnarna kopplas på this – komponenten själv – inte på document, eftersom det är interaktion med just den här komponentinstansen som är intressant.
Namnge metoder efter vad de gör, inte efter händelsen
Section titled “Namnge metoder efter vad de gör, inte efter händelsen”I exemplet ovan heter metoderna stopWriting och #onWrite, inte onMouseUp/onMouseDown. Det är en medveten namngivningsprincip: en metod som heter stopWriting talar om vad komponenten gör (slutar skriva), medan en metod som heter onMouseUp bara talar om vad som utlöste den – för att förstå vad den faktiskt gör måste man läsa koden eller dokumentationen. Namnger man efter beteendet blir det också möjligt att koppla flera olika händelser (mouseup och mouseleave) till samma tydligt namngivna beteende.
Vilka av dessa metoder som ska vara privata (#onWrite) eller publika (stopWriting, clear) avgörs av om det är rimligt att kod utanför komponenten anropar dem direkt. Att skriva ska bara triggas av interaktion med komponenten, så #onWrite är privat. Att stoppa skrivandet eller rensa tavlan i förtid kan däremot vara nyttigt att kunna göra utifrån, så de är publika.
Håll händelsedetaljer borta från domänlogiken
Section titled “Håll händelsedetaljer borta från domänlogiken”Ett event-objekt har egen kontext – preventDefault(), stopPropagation(), event.detail – som inte hör hemma i en metod som calculateResult eller stopWriting. Rekommenderat mönster: lös allt som rör själva händelsen inuti pilfunktionen i connectedCallback, och skicka bara den faktiska datan vidare till en namngiven metod som inte vet att den anropades av ett event:
connectedCallback () { this.addEventListener('click', (event) => { event.stopPropagation() event.preventDefault() this.calculateResult(event.detail) // metoden tar bara emot datan den behöver })}Fördelen är dubbel: koden blir tydligare (metodnamnet beskriver ett beteende, inte en händelse), och metoden blir lättare att testa – den kräver inget mockat event-objekt, bara den data den faktiskt använder. Ett vanligt misstag är att lägga event.preventDefault() inne i en metod som onWrite istället för i själva lyssnaren – där saknar den sammanhang och gör koden svårare att följa.
disconnectedCallback – städning, inte händelsehantering
Section titled “disconnectedCallback – städning, inte händelsehantering”disconnectedCallback körs när komponenten tas bort ur DOM-trädet och är tänkt för upprensning. I inspelningen resoneras det att removeEventListener här i praktiken är onödigt i moderna webbläsare, eftersom webbläsaren redan städar bort lyssnare kopplade till ett element som försvinner ur DOM:en.
Utöver händelselyssnare behöver disconnectedCallback fortfarande städa upp sådant som lever vidare oberoende av elementet: pågående setInterval/setTimeout eller aktiva nätverksanrop som komponenten startat och som annars fortsätter köras även efter att komponenten är borta.
Egna händelser (Custom Events)
Section titled “Egna händelser (Custom Events)”För att en komponent ska kunna berätta utåt att något hänt inuti den, utan att känna till vem som lyssnar eller vad de ska göra, skapas och utlöses en egen händelse:
const filledEvent = new window.CustomEvent('filled', { detail: someValue })this.dispatchEvent(filledEvent)dispatchEvent utlöser händelsen på komponenten precis som webbläsaren utlöser click eller mouseup på inbyggda element – den som lyssnar utifrån gör det på exakt samma sätt som för vilken annan händelse som helst:
document.querySelector('bart-board').addEventListener('filled', (event) => { event.target.clear()})Egen data skickas med via andra argumentets detail-egenskap – namnet är fast, det går inte att kalla den något annat. detail kan vara ett enkelt värde eller en referens till ett helt objekt om mer information behöver följa med.
Poängen med mönstret är avkoppling: komponenten ska aldrig behöva känna till vilka andra delar av applikationen som bryr sig om att den blivit full, fått ett klick eller liknande – den bara utlöser händelsen och talar om vad som hänt, och andra komponenter får själva avgöra om och hur de reagerar. Motsatsen – att komponenten själv anropar specifika funktioner utifrån – skapar beroenden i fel riktning och gör komponenten svårare att återanvända.
Läsanvisningar
Section titled “Läsanvisningar”Under läsanvisningar hittar du veckans kapitel som ska läsas i kurslitteraturen. Planera upp och dela upp läsningen under veckan. Denna vecka har kursledningen kryddat med två externa föreläsningar!
Övningar
Section titled “Övningar”Under Övningsuppgifter hittar du övningsuppgifter som det är lämpligt att du arbetar med under veckan.
PS. Övningsuppgiften innehåller en inspelad diskussion kring lösningen av övningsuppgiften. Missa inte att titta på den.
Handledning
Section titled “Handledning”Skicka gärna in frågor på förhand via formuläret som du når här:
Examinationsuppgift B1
Section titled “Examinationsuppgift B1”Dags för kursens andra quiz-baserade examinationsuppgift. Läs mer om examinationsuppgift B1 här.
Examinationsuppgift B2
Section titled “Examinationsuppgift B2”Även om du har en veckas teori kvar innan du kan ge dig an examinationsuppgift B2, kan det redan nu vara bra att läsa igenom uppgiften så att du vet vad som komma skall.
Studiefrågor
Section titled “Studiefrågor”Här är ett antal frågor som du kan försöka besvara och diskutera tillsammans med din AI-assistent. Tveka inte att lyfta någon av frågorna till handledningen för att fortsätta diskussionen med lärare och studiekamrater.
- Hur kan man tänka kring webbkomponenter och event?
- Jag ser att du skapade eventhanterare i din konstruktor, är det best practice att göra så, eller bör man göra annorlunda?
- Berätta hur det fungerar med attribut kopplade till webbkomponenter.
- Vad innebär slot i kombination med webbkomponenter?
- När man stylar en webbkomponent, är det bra att använda :host eller bör man göra annorlunda?
Här är en utskrift från hur det såg ut när jag diskuterade ovan frågor med min AI-assistent Ajj.
Här följer tips till hur du kan prioritera bland kursmaterialet för att komma igång med veckan.
- Titta igenom de båda föreläsningarna. Fokusera på hur event fungerar i webbkomponent.
- Om du har glömt vissa saker så kan du behöva gå tillbaka och fräscha upp din kunskap om vad som sades om webbkomponenter föregående veckor.
- Fokusera på webbkomponenter och att försöka koda ihop din egna webbkomponent där du kan prova de centrala delarna som diskuteras i föreläsningar och övningar. Gör detta och det förbereder dig inför assignment B2.
- Missa inte att titta på den inspelade föreläsningen som visar ett förslag på facit till veckans övningsuppgift. Du får se diskussion om webbkomponentens olika delar.
