Shadow DOM v1 – eigenständige Webkomponenten

Mit Shadow DOM können Webentwickler unterteilte DOMs und CSS für Webkomponenten erstellen.

Zusammenfassung

Shadow DOM beseitigt die Anfälligkeit beim Erstellen von Web-Apps. Die Anfälligkeit ergibt sich aus der globalen Natur von HTML, CSS und JS. Im Laufe der Jahre haben wir eine exorbitante Anzahl von Tools erfunden, um die Probleme zu umgehen. Wenn Sie beispielsweise eine neue HTML-ID oder -Klasse verwenden, ist nicht sicher, ob es zu einem Konflikt mit einem vorhandenen Namen auf der Seite kommt. Subtile Fehler schleichen sich ein, die CSS-Spezifität wird zu einem großen Problem (!important alles!), Stilselektoren geraten außer Kontrolle und die Leistung kann leiden. Die Liste ist schier endlos.

Shadow DOM behebt CSS- und DOM-Probleme. Damit werden bereichsbezogene Stile auf der Webplattform eingeführt. Ohne Tools oder Namenskonventionen können Sie CSS mit Markup bündeln, Implementierungsdetails ausblenden und eigenständige Komponenten in Vanilla JavaScript erstellen.

Einführung

Shadow DOM ist einer der drei Webkomponentenstandards: HTML-Vorlagen, Shadow DOM und Benutzerdefinierte Elemente. HTML-Importe waren früher Teil der Liste, gelten jetzt aber als veraltet.

Sie müssen keine Webkomponenten erstellen, die Shadow-DOM verwenden. Wenn Sie dies jedoch tun, profitieren Sie von den Vorteilen (CSS-Scoping, DOM-Kapselung, Komposition) und erstellen wiederverwendbare benutzerdefinierte Elemente, die robust, hochgradig konfigurierbar und extrem wiederverwendbar sind. Wenn benutzerdefinierte Elemente die Möglichkeit sind, ein neues HTML-Element (mit einer JS API) zu erstellen, ist Shadow DOM die Möglichkeit, dessen HTML und CSS bereitzustellen. Die beiden APIs bilden zusammen eine Komponente mit eigenständigem HTML, CSS und JavaScript.

Shadow DOM ist ein Tool zum Erstellen komponentenbasierter Anwendungen. Daher bietet es Lösungen für häufige Probleme bei der Webentwicklung:

  • Isoliertes DOM: Das DOM einer Komponente ist eigenständig (z.B. gibt document.querySelector() keine Knoten im Shadow DOM der Komponente zurück).
  • Bereichsbezogenes CSS: CSS, das im Shadow DOM definiert ist, ist auf dieses beschränkt. Stilregeln werden nicht nach außen weitergegeben und Seitenstile werden nicht nach innen weitergegeben.
  • Komposition: Entwerfen Sie eine deklarative, Markup-basierte API für Ihre Komponente.
  • Vereinfacht CSS: Mit einem bereichsbezogenen DOM können Sie einfache CSS-Selektoren und allgemeinere ID-/Klassennamen verwenden, ohne sich Gedanken über Namenskonflikte machen zu müssen.
  • Produktivität: Betrachten Sie Apps als DOM-Blöcke und nicht als eine große (globale) Seite.

fancy-tabs-Demo

In diesem Artikel beziehe ich mich auf eine Demokomponente (<fancy-tabs>) und verweise auf Code-Snippets daraus. Wenn Ihr Browser die APIs unterstützt, sollten Sie direkt darunter eine Live-Demo sehen. Andernfalls können Sie sich die vollständige Quelle auf GitHub ansehen.

Quelle auf GitHub ansehen

Was ist Shadow-DOM?

Hintergrund zum DOM

HTML ist die Grundlage des Webs, weil es einfach zu verwenden ist. Durch die Deklaration einiger Tags können Sie in Sekundenschnelle eine Seite erstellen, die sowohl Darstellung als auch Struktur hat. HTML allein ist jedoch nicht besonders nützlich. Für Menschen ist es einfach, eine textbasierte Sprache zu verstehen, aber Maschinen brauchen etwas mehr. Hier kommt das Document Object Model (DOM) ins Spiel.

Wenn der Browser eine Webseite lädt, passieren viele interessante Dinge. Unter anderem wird das HTML des Autors in ein Live-Dokument umgewandelt. Um die Struktur der Seite zu verstehen, parst der Browser HTML (statische Textstrings) in ein Datenmodell (Objekte/Knoten). Der Browser behält die Hierarchie des HTML-Codes bei, indem er einen Baum dieser Knoten erstellt: das DOM. Das Tolle am DOM ist, dass es eine Live-Darstellung Ihrer Seite ist. Im Gegensatz zu statischem HTML, das wir erstellen, enthalten die vom Browser erzeugten Knoten Eigenschaften, Methoden und vor allem… können sie von Programmen manipuliert werden! Deshalb können wir DOM-Elemente direkt mit JavaScript erstellen:

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

erzeugt das folgende HTML-Markup:

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

Das ist alles schön und gut. Was ist dann Shadow DOM?

DOM… im Schatten

Shadow DOM ist ein normales DOM mit zwei Unterschieden: 1) wie es erstellt/verwendet wird und 2) wie es sich im Verhältnis zum Rest der Seite verhält. Normalerweise erstellen Sie DOM-Knoten und hängen sie als untergeordnete Elemente eines anderen Elements an. Mit Shadow DOM erstellen Sie einen bereichsbezogenen DOM-Baum, der an das Element angehängt, aber von seinen tatsächlichen untergeordneten Elementen getrennt ist. Diese Teilbäume werden als Shadow-Trees bezeichnet. Das Element, an das es angehängt ist, ist der Shadow-Host. Alles, was Sie im Schatten hinzufügen, wird lokal für das Hostelement, einschließlich <style>. So wird mit Shadow DOM die CSS-Stil-Eingrenzung erreicht.

Shadow-DOM erstellen

Ein Shadow-Root ist ein Dokumentfragment, das an ein Hostelement angehängt wird. Durch das Anhängen eines Shadow-Roots erhält das Element sein Shadow-DOM. Wenn Sie ein Shadow DOM für ein Element erstellen möchten, rufen Sie element.attachShadow() auf:

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

Ich verwende .innerHTML, um den Shadow-Root zu füllen, aber Sie können auch andere DOM-APIs verwenden. Das ist das Web. Wir haben die Wahl.

In der Spezifikation wird eine Liste von Elementen definiert, die keinen Shadow Tree hosten können. Es gibt mehrere Gründe, warum ein Element auf der Liste stehen könnte:

  • Der Browser hostet bereits ein eigenes internes Shadow DOM für das Element (<textarea>, <input>).
  • Es ist nicht sinnvoll, dass das Element ein Shadow DOM (<img>) hostet.

Folgendes funktioniert beispielsweise nicht:

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

Shadow DOM für ein benutzerdefiniertes Element erstellen

Shadow DOM ist besonders nützlich, wenn Sie benutzerdefinierte Elemente erstellen. Verwenden Sie Shadow DOM, um den HTML-, CSS- und JS-Code eines Elements zu unterteilen und so eine „Webkomponente“ zu erstellen.

Beispiel: Ein benutzerdefiniertes Element hängt Shadow DOM an sich selbst an und kapselt sein DOM/CSS:

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

Hier passieren ein paar interessante Dinge. Erstens: Das benutzerdefinierte Element erstellt ein eigenes Shadow DOM, wenn eine Instanz von <fancy-tabs> erstellt wird. Das geschieht in der constructor(). Zweitens: Da wir ein Shadow-Root erstellen, werden die CSS-Regeln innerhalb des <style> auf <fancy-tabs> beschränkt.

Komposition und Slots

Die Komposition ist eine der am wenigsten verstandenen Funktionen von Shadow DOM, aber wohl die wichtigste.

In der Webentwicklung ist die Komposition die Art und Weise, wie wir Anwendungen deklarativ aus HTML erstellen. Verschiedene Bausteine (<div>s, <header>s, <form>s, <input>s) bilden zusammen Anwendungen. Einige dieser Tags können sogar miteinander kombiniert werden. Die Komposition ist der Grund, warum native Elemente wie <select>, <details>, <form> und <video> so flexibel sind. Jedes dieser Tags akzeptiert bestimmte HTML-Elemente als untergeordnete Elemente und führt damit etwas Besonderes aus. <select> kann beispielsweise <option> und <optgroup> in Drop-down- und Mehrfachauswahl-Widgets rendern. Das <details>-Element rendert <summary> als erweiterbaren Pfeil. Sogar <video> weiß, wie man mit bestimmten untergeordneten Elementen umgeht: <source>-Elemente werden nicht gerendert, wirken sich aber auf das Verhalten des Videos aus. Wie magisch!

Terminologie: Light DOM und Shadow DOM

Die Shadow DOM-Komposition führt eine Reihe neuer Grundlagen in der Webentwicklung ein. Bevor wir ins Detail gehen, sollten wir uns auf einige Begriffe einigen, damit wir dieselbe Sprache sprechen.

Light DOM

Das Markup, das ein Nutzer Ihrer Komponente schreibt. Dieses DOM befindet sich außerhalb des Shadow DOM der Komponente. Es sind die tatsächlichen untergeordneten Elemente des Elements.

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Shadow DOM

Das DOM, das ein Komponentenautor schreibt. Das Shadow DOM ist lokal für die Komponente und definiert ihre interne Struktur, das bereichsbezogene CSS und kapselt Ihre Implementierungsdetails. Sie kann auch definieren, wie Markup gerendert werden soll, das vom Nutzer Ihrer Komponente erstellt wurde.

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

Vereinfachter DOM-Baum

Das Ergebnis der Verteilung des Light DOM des Nutzers durch den Browser in Ihr Shadow DOM, wodurch das endgültige Produkt gerendert wird. Der vereinfachte Baum ist das, was Sie letztendlich in den DevTools sehen und was auf der Seite gerendert wird.

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

Das <slot>-Element

Shadow-DOM setzt verschiedene DOM-Bäume mithilfe des <slot>-Elements zusammen. Slots sind Platzhalter in Ihrer Komponente, die Nutzer mit ihrem eigenen Markup füllen können. Wenn Sie einen oder mehrere Slots definieren, laden Sie externes Markup ein, im Shadow DOM Ihrer Komponente gerendert zu werden. Im Grunde sagen Sie damit: „Rendere das Markup des Nutzers hier.“

Elemente dürfen die Shadow DOM-Grenze „überschreiten“, wenn sie von einem <slot> dazu eingeladen werden. Diese Elemente werden als verteilte Knoten bezeichnet. Konzeptionell können verteilte Knoten etwas seltsam wirken. Slots verschieben das DOM nicht physisch, sondern rendern es an einer anderen Stelle im Shadow DOM.

Eine Komponente kann null oder mehr Slots in ihrem Shadow DOM definieren. Slots können leer sein oder Fallback-Content enthalten. Wenn der Nutzer keine Light-DOM-Inhalte bereitstellt, wird im Slot der Fallback-Inhalt gerendert.

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

Sie können auch benannte Slots erstellen. Benannte Slots sind bestimmte Löcher in Ihrem Shadow-DOM, auf die Nutzer namentlich verweisen.

Beispiel: Die Slots im Shadow DOM von <fancy-tabs>:

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

Komponentennutzer deklarieren <fancy-tabs> so:

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

Der abgeflachte Baum sieht in etwa so aus:

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

Unsere Komponente kann verschiedene Konfigurationen verarbeiten, aber der vereinfachte DOM-Baum bleibt gleich. Wir können auch von <button> zu <h2> wechseln. Diese Komponente wurde so erstellt, dass sie verschiedene Arten von untergeordneten Elementen verarbeiten kann – genau wie <select>.

Stile

Es gibt viele Möglichkeiten, Webkomponenten zu gestalten. Eine Komponente, die Shadow DOM verwendet, kann von der Hauptseite formatiert werden, eigene Stile definieren oder Hooks (in Form von benutzerdefinierten CSS-Eigenschaften) bereitstellen, mit denen Nutzer Standardeinstellungen überschreiben können.

Komponentendefinierte Stile

Die mit Abstand nützlichste Funktion von Shadow DOM ist bereichsbezogenes CSS:

  • CSS-Selektoren von der äußeren Seite gelten nicht innerhalb Ihrer Komponente.
  • Darin definierte Stile werden nicht nach außen übertragen. Sie sind auf das Hostelement beschränkt.

CSS-Selektoren, die im Shadow-DOM verwendet werden, gelten lokal für Ihre Komponente. In der Praxis bedeutet das, dass wir gängige ID-/Klassennamen wiederverwenden können, ohne uns Gedanken über Konflikte an anderer Stelle auf der Seite machen zu müssen. Einfachere CSS-Selektoren sind eine Best Practice im Shadow-DOM. Außerdem sind sie gut für die Leistung.

Beispiel: In einem Shadow-Root definierte Stile sind lokal

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

Stylesheets sind auch auf den Shadow Tree beschränkt:

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

Haben Sie sich schon einmal gefragt, wie das <select>-Element ein Widget für die Mehrfachauswahl (anstelle eines Drop-down-Menüs) rendert, wenn Sie das Attribut multiple hinzufügen:

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

<select> kann sich selbst basierend auf den Attributen, die Sie dafür deklarieren, unterschiedlich formatieren. Webkomponenten können sich auch selbst mithilfe des Selektors :host formatieren.

Beispiel: Eine Komponente, die sich selbst stilisiert

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

Ein Problem bei :host ist, dass Regeln auf der übergeordneten Seite eine höhere Spezifität haben als :host-Regeln, die im Element definiert sind. Das heißt, externe Stile haben Vorrang. So können Nutzer das Styling auf oberster Ebene von außen überschreiben. Außerdem funktioniert :host nur im Kontext eines Shadow-Roots, sodass Sie es nicht außerhalb des Shadow-DOMs verwenden können.

Mit der funktionalen Form von :host(<selector>) können Sie den Host als Ziel festlegen, wenn er mit einem <selector> übereinstimmt. So kann Ihre Komponente Verhaltensweisen kapseln, die auf Nutzerinteraktionen reagieren, oder interne Knoten basierend auf dem Host gestalten.

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

Stile basierend auf dem Kontext

:host-context(<selector>) stimmt mit der Komponente überein, wenn sie oder einer ihrer Ancestors mit <selector> übereinstimmt. Eine häufige Anwendung hierfür ist das Design basierend auf der Umgebung einer Komponente. Viele Nutzer wenden beispielsweise ein Design an, indem sie eine Klasse auf <html> oder <body> anwenden:

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

:host-context(.darktheme) würde <fancy-tabs> formatieren, wenn es ein Nachfahre von .darktheme ist:

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context() kann für das Design nützlich sein, aber ein noch besserer Ansatz ist es, Stilhaken mit benutzerdefinierten CSS-Eigenschaften zu erstellen.

Verteilte Knoten formatieren

::slotted(<compound-selector>) stimmt mit Knoten überein, die in einer <slot> verteilt sind.

Angenommen, wir haben eine Namensschild-Komponente erstellt:

<name-badge>
    <h2>Eric Bidelman</h2>
    <span class="title">
    Digital Jedi, <span class="company">Google</span>
    </span>
</name-badge>

Das Shadow DOM der Komponente kann die <h2>- und .title-Elemente des Nutzers formatieren:

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

Wie Sie sich vielleicht erinnern, verschieben <slot>s das Light DOM des Nutzers nicht. Wenn Knoten in einem <slot> verteilt werden, rendert das <slot> ihr DOM, aber die Knoten bleiben physisch an Ort und Stelle. Stile, die vor der Verteilung angewendet wurden, gelten auch nach der Verteilung. Wenn das Light DOM jedoch verteilt wird, kann es zusätzliche Stile annehmen, die vom Shadow DOM definiert werden.

Ein weiteres, detaillierteres Beispiel von <fancy-tabs>:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

In diesem Beispiel gibt es zwei Slots: einen benannten Slot für die Tab-Titel und einen Slot für den Inhalt des Tab-Bereichs. Wenn der Nutzer einen Tab auswählt, wird die Auswahl fett formatiert und der entsprechende Bereich angezeigt. Dazu werden verteilte Knoten mit dem Attribut selected ausgewählt. Das JS des benutzerdefinierten Elements (hier nicht gezeigt) fügt dieses Attribut zum richtigen Zeitpunkt hinzu.

Komponente von außen stilisieren

Es gibt verschiedene Möglichkeiten, eine Komponente von außen zu gestalten. Am einfachsten ist es, den Tag-Namen als Selektor zu verwenden:

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

Externe Stile haben immer Vorrang vor Stilen, die im Shadow DOM definiert sind. Wenn der Nutzer beispielsweise den Selektor fancy-tabs { width: 500px; } schreibt, hat dieser Vorrang vor der Regel der Komponente: :host { width: 650px;}.

Das Styling der Komponente selbst reicht nur bis zu einem gewissen Punkt. Aber was passiert, wenn Sie die internen Elemente einer Komponente gestalten möchten? Dafür benötigen wir benutzerdefinierte CSS-Eigenschaften.

Stil-Hooks mit benutzerdefinierten CSS-Eigenschaften erstellen

Nutzer können interne Stile anpassen, wenn der Autor der Komponente mithilfe von benutzerdefinierten CSS-Eigenschaften Styling-Hooks bereitstellt. Konzeptionell ähnelt die Idee <slot>. Sie erstellen „Stilplatzhalter“, die Nutzer überschreiben können.

Beispiel: Mit <fancy-tabs> können Nutzer die Hintergrundfarbe überschreiben:

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

Im Shadow DOM:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

In diesem Fall verwendet die Komponente black als Hintergrundwert, da der Nutzer ihn angegeben hat. Andernfalls wird standardmäßig #9E9E9E verwendet.

Themen für Fortgeschrittene

Geschlossene Shadow-Roots erstellen (sollte vermieden werden)

Es gibt eine weitere Variante des Shadow DOM, den sogenannten „geschlossenen“ Modus. Wenn Sie einen geschlossenen Shadow-Baum erstellen, kann externes JavaScript nicht auf das interne DOM Ihrer Komponente zugreifen. Das funktioniert ähnlich wie native Elemente wie <video>. JavaScript kann nicht auf das Shadow-DOM von <video> zugreifen, da der Browser es mit einem Shadow-Root im geschlossenen Modus implementiert.

Beispiel: Geschlossenen Shadow-Tree erstellen:

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

Andere APIs sind ebenfalls vom geschlossenen Modus betroffen:

  • Element.assignedSlot / TextNode.assignedSlot gibt null zurück
  • Event.composedPath() für Ereignisse, die mit Elementen im Shadow DOM verknüpft sind, gibt [] zurück

Hier ist meine Zusammenfassung, warum Sie niemals Webkomponenten mit {mode: 'closed'} erstellen sollten:

  1. Künstliches Sicherheitsgefühl. Nichts hindert einen Angreifer daran, Element.prototype.attachShadow zu kapern.

  2. Der geschlossene Modus verhindert, dass der Code Ihres benutzerdefinierten Elements auf sein eigenes Shadow-DOM zugreift. Das ist ein kompletter Reinfall. Stattdessen müssen Sie eine Referenz für später speichern, wenn Sie Elemente wie querySelector() verwenden möchten. Damit wird der ursprüngliche Zweck des geschlossenen Modus vollständig verfehlt.

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. Der geschlossene Modus macht Ihre Komponente für Endnutzer weniger flexibel. Beim Erstellen von Webkomponenten kann es vorkommen, dass Sie vergessen, eine Funktion hinzuzufügen. Eine Konfigurationsoption. Ein Anwendungsfall, den der Nutzer möchte. Ein häufiges Beispiel ist das Vergessen von geeigneten Styling-Hooks für interne Knoten. Im geschlossenen Modus können Nutzer die Standardeinstellungen nicht überschreiben und die Stile nicht anpassen. Der Zugriff auf die internen Komponenten ist sehr hilfreich. Letztendlich werden Nutzer Ihre Komponente forken, eine andere finden oder eine eigene erstellen, wenn sie nicht das tut, was sie wollen :(

Mit Slots in JS arbeiten

Die Shadow DOM API bietet Dienstprogramme für die Arbeit mit Slots und verteilten Knoten. Diese sind beim Erstellen eines benutzerdefinierten Elements nützlich.

slotchange-Ereignis

Das Ereignis slotchange wird ausgelöst, wenn sich die verteilten Knoten eines Slots ändern. Beispiel: Wenn der Nutzer untergeordnete Elemente aus dem Light DOM hinzufügt oder entfernt.

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

Wenn Sie andere Arten von Änderungen am Light DOM überwachen möchten, können Sie im Konstruktor Ihres Elements einen MutationObserver einrichten.

Welche Elemente werden in einem Slot gerendert?

Manchmal ist es nützlich zu wissen, welche Elemente mit einem Slot verknüpft sind. Rufen Sie slot.assignedNodes() auf, um herauszufinden, welche Elemente die Anzeigenfläche rendert. Mit der Option {flatten: true} wird auch der Fallback-Content eines Slots zurückgegeben (wenn keine Knoten verteilt werden).

Angenommen, Ihr Shadow DOM sieht so aus:

<slot><b>fallback content</b></slot>
NutzungAnrufErgebnis
<my-component>component text</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

Welchem Slot ist ein Element zugewiesen?

Auch die umgekehrte Frage lässt sich beantworten. element.assignedSlot gibt an, welchem der Komponenten-Slots Ihr Element zugewiesen ist.

Das Shadow-DOM-Ereignismodell

Wenn ein Ereignis aus dem Shadow DOM aufsteigt, wird sein Ziel angepasst, um die Kapselung beizubehalten, die das Shadow DOM bietet. Das heißt, Ereignisse werden so umgeleitet, dass sie so aussehen, als kämen sie von der Komponente und nicht von internen Elementen in Ihrem Shadow DOM. Einige Ereignisse werden nicht einmal aus dem Shadow-DOM weitergegeben.

Die Ereignisse, die die Schattengrenze überschreiten, sind:

  • Fokus-Ereignisse: blur, focus, focusin, focusout
  • Mausereignisse: click, dblclick, mousedown, mouseenter, mousemove usw.
  • Wheel-Ereignisse: wheel
  • Eingabeereignisse: beforeinput, input
  • Tastaturereignisse: keydown, keyup
  • Kompositionsereignisse: compositionstart, compositionupdate, compositionend
  • DragEvent: dragstart, drag, dragend, drop usw.

Tipps

Wenn der Shadow-Baum geöffnet ist, gibt der Aufruf von event.composedPath() ein Array von Knoten zurück, die das Ereignis durchlaufen hat.

Benutzerdefinierte Ereignisse verwenden

Benutzerdefinierte DOM-Ereignisse, die auf internen Knoten in einem Shadow-Baum ausgelöst werden, werden nicht über die Shadow-Grenze hinaus weitergegeben, es sei denn, das Ereignis wird mit dem Flag composed: true erstellt:

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

Wenn composed: false (Standard), können Nutzer das Ereignis nicht außerhalb des Shadow-Roots überwachen.

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

Fokus verwalten

Wie Sie vielleicht noch aus dem Ereignismodell des Shadow DOM wissen, werden Ereignisse, die im Shadow DOM ausgelöst werden, so angepasst, dass sie so aussehen, als kämen sie vom Hostelement. Angenommen, Sie klicken auf ein <input> innerhalb eines Shadow-Roots:

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

Das Ereignis focus sieht so aus, als ob es von <x-focus> und nicht von <input> stammt. document.activeElement wird zu <x-focus>. Wenn die Shadow-Root mit mode:'open' erstellt wurde (siehe geschlossener Modus), können Sie auch auf den internen Knoten zugreifen, der den Fokus erhalten hat:

document.activeElement.shadowRoot.activeElement // only works with open mode.

Wenn mehrere Ebenen von Shadow DOM im Spiel sind (z. B. ein benutzerdefiniertes Element in einem anderen benutzerdefinierten Element), müssen Sie rekursiv in die Shadow-Roots vordringen, um das activeElement zu finden:

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

Eine weitere Option für den Fokus ist die Option delegatesFocus: true, die das Fokusverhalten von Elementen innerhalb eines Shadow-Baums erweitert:

  • Wenn Sie auf einen Knoten im Shadow DOM klicken und der Knoten kein fokussierbarer Bereich ist, wird der erste fokussierbare Bereich fokussiert.
  • Wenn ein Knoten im Shadow DOM den Fokus erhält, wird :focus zusätzlich zum fokussierten Element auf den Host angewendet.

Beispiel: Wie delegatesFocus: true das Fokusverhalten ändert

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

Ergebnis

delegatesFocus: true-Verhalten.

Oben sehen Sie das Ergebnis, wenn <x-focus> fokussiert ist (Nutzerklick, Tabulator, focus() usw.), auf „Clickable Shadow DOM text“ geklickt wird oder das interne <input> fokussiert ist (einschließlich autofocus).

Wenn Sie delegatesFocus: false festlegen, sehen Sie stattdessen Folgendes:

delegatesFocus: false und die interne Eingabe ist fokussiert.
delegatesFocus: false und das interne <input> ist fokussiert.
delegatesFocus: false und x-focus erhält den Fokus (z.B. tabindex='0').
delegatesFocus: false und <x-focus> erhalten den Fokus (z.B. mit tabindex="0").
delegatesFocus: false und „Anklickbarer Shadow DOM-Text“ wird angeklickt (oder ein anderer leerer Bereich im Shadow DOM des Elements wird angeklickt).
delegatesFocus: false und „Anklickbarer Shadow DOM-Text“ werden angeklickt (oder ein anderer leerer Bereich im Shadow DOM des Elements wird angeklickt).

Tipps und Tricks

Im Laufe der Jahre habe ich einiges über das Erstellen von Webkomponenten gelernt. Ich denke, dass einige dieser Tipps für das Erstellen von Komponenten und das Debuggen von Shadow DOM nützlich sein werden.

CSS-Begrenzung verwenden

Normalerweise sind Layout, Stil und Darstellung einer Webkomponente ziemlich eigenständig. Verwenden Sie CSS-Containment in :host, um die Leistung zu verbessern:

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

Übertragbare Stile werden zurückgesetzt

Vererbbare Stile (background, color, font, line-height usw.) werden weiterhin im Shadow DOM vererbt. Das heißt, sie durchbrechen standardmäßig die Shadow-DOM-Grenze. Wenn Sie ganz von vorn beginnen möchten, können Sie mit all: initial; vererbbare Stile auf ihren ursprünglichen Wert zurücksetzen, wenn sie die Schattengrenze überschreiten.

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

Alle benutzerdefinierten Elemente finden, die auf einer Seite verwendet werden

Manchmal ist es nützlich, benutzerdefinierte Elemente zu finden, die auf der Seite verwendet werden. Dazu müssen Sie das Shadow-DOM aller auf der Seite verwendeten Elemente rekursiv durchlaufen.

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

Elemente aus einer <template> erstellen

Anstatt ein Shadow-Root-Element mit .innerHTML zu füllen, können wir ein deklaratives <template> verwenden. Vorlagen sind ein idealer Platzhalter, um die Struktur einer Webkomponente zu deklarieren.

Ein Beispiel finden Sie unter Benutzerdefinierte Elemente: Wiederverwendbare Webkomponenten erstellen.

Verlauf und Browserunterstützung

Wenn Sie die Entwicklung von Webkomponenten in den letzten Jahren verfolgt haben, wissen Sie, dass Chrome 35+ und Opera schon seit einiger Zeit eine ältere Version von Shadow DOM verwenden. Blink wird beide Versionen noch einige Zeit parallel unterstützen. Die v0-Spezifikation bot eine andere Methode zum Erstellen eines Shadow-Roots (element.createShadowRoot anstelle von element.attachShadow in v1). Durch den Aufruf der älteren Methode wird weiterhin ein Shadow-Root mit v0-Semantik erstellt, sodass vorhandener v0-Code nicht beschädigt wird.

Wenn Sie sich für die alte v0-Spezifikation interessieren, lesen Sie die html5rocks-Artikel: 1, 2, 3. Es gibt auch einen guten Vergleich der Unterschiede zwischen Shadow DOM v0 und v1.

Unterstützte Browser

Shadow DOM v1 ist in Chrome 53 (Status), Opera 40, Safari 10 und Firefox 63 enthalten. Edge hat mit der Entwicklung begonnen.

Wenn Sie prüfen möchten, ob Shadow DOM unterstützt wird, prüfen Sie, ob attachShadow vorhanden ist:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

Bis die Browserunterstützung allgemein verfügbar ist, bieten die Polyfills shadydom und shadycss die v1-Funktion. Shady DOM ahmt die DOM-Bereichsdefinition von Shadow DOM nach und shadycss-Polyfills ahmen die benutzerdefinierten CSS-Eigenschaften und die Stilbereichsdefinition der nativen API nach.

Installieren Sie die Polyfills:

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

Verwenden Sie die Polyfills:

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

Eine Anleitung zum Shimming/Scoping Ihrer Stile finden Sie unter https://github.com/webcomponents/shadycss#usage.

Fazit

Zum ersten Mal gibt es ein API-Primitiv, das korrektes CSS-Scoping, DOM-Scoping und echte Komposition bietet. In Kombination mit anderen Webkomponenten-APIs wie benutzerdefinierten Elementen bietet Shadow DOM eine Möglichkeit, wirklich gekapselte Komponenten ohne Hacks oder ältere Elemente wie <iframe>s zu erstellen.

Versteh mich nicht falsch. Shadow DOM ist sicherlich ein komplexes Thema. Aber es lohnt sich, sich damit zu beschäftigen. Nimm dir etwas Zeit dafür. Lernen Sie es und stellen Sie Fragen!

Weitere Informationen

FAQ

Kann ich Shadow DOM v1 schon heute verwenden?

Mit einem Polyfill, ja. Weitere Informationen finden Sie unter Browserunterstützung.

Welche Sicherheitsfunktionen bietet Shadow DOM?

Shadow DOM ist keine Sicherheitsfunktion. Es ist ein einfaches Tool, um CSS-Bereiche zu definieren und DOM-Bäume in Komponenten zu verbergen. Wenn Sie eine echte Sicherheitsgrenze benötigen, verwenden Sie eine <iframe>.

Muss eine Webkomponente Shadow DOM verwenden?

Nein. Sie müssen keine Webkomponenten erstellen, die Shadow DOM verwenden. Wenn Sie jedoch benutzerdefinierte Elemente erstellen, die Shadow DOM verwenden, können Sie Funktionen wie CSS-Scoping, DOM-Kapselung und Komposition nutzen.

Was ist der Unterschied zwischen offenen und geschlossenen Shadow-Roots?

Weitere Informationen finden Sie unter Geschlossene Shadow-Roots.