Hoppa till innehåll

Komponenter i komponenter

HTML bygger på att små element sätts ihop till större. En <ul> innehåller <li>, ett <form> innehåller <input> och <button>. Komponenter sätts ihop på samma sätt, och det finns två sätt att göra det. Den här artikeln går igenom båda, och bygger på shadow DOM och slots från kursvecka 10 och den föregående artikeln, Bra komponenter.

Exemplen kommer från exemplet Att göra, där todo-list innehåller todo-item, och från övningsuppgiften YouTube Player, där youtube-scene innehåller youtube-control och youtube-player.

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

Barnen kan ligga i två olika träd:

  • I light DOM, alltså som vanliga barn till komponenten i dokumentets träd. Den som använder komponenten skriver dem, och komponenten visar dem med ett <slot>. Så fungerar todo-list, och så fungerar <ul>.
  • I shadow DOM. Komponenten skapar barnen själv, i sin egen mall, och ingen utanför ser dem. Så fungerar youtube-scene, och så fungerar <video controls>: knapparna och reglagen finns, men du har inte skrivit dem och kommer inte åt dem.
Objektdiagram i två delar. Till vänster, barn i light DOM: i dokumentets träd har todo-list två barn, todo-item och todo-item done, och via shadowRoot ett shadow-träd med shadow-root, div och slot. Streckade pilar visar att båda todo-item visas i slotten. En anteckning säger att de skrivs av den som använder todo-list och nås med this.querySelectorAll. Till höger, barn i shadow DOM: i dokumentets träd finns bara youtube-scene, och i dess shadow-träd finns shadow-root, main och under den youtube-player och youtube-control. En anteckning säger att de skapas av youtube-scenes egen mall och nås med this.shadowRoot.querySelector.

Valet handlar om vem som ska bestämma över barnen.

Med barn i light DOM bestämmer den som använder komponenten. Det är lätt att lägga till, ta bort och ändra en uppgift i listan, både i HTML-koden och med JavaScript, och det kräver inget annat än det alla redan kan:

<todo-list>
<todo-item>Buy milk</todo-item>
<todo-item done>Do the dishes</todo-item>
</todo-list>

Med barn i shadow DOM bestämmer komponenten. Den som använder youtube-scene skriver bara <youtube-scene></youtube-scene> och behöver inte veta att det finns ett formulär och en spelare inuti. Komponenten kan byta ut sina delar utan att någon utanför märker det.

Fråga dig om barnen är innehåll eller delar. Uppgifterna i en att göra-lista är innehåll; de är olika varje gång listan används. Formuläret och spelaren i youtube-scene är delar; de är alltid desamma. Innehåll hör hemma i light DOM, delar i shadow DOM.

En komponent kan göra båda. todo-item har sin kryssruta i shadow DOM, som en del, och tar emot texten som innehåll genom ett <slot>.

Var barnen finns avgör hur komponenten hittar dem. Barn i light DOM är vanliga barn till komponenten, och nås från this:

todo-list.js
const items = this.querySelectorAll('todo-item')

Barn i shadow DOM nås från this.shadowRoot:

youtube-scene.js
this.#player = this.shadowRoot.querySelector('youtube-player')

I inget av fallen söker komponenten i document, av skälen i föregående artikel.

todo-list och todo-item har var sitt shadow DOM med var sitt <slot>, och todo-item har en kryssruta i sitt. Vad ger de här anropen inuti todo-list, med listan ovan?

this.querySelectorAll('todo-item').length
this.shadowRoot.querySelectorAll('todo-item').length
this.querySelectorAll('input').length

2, 0 och 0.

Uppgifterna är barn till todo-list i dokumentets träd. Att de visas i listans <slot> flyttar dem inte in i listans shadow-träd, så this.shadowRoot hittar dem inte.

Kryssrutorna finns i varje todo-items shadow-träd. Varken this.querySelectorAll() eller this.shadowRoot.querySelectorAll() letar sig in i ett annat elements shadow DOM. Det är inkapslingen som gör att todo-list inte kan börja bero på hur todo-item är byggd inuti.

Behöver komponenten bara de element som just nu visas i en viss slot finns assignedElements() på slot-elementet. Det är användbart när en komponent har flera namngivna slots.

todo-list visar hur många uppgifter som är klara. Räknar den bara i connectedCallback() blir siffran fel så fort någon lägger till en uppgift, eftersom listan inte vet om det.

Ett <slot> utlöser händelsen slotchange när de element som visas i den ändras, alltså när barn läggs till eller tas bort:

connectedCallback() {
this.#abortController = new AbortController()
const signal = this.#abortController.signal
const slot = this.shadowRoot.querySelector('slot')
slot.addEventListener('slotchange', () => this.#update(), { signal })
this.addEventListener('statechanged', () => this.#update(), { signal })
this.#update()
}
#update() {
const items = [...this.querySelectorAll('todo-item')]
this.#total.textContent = items.length
this.#completed.textContent = items.filter((item) => item.hasAttribute('done')).length
}

slotchange utlöses inte när ett barn ändras, bara när det kommer eller går. Att en uppgift bockas av berättar todo-item själv, med händelsen statechanged, och det är den andra lyssnaren.

todo-item utlöser statechanged med bubbles: true:

todo-item.js
#onCheckboxChange() {
this.toggleAttribute('done', this.#checkbox.checked)
this.dispatchEvent(new CustomEvent('statechanged', {
detail: { done: this.#checkbox.checked },
bubbles: true
}))
}

Det är fallet som kursens händelsekonvention tar upp: bubbles behövs när föräldern inte lyssnar på barnet. todo-list vet inte vilka uppgifter den kommer att få, så den har en enda lyssnare på sig själv i stället för en på varje uppgift. Det kallas händelsedelegering. Händelsen bubblar från uppgiften till listan som vilken händelse som helst, eftersom uppgiften är listans vanliga barn. composed behövs inte; händelsen utlöses på todo-item och lämnar aldrig något shadow-träd.

Skulle todo-list kunna lyssna på statechanged på <div>-elementet runt sin <slot>, inuti sitt eget shadow DOM, i stället för på this?

this.shadowRoot.querySelector('div').addEventListener('statechanged', ...)

Ja. Ett element som visas i en slot har slotten som förälder när en händelse ska bubbla. Händelsen går från todo-item till <slot>, vidare till <div> och listans shadow-root, och därefter ut till todo-list och upp genom dokumentet.

Inspelningen från 2021 gissar fel här, men slutsatsen står sig: lyssnaren på this är det enkla valet, och den fungerar oavsett hur listans mall ser ut.

Att händelsen fortsätter upp genom dokumentet har en följd. Även kod utanför listan kan lyssna på den, och det utnyttjar exemplets index.js, som du ser i nästa artikel. Det har också en baksida: en bubblande händelse når alla förfäder, så den behöver ett namn som inte krockar med andras. Ett allmänt namn som change vore en dålig idé.

En knapp på sidan ska bocka av alla uppgifter, och index.js gör så här:

for (const item of list.querySelectorAll('todo-item')) {
item.toggleAttribute('done', true)
}

Kryssrutorna blir ikryssade, eftersom todo-item lyssnar på sitt attribut. Visar listan rätt antal klara uppgifter?

Nej. Inga barn har kommit eller gått, så ingen slotchange. Och todo-item utlöser statechanged bara när användaren klickar, inte när attributet ändras utifrån.

Det är medvetet, och det härmar de inbyggda elementen: sätter du checked på en kryssruta med JavaScript utlöses ingen change. Den som ändrar ett attribut vet redan om det och behöver ingen händelse. Händelser berättar om sådant som föräldern inte själv har orsakat.

Ska listan hålla räkningen även då finns två vägar. Antingen säger den som ändrade till listan att räkna om, med en metod. Eller så bevakar listan sina barns done-attribut med en MutationObserver. Den andra gör listan självgående, men kräver att den känner till ännu ett attribut hos sina barn.

youtube-scene skapar sina barn i sin egen mall:

youtube-scene.html
<main>
<youtube-player></youtube-player>
<youtube-control></youtube-control>
</main>

När mallen klonas in i shadow-roten skapas båda elementen, och konstruktorn kan hämta dem med en gång:

constructor() {
super()
this.attachShadow({ mode: 'open' })
this.shadowRoot.adoptedStyleSheets = [stylesheet]
this.shadowRoot.append(template.content.cloneNode(true))
this.#control = this.shadowRoot.querySelector('youtube-control')
this.#player = this.shadowRoot.querySelector('youtube-player')
}

Föräldern har skapat barnen och har referenser till dem. Den lyssnar direkt på dem och skickar data till dem med attribut och metoder. Barnens händelser behöver varken bubbles eller composed.

Står barnen i mallen finns de alltid, ett av varje. Det passar delar, som formuläret och spelaren. Det passar också förslaget på arkitektur i examinationsuppgift B2, där det alltid finns exakt en quiz-question och en countdown-timer.

Varierar antalet, som rader i en sökträfflista, skapas barnen med document.createElement() och läggs in i komponentens eget träd:

#showBooks(books) {
const items = books.map((book) => {
const item = document.createElement('book-item')
item.setAttribute('book-id', book.id)
item.textContent = book.title
return item
})
this.#list.replaceChildren(...items)
}

replaceChildren() tar bort de gamla raderna och lägger in de nya i ett svep.

En komponent med barn i shadow DOM fungerar bara om barnens taggar är registrerade. Därför laddar föräldern barnen. Importen ligger i förälderns index.js, inte i klassfilen, så att klassen fortsatt går att importera utan att någon tagg registreras:

youtube-scene/index.js
import '../youtube-control/index.js'
import '../youtube-player/index.js'
import { YoutubeScene } from './youtube-scene.js'
if (!customElements.get('youtube-scene')) {
customElements.define('youtube-scene', YoutubeScene)
}

Applikationen behöver då bara ladda youtube-scene, och får de andra två på köpet. Varför klassen och registreringen ligger i olika filer läste du i En komponents filer.

  • Barn i light DOM är innehåll som den som använder komponenten bestämmer över. De visas med <slot> och nås med this.querySelector().
  • Barn i shadow DOM är delar som komponenten bestämmer över. De skapas i mallen eller med document.createElement() och nås med this.shadowRoot.querySelector().
  • slotchange berättar att barn har kommit eller gått, inte att de har ändrats.
  • Barn i light DOM berättar om ändringar med en händelse med bubbles: true, och föräldern lyssnar på sig själv. Barn i shadow DOM lyssnar föräldern på direkt.
  • Händelser berättar om sådant föräldern inte själv har orsakat, precis som change på en kryssruta.
  • En förälder som skapar barn i shadow DOM importerar barnens index.js från sin egen index.js.
CCBY