Hoppa till innehåll

Shadow DOM

I förra artikeln såg du att en komponents konstruktor inte får lägga till barn på elementet självt. Ändå behöver nästan varje komponent ett inre: en knapp, en bild, några rubriker. Den här artikeln handlar om var det inre hamnar, shadow DOM, och om hur komponentens stilar och sidans stilar hålls isär.

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

Du har redan använt element med ett dolt inre. Ett <video controls> har knappar för att spela och pausa och ett reglage för volymen, men de syns inte bland elementets barn i utvecklarverktygen, och document.querySelector('button') hittar dem inte. Knapparna ligger i elementets eget, inkapslade träd.

Shadow DOM ger ditt element samma möjlighet. Elementet blir en shadow host, och till det kopplas en shadow root, roten i ett eget träd:

constructor() {
super()
this.attachShadow({ mode: 'open' })
this.shadowRoot.append(template.content.cloneNode(true))
}

Att koppla på en shadow root räknas inte som att lägga till barn på elementet, så det är tillåtet i konstruktorn. Med mode: 'open' når du roten via this.shadowRoot, och det gör även kod utanför komponenten. Det finns också mode: 'closed', där shadowRoot ger null utifrån, men det skyddar inte mot något på riktigt och gör komponenten svårare att testa. Kursens komponenter använder 'open'.

I utvecklarverktygen syns shadow root som #shadow-root (open) direkt under elementet. Öppna den, så ser du komponentens inre.

Komponentens HTML och CSS skrivs som vanlig HTML och CSS, i egna filer. Så här ser user-card ut:

user-card.html
<article>
<h3 class="name">Unknown</h3>
<slot></slot>
</article>
user-card.css
:host {
display: block;
}
h3 {
margin: 0;
}
user-card.js
import htmlText from './user-card.html?raw'
import cssText from './user-card.css?inline'
const template = document.createElement('template')
template.innerHTML = htmlText
const stylesheet = new CSSStyleSheet()
stylesheet.replaceSync(cssText)
export class UserCard extends HTMLElement {
#name
constructor() {
super()
this.attachShadow({ mode: 'open' })
this.shadowRoot.adoptedStyleSheets = [stylesheet]
this.shadowRoot.append(template.content.cloneNode(true))
this.#name = this.shadowRoot.querySelector('.name')
}
// ...
}

Det är samma uppbyggnad som komponenterna i kursens övningsuppgifter. ?raw och ?inline får Vite att importera filernas innehåll som strängar. Varför kursen gör så, och vilka alternativen är, tar nästa artikel upp.

Lägg märke till vad som körs var:

  • En gång, när modulen laddas: mallen skapas från strängen, som i kursvecka 9, och CSS-koden tolkas till ett CSSStyleSheet.
  • En gång per element, i konstruktorn: mallen klonas in i elementets shadow root, och stilmallen kopplas på med adoptedStyleSheets.

Alla <user-card> på sidan delar alltså på samma stilmallsobjekt i stället för att var och en får en egen kopia av CSS-koden som måste tolkas. Du kan också lägga CSS-koden i ett <style>-element i mallen. Det fungerar, men då tolkas den på nytt för varje element.

Shadow-trädet är skilt från resten av dokumentet:

  • document.querySelector() söker inte in i shadow-träd. Komponenten söker i sitt eget träd med this.shadowRoot.querySelector().
  • CSS i komponenten gäller bara i komponenten. Regeln h3 { margin: 0; } ovan påverkar inte någon annan <h3> på sidan.
  • Sidans CSS gäller inte heller inuti komponenten. En regel som h3 { color: red; } i sidans stilmall når inte rubriken i user-card.
  • Ett id i komponenten behöver bara vara unikt inom komponenten.

Det är därför du kan använda korta, enkla selektorer som h3 och button i en komponent utan att tänka på resten av sidan.

Sidans stilmall innehåller:

body {
color: darkblue;
font-family: Georgia, serif;
}
h3 {
color: red;
}

Vilken färg och vilket typsnitt får rubriken inuti user-card, där komponentens egen CSS inte sätter någon färg eller något typsnitt?

Rubriken blir mörkblå och får typsnittet Georgia, inte röd.

Regeln h3 { color: red; } är en selektor, och sidans selektorer når inte in i shadow-trädet. Men egenskaper som ärvs, som color och font-family, ärvs genom gränsen. Shadow-trädet ärver från sin shadow host, <user-card>, som i sin tur ärver från <body>.

Det är en medveten del av standarden: en komponent ska smälta in i sidans typografi, men inte gå sönder av sidans selektorer.

Inuti komponenten väljer du elementet självt, shadow host, med :host. Ett custom element är som standard ett inline-element, som <span>. Därför börjar nästan varje komponents CSS med:

:host {
display: block;
}
:host([hidden]) {
display: none;
}

Den andra regeln behövs eftersom display: block annars tar över webbläsarens standardregel för attributet hidden. :host() med en selektor inom parentes matchar bara när elementet självt matchar selektorn, här när det har attributet hidden.

Sidan kan fortfarande styla elementet självt utifrån, till exempel med user-card { margin: 1em; }. Sätter både sidan och :host samma egenskap vinner sidans regel, så användaren av komponenten har sista ordet.

Ibland vill du låta den som använder komponenten styla något i den. Det finns två sätt, och båda bygger på att komponenten själv bestämmer vad som får ändras.

CSS custom properties (CSS-variabler) ärvs genom gränsen, precis som color. Komponenten använder en variabel med ett standardvärde:

user-card.css
h3 {
color: var(--user-card-name-color, inherit);
}
/* the page's stylesheet */
user-card {
--user-card-name-color: darkred;
}

::part() låter sidan styla ett helt element i komponenten, som komponenten har märkt med attributet part:

user-card.html
<h3 class="name" part="name">Unknown</h3>
/* the page's stylesheet */
user-card::part(name) {
font-variant: small-caps;
}

En CSS-variabel exponerar ett enskilt värde, en part ett helt element. Övningsuppgiften bart-board i kursvecka 11 exponerar sin text som part="text".

En komponent med en mall visar alltid samma struktur. Ofta vill du att den som använder komponenten ska kunna skicka in eget innehåll, som barn till elementet:

<user-card name="Ada Lovelace">
<p>Wrote the first published computer program.</p>
</user-card>

Barnen hamnar i dokumentets vanliga träd, inte i shadow-trädet. För att de ska visas måste mallen innehålla ett <slot>-element, en platshållare som talar om var de ska visas. Mallen för user-card har en sådan efter rubriken, så stycket visas där.

Med attributet name kan en mall ha flera named slots. Innehållet väljer plats med attributet slot:

user-card.html
<article>
<h3><slot name="name">Unknown</slot></h3>
<slot></slot>
</article>
<user-card>
<span slot="name">Ada Lovelace</span>
<p>Wrote the first published computer program.</p>
</user-card>

Barn med slot="name" hamnar i slotten med samma namn, och resten i slotten utan namn. Innehållet mellan slot-taggarna i mallen, Unknown, visas om inget skickas in.

Objektdiagram med två träd. Dokumentets träd: user-card har barnen span slot=name och p, och via shadowRoot en shadow-root. Komponentens shadow-träd: shadow-root har barnen h3 och en slot utan namn, och h3 har barnet slot name=name. Streckade pilar visar att span visas i slot name=name och p i slotten utan namn.

Diagrammet visar det viktiga: de inskickade elementen flyttas inte. De är fortfarande barn till <user-card> i dokumentets träd, och visas bara på slottens plats.

Med HTML-koden ovan, vad ger de här två raderna?

document.querySelector('user-card span')
document.querySelector('user-card h3')

Den första ger <span slot="name">, den andra null.

<span> är ett vanligt barn till <user-card> i dokumentets träd. Att det visas inuti komponentens <h3> ändrar inte var det finns. <h3> ligger däremot i shadow-trädet, dit document.querySelector() inte når.

Samma sak gäller CSS: sidans stilmall kan styla <span> som vanligt, eftersom det är sidans element.

Inuti komponenten kan du styla det inskickade innehållet med ::slotted():

::slotted(p) {
margin-block: 0.5em 0;
}

::slotted() når bara de element som själva skickats in, inte deras barn. Och eftersom innehållet är sidans element vinner sidans CSS om båda sätter samma egenskap.

Allt ovan skapar shadow-trädet med JavaScript. Sedan 2024 kan det även skrivas direkt i HTML, med <template shadowrootmode="open"> som första barn till elementet. Det används när sidan genereras på en server, så att komponenten syns innan JavaScript har körts. Du behöver det inte i kursen, där all HTML skapas i webbläsaren, men du kan stöta på det när du söker information.

  • En komponents inre läggs i en shadow root, som skapas med this.attachShadow({ mode: 'open' }) i konstruktorn.
  • Mallen och stilmallen skapas en gång per modul. Varje element klonar mallen och delar stilmallen via adoptedStyleSheets.
  • Selektorer når inte genom gränsen åt något håll, men ärvda egenskaper och CSS-variabler gör det.
  • :host stylar elementet självt. Ge det display: block och glöm inte :host([hidden]).
  • Sidan kan styla det komponenten exponerar, med CSS-variabler och ::part().
  • <slot> visar elementets barn på en bestämd plats i shadow-trädet, men barnen ligger kvar i dokumentets träd.
CCBY