Hoppa till innehåll

Kommunikation mellan komponenter

När en sak händer i en komponent ska ofta något hända i en annan. Användaren trycker på Apply settings i formuläret, och spelaren ska visa en ny video. Tiden tar slut i timern, och frågan ska avslutas. Den här artikeln handlar om hur det ska gå till utan att komponenterna blir beroende av varandra. Den bygger på de två föregående artiklarna, Bra komponenter och Komponenter i komponenter.

Exemplen kommer från veckans övningsuppgift, YouTube Player, från exemplet Att göra och från förslaget på arkitektur i examinationsuppgift B2.

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

youtube-control och youtube-player ligger bredvid varandra i youtube-scenes shadow DOM. Det ligger nära till hands att låta formuläret ställa in spelaren direkt:

// Avoid: youtube-control reaches for its sibling
#applySettings() {
const player = this.getRootNode().querySelector('youtube-player')
player.setAttribute('video-id', this.#getFieldValue('video-id'))
// ...
}

Det fungerar, men nu kan youtube-control inte användas utan en youtube-player bredvid sig. Den kan inte heller testas utan en, och vill du byta spelaren mot en annan måste du ändra i formuläret. Två komponenter som borde vara oberoende har blivit en.

HTML fungerar inte så. Ett <input> och en <button> bredvid varandra påverkar inte varandra av sig själva. Det är koden runt omkring som bestämmer att ett klick på knappen ska läsa fältet.

Samma princip känner du igen från funktioner. En funktion som anropar nästa steg själv är svårare att återanvända än en som returnerar sitt resultat och låter den som anropar bestämma vad som händer sedan:

// Avoid: readSettings() decides what happens next
function readSettings() {
const settings = { /* ... */ }
embedVideo(settings)
}
// Prefer: the caller composes the two
const settings = readSettings()
embedVideo(settings)

Komponenter returnerar inte, men de utlöser händelser. youtube-control gör sitt jobb, läser formuläret, och berättar att inställningarna har ändrats. Vad som ska hända sedan är inte dess sak.

Den som kopplar ihop två komponenter är den som känner till båda: deras gemensamma förälder. I övningen är det youtube-scene, som har skapat både formuläret och spelaren. Den lyssnar på det ena barnets händelse och ställer in det andra:

Sekvensdiagram med användaren, youtube-control, youtube-scene och youtube-player. Användaren klickar på Apply settings i youtube-control, som läser formuläret och skickar händelsen settingschanged med detail videoId, autoplay och så vidare upp till youtube-scene, data upp via händelse. youtube-scene översätter detail till attribut och anropar setAttribute för video-id, toggleAttribute för autoplay och så vidare på youtube-player, data ner via attribut. youtube-player bäddar in videon. En anteckning säger att youtube-control och youtube-player inte känner till varandra; bara youtube-scene, som har skapat båda, vet att de hör ihop.
connectedCallback() {
this.#abortController = new AbortController()
this.#control.addEventListener('settingschanged', (event) => this.#forwardSettings(event.detail), {
signal: this.#abortController.signal
})
}

youtube-control vet inte att det finns en spelare. youtube-player vet inte att det finns ett formulär; den fungerar lika bra om du sätter attributen för hand i konsolen. Bara youtube-scene vet att de hör ihop, och det är hela dess uppgift.

Det här är samma mönster som du har sett sedan kursvecka 11, data ned och händelser upp, men med två barn. Ett barns händelse går upp till föräldern, och föräldern skickar data ned till ett annat barn. Data går aldrig i sidled.

Föräldern kan vara en komponent, som youtube-scene, eller applikationens index.js. I exemplet Att göra är det index.js som flyttar en uppgift sist i listan när den bockas av:

js/index.js
const list = document.querySelector('#household')
list.addEventListener('statechanged', (event) => {
if (event.detail.done) {
list.append(event.target)
} else {
list.prepend(event.target)
}
})

Händelsen kommer från en todo-item, men bubblar upp genom listan till index.js, som du såg i föregående artikel.

todo-list får redan veta när en uppgift bockas av. Varför flyttar inte listan själv uppgiften sist, i stället för att låta index.js göra det?

Uppgifterna är listans barn i light DOM. De är innehåll som den som använder listan bestämmer över, inte delar som listan äger. En <ul> sorterar inte om sina <li>.

Om avklarade uppgifter ska hamna sist, försvinna eller ligga kvar är ett beslut om just den här sidan. En annan sida kan vilja något annat med samma lista. Därför fattas beslutet en nivå upp, där sidan bestäms.

Regeln för var orkestreringen hör hemma är densamma som i händelser i webbkomponenter: varje komponent känner bara till sina egna barn. Behöver en händelse längre upp tar varje nivå emot den och skickar en egen vidare.

Barnen talar inte alltid samma språk. youtube-control skickar inställningarna i detail med nycklar i camelCase, som är brukligt i JavaScript: videoId. youtube-player vill ha attribut i kebab-case: video-id. Att översätta är förälderns jobb, och youtube-scene gör det med en liten tabell:

const ATTRIBUTE_NAME_BY_DETAIL_KEY = {
videoId: 'video-id',
autoplay: 'autoplay',
controls: 'controls',
loop: 'loop',
related: 'related',
start: 'start'
}
const BOOLEAN_DETAIL_KEYS = new Set(['autoplay', 'controls', 'loop', 'related'])
#forwardSettings(detail) {
for (const [key, value] of Object.entries(detail)) {
const attributeName = ATTRIBUTE_NAME_BY_DETAIL_KEY[key]
if (BOOLEAN_DETAIL_KEYS.has(key)) {
this.#player.toggleAttribute(attributeName, value)
} else {
this.#player.setAttribute(attributeName, value)
}
}
}

Tabellen är det enda stället som känner till båda barnens namn. Börjar formuläret kalla fältet något annat är det bara tabellen som behöver ändras. Booleska värden sätts med toggleAttribute(), av skälen i Bra komponenter.

#forwardSettings() sätter sex attribut i rad, och attributeChangedCallback() anropas för vart och ett.

Anta att youtube-player bygger om sin <iframe> direkt i attributeChangedCallback():

attributeChangedCallback() {
this.#renderVideo()
}

Användaren ändrar video-id och klickar på Apply settings. Hur många gånger börjar spelaren ladda en video?

Upp till sex gånger. setAttribute() anropar attributeChangedCallback() även när värdet är detsamma som förut, så video-id och start ger en laddning var. De booleska attributen ger en laddning när toggleAttribute() faktiskt lägger till eller tar bort dem. Den första laddningen sker med det nya video-id:t men de gamla inställningarna, nästa med en inställning till, och så vidare. Bara den sista är rätt, och användaren ser spelaren blinka.

attributeChangedCallback() vet inte att fler attribut är på väg. Det gör ingen, eftersom setAttribute() anropas ett i taget.

Lösningen är att vänta med att rita tills attributen har slutat ändras, så kallad debouncing. Varje anrop avbryter det förra och schemalägger en ny rendering:

attributeChangedCallback() {
clearTimeout(this.#renderTimeoutId)
this.#renderTimeoutId = setTimeout(() => this.#renderVideo())
}
disconnectedCallback() {
clearTimeout(this.#renderTimeoutId)
}

Alla sex setAttribute() körs i samma task, och en timer körs tidigast i nästa, som du läste om i Händelseloopen. När timern väl körs har alla sex attribut satts, och videon laddas en gång. Som allt annat komponenten har startat stoppas timern i disconnectedCallback().

Förslaget på arkitektur i examinationsuppgift B2 följer samma mönster. quiz-app är föräldern som orkestrerar. Den skickar alla förfrågningar till servern, läser och skriver topplistan i Web Storage och bestämmer vad som visas. De andra komponenterna pratar bara med användaren:

  • nickname-form berättar med en händelse att användaren har skrivit sitt namn.
  • quiz-question får frågan av quiz-app och berättar med en händelse vad användaren svarade. En fråga kan ha en array med alternativ, så den skickas med en metod, showQuestion(), och inte som attribut, av skälen i Bra komponenter. Detsamma gäller topplistan till leaderboard.
  • countdown-timer ligger i quiz-question, och quiz-app vet inte att den finns. När tiden är slut tar quiz-question emot timeout och skickar en egen timeout vidare. Det håller isär “tiden tog slut” och “användaren svarade” för quiz-app.

Ingen av de små komponenterna anropar servern, och ingen av dem vet om de andra. quiz-question fungerar lika bra med frågor från en JSON-fil som från servern. Det är också det som gör den lätt att testa för sig.

Det här är ett förslag, inte ett krav. Den första versionen av din applikation behöver inte ha fem komponenter. En quiz-app som gör allt själv och fungerar är en bättre utgångspunkt än fem komponenter som inte gör det. När du ser att en del av koden har ett eget ansvar, som timern, bryter du ut den, och då vet du också vilka attribut, metoder och händelser den behöver.

  • Komponenter anropar inte varandra. En komponent gör sitt jobb och berättar med en händelse att något har hänt.
  • Den gemensamma föräldern, en komponent eller index.js, lyssnar på ett barns händelse och skickar data ned till ett annat barn. Data går upp och ned, aldrig i sidled.
  • Varje komponent känner bara till sina egna barn. Beslut om sidan fattas där sidan bestäms.
  • Föräldern översätter mellan barnens namn och format, på ett enda ställe.
  • Sätts flera attribut i rad, vänta med att rita tills de har slutat ändras.
  • Börja enkelt. Bryt ut en komponent när du ser vilket ansvar den ska ha.
  • README.md och ARCHITECTURE.md i övningsuppgiften YouTube Player, i synnerhet avsnittet Varför tre komponenter.
  • README.md i exemplet Att göra.
  • Förslaget på arkitektur med komponent- och sekvensdiagram i README:n till examinationsuppgift B2.
CCBY