Deklaratives Schatten-DOM

Eine neue Möglichkeit, Shadow DOM direkt in HTML zu implementieren und zu verwenden.

Das deklarative Shadow-DOM ist eine Webplattformfunktion, die sich derzeit im Standardisierungsprozess befindet. Sie ist in Chrome-Version 111 standardmäßig aktiviert.

Shadow DOM ist einer der drei Standards für Webkomponenten, die durch HTML-Vorlagen und benutzerdefinierte Elemente abgerundet werden. Mit Shadow-DOM können Sie CSS-Stile auf eine bestimmte DOM-Unterstruktur beschränken und diese Unterstruktur vom Rest des Dokuments isolieren. Mit dem Element <slot> können Sie steuern, wo die untergeordneten Elemente eines benutzerdefinierten Elements in dessen Schattenbaum eingefügt werden. Die Kombination dieser Funktionen ermöglicht ein System zum Erstellen eigenständiger, wiederverwendbarer Komponenten, die sich wie ein integriertes HTML-Element nahtlos in vorhandene Anwendungen einbinden lassen.

Bisher bestand die einzige Möglichkeit zur Verwendung von Shadow DOM darin, mit JavaScript eine Shadow-Stamm zu konstruieren:

const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

Eine solche imperative API eignet sich gut für das clientseitige Rendering: Die JavaScript-Module, die unsere benutzerdefinierten Elemente definieren, erstellen auch ihre Shadow Roots und legen ihre Inhalte fest. Viele Webanwendungen müssen Inhalte jedoch beim Erstellen serverseitig oder in statischem HTML rendern. Dies kann wichtig sein, um Besuchern, die kein JavaScript ausführen können, eine angemessene Nutzererfahrung zu bieten.

Die Gründe für das serverseitige Rendering (SSR) sind von Projekt zu Projekt unterschiedlich. Einige Websites müssen voll funktionsfähigen, serverseitig gerenderten HTML-Code bereitstellen, um die Richtlinien für Barrierefreiheit zu erfüllen. Andere wiederum bieten eine Basislinie ohne JavaScript an, um eine gute Leistung bei langsamen Verbindungen oder Geräten zu gewährleisten.

In der Vergangenheit war es schwierig, Shadow DOM in Kombination mit serverseitigem Rendering zu verwenden, da es keine integrierte Möglichkeit gab, Shadow Roots im servergenerierten HTML-Code auszudrücken. Es gibt auch Auswirkungen auf die Leistung, wenn Shadow Roots an DOM-Elemente angehängt werden, die ohne diese Elemente gerendert wurden. Dies kann dazu führen, dass nach dem Laden der Seite Layoutverschiebungen auftreten oder dass beim Laden der Stylesheets von Shadow Root vorübergehend unformatierte Inhalte („FOUC“) angezeigt werden.

Durch das Declarative Shadow DOM (DSD) wird diese Einschränkung aufgehoben und das Shadow DOM wird auf den Server eingefügt.

Einen deklarativen Schattenstamm aufbauen

Eine deklarative Shadow-Stammversion ist ein <template>-Element mit einem shadowrootmode-Attribut:

<host-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

Ein Vorlagenelement mit dem Attribut shadowrootmode wird vom HTML-Parser erkannt und sofort als Schattenstamm des übergeordneten Elements angewendet. Das Laden des reinen HTML-Markups aus dem obigen Beispiel führt zur folgenden DOM-Baumstruktur:

<host-element>
  #shadow-root (open)
  <slot>
    ↳
    <h2>Light content</h2>
  </slot>
</host-element>

Dieses Codebeispiel entspricht den Konventionen des Steuerfelds „Elemente“ der Chrome-Entwicklertools zur Anzeige von Shadow DOM-Inhalten. So steht z. B. das Zeichen für einen Slot-Light-DOM-Inhalt.

Dies bietet uns die Vorteile der Kapselung und Slotprojektion von Shadow DOM in statischem HTML. JavaScript ist nicht erforderlich, um den gesamten Baum, einschließlich Shadow Root, zu erstellen.

Hydration von Komponenten

Deklaratives Shadow-DOM kann allein verwendet werden, um Stile zu kapseln oder untergeordnete Placements anzupassen. Es ist jedoch am stärksten, wenn es zusammen mit benutzerdefinierten Elementen verwendet wird. Komponenten, die mit benutzerdefinierten Elementen erstellt wurden, werden automatisch aus statischem HTML-Code aktualisiert. Mit der Einführung des deklarativen Shadow-DOM ist es jetzt möglich, dass ein benutzerdefiniertes Element einen Schattenstamm hat, bevor es aktualisiert wird.

Ein benutzerdefiniertes Element, das von HTML aktualisiert wird und einen deklarativen Shadow-Stamm enthält, ist bereits mit diesem Schattenstamm verknüpft. Das bedeutet, dass für das Element die Eigenschaft shadowRoot bereits verfügbar ist, wenn es instanziiert wird, ohne dass Ihr Code explizit eine erstellt hat. Am besten prüfen Sie this.shadowRoot auf eine vorhandene Schattenwurzel im Konstruktor des Elements. Wenn bereits ein Wert vorhanden ist, enthält der HTML-Code für diese Komponente einen deklarativen Shadow-Stamm. Wenn der Wert null ist, war kein Deklarative Shadow Root im HTML-Code vorhanden oder der Browser unterstützt das deklarative Shadow-DOM nicht.

<menu-toggle>
  <template shadowrootmode="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>
<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // Detect whether we have SSR content already:
      if (this.shadowRoot) {
        // A Declarative Shadow Root exists!
        // wire up event listeners, references, etc.:
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // A Declarative Shadow Root doesn't exist.
        // Create a new shadow root and populate it:
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

  customElements.define('menu-toggle', MenuToggle);
</script>

Benutzerdefinierte Elemente gibt es schon seit geraumer Zeit und bisher gab es keinen Grund, nach einem vorhandenen Schattenstamm zu suchen, bevor ein solcher mit attachShadow() erstellt wurde. Das deklarative Shadow-DOM enthält eine kleine Änderung, die dafür sorgt, dass vorhandene Komponenten trotzdem funktionieren: Wenn die Methode attachShadow() für ein Element mit einem vorhandenen deklarativen Shadow Root-Element aufgerufen wird, wird kein Fehler ausgegeben. Stattdessen wird der deklarative Shadow-Stamm geleert und zurückgegeben. Ältere Komponenten, die nicht für das deklarative Shadow-DOM erstellt wurden, können dadurch weiterhin funktionieren, da deklarative Stammkomponenten beibehalten werden, bis ein erforderlicher Ersatz erstellt wird.

Bei neu erstellten benutzerdefinierten Elementen bietet die neue Property ElementInternals.shadowRoot eine explizite Möglichkeit, einen Verweis auf das vorhandene deklarative Shadow Root eines Elements abzurufen – sowohl offen als auch geschlossen. Dies kann verwendet werden, um deklarative Shadow Root-Zertifikate zu suchen und zu verwenden. Wenn kein Root-Wert angegeben wurde, wird auf attachShadow() zurückgesetzt.

class MenuToggle extends HTMLElement {
  constructor() {
    super();

    const internals = this.attachInternals();

    // check for a Declarative Shadow Root:
    let shadow = internals.shadowRoot;
    if (!shadow) {
      // there wasn't one. create a new Shadow Root:
      shadow = this.attachShadow({
        mode: 'open'
      });
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // in either case, wire up our event listener:
    shadow.firstChild.addEventListener('click', toggle);
  }
}
customElements.define('menu-toggle', MenuToggle);

Ein Schatten pro Wurzel

Ein deklarativer Schattenstamm ist nur mit seinem übergeordneten Element verknüpft. Dies bedeutet, dass Schattenwurzeln immer am selben Standort wie das zugehörige Element liegen. Durch diese Designentscheidung wird sichergestellt, dass Schattenwurzeln wie der Rest eines HTML-Dokuments gestreamt werden können. Es ist auch praktisch für das Schreiben und Generieren, da für das Hinzufügen einer Schattenwurzel zu einem Element keine Registrierung mit vorhandenen Schattenwurzeln erforderlich ist.

Der Nachteil bei der Verknüpfung von Schattenwurzeln mit ihrem übergeordneten Element besteht darin, dass nicht mehrere Elemente aus demselben deklarativen Schattenstamm-<template> initialisiert werden können. In den meisten Fällen, in denen deklaratives Shadow-DOM verwendet wird, ist dies jedoch unwahrscheinlich, da der Inhalt jeder Schattenwurzel selten identisch ist. Vom Server gerenderter HTML-Code enthält häufig wiederholte Elementstrukturen. Ihr Inhalt unterscheidet sich jedoch in der Regel, z. B. bei leichten Abweichungen im Text oder bei Attributen. Da der Inhalt eines deklarativen deklarativen Schattenstamms vollständig statisch ist, funktioniert das Upgrade mehrerer Elemente aus einem einzigen deklarativen Schattenstamm nur dann, wenn die Elemente identisch sind. Schließlich sind die Auswirkungen wiederholter ähnlicher Schattenwurzeln auf die Netzwerkübertragungsgröße aufgrund der Auswirkungen der Komprimierung relativ gering.

In Zukunft ist es möglicherweise möglich, gemeinsame Schattenwurzeln erneut aufzurufen. Wenn das DOM integrierte Vorlagen unterstützt, können deklarative Shadow Roots als Vorlagen behandelt werden, die instanziiert werden, um die Schattenwurzel für ein bestimmtes Element zu konstruieren. Das aktuelle deklarative Shadow-DOM-Design ermöglicht diese Möglichkeit in Zukunft, da die Schatten-Stammverknüpfung auf ein einzelnes Element beschränkt ist.

Streaming ist cool

Die direkte Verknüpfung von deklarativen Shadow Roots mit ihrem übergeordneten Element vereinfacht das Upgrade und die Verknüpfung mit dem Element. Deklarative Shadow Roots werden beim HTML-Parsing erkannt und sofort angehängt, wenn ihr öffnendes <template>-Tag gefunden wird. Geparster HTML-Code im <template> wird direkt in den Schattenstamm geparst, sodass er gestreamt und so gerendert werden kann, wie er empfangen wird.

<div id="el">
  <script>
    el.shadowRoot; // null
  </script>

  <template shadowrootmode="open">
    <!-- shadow realm -->
  </template>

  <script>
    el.shadowRoot; // ShadowRoot
  </script>
</div>

Nur Parser

Das deklarative Shadow-DOM ist eine Funktion des HTML-Parsers. Das bedeutet, dass ein deklarativer Schattenstamm nur für <template>-Tags mit einem shadowrootmode-Attribut, die beim HTML-Parsing vorhanden sind, geparst und angehängt wird. Mit anderen Worten, deklarative Shadow Roots können während des ersten HTML-Parsings erstellt werden:

<some-element>
  <template shadowrootmode="open">
    shadow root content for some-element
  </template>
</some-element>

Das Festlegen des Attributs shadowrootmode eines <template>-Elements hat keine Auswirkungen und die Vorlage bleibt ein gewöhnliches Vorlagenelement:

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null

Deklarative Shadow Roots können nicht mit APIs zum Parsen von Fragmenten wie innerHTML oder insertAdjacentHTML() erstellt werden, um einige wichtige Sicherheitsaspekte zu vermeiden. Die einzige Möglichkeit zum Parsen von HTML mit angewendeten deklarativen Shadow Roots besteht darin, eine neue includeShadowRoots-Option an DOMParser zu übergeben:

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  const fragment = new DOMParser().parseFromString(html, 'text/html', {
    includeShadowRoots: true
  }); // Shadow root here
</script>

Server-Rendering mit Stil

Inline- und externe Stylesheets werden in deklarativen Shadow Roots mithilfe der Standard-<style>- und <link>-Tags vollständig unterstützt:

<nineties-button>
  <template shadowrootmode="open">
    <style>
      button {
        color: seagreen;
      }
    </style>
    <link rel="stylesheet" href="/comicsans.css" />
    <button>
      <slot></slot>
    </button>
  </template>
  I'm Blue
</nineties-button>

Auf diese Weise angegebene Stile sind auch stark optimiert: Wenn dasselbe Stylesheet in mehreren deklarativen Shadow Roots vorhanden ist, wird es nur einmal geladen und geparst. Der Browser verwendet eine einzelne unterstützende CSSStyleSheet, die von allen Shadow-Roots gemeinsam genutzt wird, um doppelten Arbeitsspeicher-Overhead zu vermeiden.

Konstruktierbare Stylesheets werden im deklarativen Schatten-DOM nicht unterstützt. Das liegt daran, dass es derzeit keine Möglichkeit gibt, konstruierbare Stylesheets in HTML zu serialisieren und beim Ausfüllen von adoptedStyleSheets darauf zu verweisen.

Flash von unformatierten Inhalten vermeiden

Ein mögliches Problem in Browsern, die Declarative Shadow DOM noch nicht unterstützen, besteht darin, FOUC (Flash of Unstyled Content) zu vermeiden, bei dem die Rohinhalte für benutzerdefinierte Elemente angezeigt werden, die noch nicht aktualisiert wurden. Vor dem deklarativen Schatten-DOM bestand eine häufige Methode zur Vermeidung von FOUC darin, eine display:none-Stilregel auf benutzerdefinierte Elemente anzuwenden, die noch nicht geladen wurden, da bei diesen der Schattenstamm nicht angehängt und ausgefüllt wurde. Auf diese Weise werden Inhalte erst angezeigt, wenn sie „bereit“ sind:

<style>
  x-foo:not(:defined) > * {
    display: none;
  }
</style>

Mit der Einführung des deklarativen Shadow-DOM können benutzerdefinierte Elemente in HTML so gerendert oder erstellt werden, dass ihr Schatteninhalt vorhanden und bereit ist, bevor die clientseitige Komponentenimplementierung geladen wird:

<x-foo>
  <template shadowrootmode="open">
    <style>h2 { color: blue; }</style>
    <h2>shadow content</h2>
  </template>
</x-foo>

In diesem Fall würde die „FOUC“-Regel display:none verhindern, dass der Inhalt des deklarativen Schattenstamms angezeigt wird. Das Entfernen dieser Regel würde jedoch dazu führen, dass in Browsern ohne Unterstützung für deklaratives Shadow-DOM falsche oder nicht gestaltete Inhalte angezeigt werden, bis das polyfill des deklarativen Shadow-DOMs geladen und die Schattenstammvorlage in einen echten Schattenstamm umwandelt wird.

Dieses Problem lässt sich in CSS lösen, indem Sie die FOUC-Stilregel ändern. In Browsern, die deklarative Shadow-DOM unterstützen, wird das <template shadowrootmode>-Element sofort in einen Schattenstamm umgewandelt, sodass kein <template>-Element in der DOM-Struktur vorhanden ist. Browser, die deklarative Shadow-DOM nicht unterstützen, behalten das <template>-Element bei, mit dem wir FOUC verhindern können:

<style>
  x-foo:not(:defined) > template[shadowrootmode] ~ *  {
    display: none;
  }
</style>

Anstatt das noch nicht definierte benutzerdefinierte Element auszublenden, blendet die überarbeitete „FOUC“-Regel ihre untergeordneten Elemente aus, wenn sie einem <template shadowrootmode>-Element folgen. Sobald das benutzerdefinierte Element definiert wurde, stimmt die Regel nicht mehr überein. In Browsern, die deklaratives Shadow-DOM unterstützen, wird die Regel ignoriert, da das untergeordnete <template shadowrootmode> beim HTML-Parsing entfernt wird.

Funktionserkennung und Browserunterstützung

Deklaratives Shadow-DOM ist seit Chrome 90 und Edge 91 verfügbar. Dabei wurde jedoch ein älteres nicht standardmäßiges Attribut namens shadowroot anstelle des standardisierten shadowrootmode-Attributs verwendet. Das neuere shadowrootmode-Attribut und das Streamingverhalten sind in Chrome 111 und Edge 111 verfügbar.

Das deklarative Shadow-DOM ist eine neue Webplattform-API, die noch nicht in allen Browsern unterstützt wird. Die Browserunterstützung lässt sich ermitteln, indem geprüft wird, ob im Prototyp von HTMLTemplateElement eine shadowRootMode-Eigenschaft vorhanden ist:

function supportsDeclarativeShadowDOM() {
  return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}

Polyfill

Das Erstellen eines vereinfachten Polyfill für das deklarative Shadow-DOM ist relativ einfach, da ein Polyfill die Zeitsemantik oder die reinen Parsereigenschaften, die eine Browserimplementierung betrifft, nicht perfekt replizieren muss. Zum Polyfill deklarativer Shadow-DOM können wir das DOM scannen, um alle <template shadowrootmode>-Elemente zu finden, und sie dann in angehängte Shadow Roots-Elemente für das übergeordnete Element konvertieren. Dieser Vorgang kann abgeschlossen werden, sobald das Dokument fertig ist, oder durch spezifischere Ereignisse wie Lebenszyklen von benutzerdefinierten Elementen ausgelöst.

(function attachShadowRoots(root) {
  root.querySelectorAll("template[shadowrootmode]").forEach(template => {
    const mode = template.getAttribute("shadowrootmode");
    const shadowRoot = template.parentNode.attachShadow({ mode });
    shadowRoot.appendChild(template.content);
    template.remove();
    attachShadowRoots(shadowRoot);
  });
})(document);

Weitere Informationen