Encapsulating Style and Structure with Shadow DOM

Avatar of Caleb Williams
Caleb Williams am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Dies ist Teil vier einer fünfteiligen Serie über die Web Components-Spezifikationen. In Teil eins haben wir die Spezifikationen und ihre Funktionen aus der Vogelperspektive betrachtet. In Teil zwei haben wir damit begonnen, ein benutzerdefiniertes Modal-Dialogfeld zu erstellen und die HTML-Vorlage dafür erstellt, die sich in Teil drei zu unserem eigenen benutzerdefinierten HTML-Element entwickeln würde.

Artikelserie

  1. Eine Einführung in Web Components
  2. Erstellung wiederverwendbarer HTML-Vorlagen
  3. Creating a Custom Element from Scratch
  4. Encapsulating Style and Structure with Shadow DOM (Dieser Beitrag)
  5. Fortgeschrittene Werkzeuge für Web Components

Wenn Sie diese Artikel noch nicht gelesen haben, ist es ratsam, dies jetzt zu tun, bevor Sie mit diesem Artikel fortfahren, da dieser auf der dort geleisteten Arbeit aufbaut.

Als wir uns das letzte Mal mit unserem Dialogkomponenten befassten, hatte es eine bestimmte Form, Struktur und Verhaltensweisen. Es war jedoch stark vom äußeren DOM abhängig und erforderte, dass die Konsumenten unseres Elements seine allgemeine Form und Struktur verstehen mussten, ganz zu schweigen vom Erstellen all ihrer eigenen Stile (die schließlich die globalen Stile des Dokuments modifizieren würden). Und da unser Dialog auf den Inhalten eines Template-Elements mit der ID "one-dialog" beruhte, konnte jedes Dokument nur eine Instanz unseres Modals enthalten.

Die aktuellen Einschränkungen unserer Dialogkomponente sind nicht unbedingt schlecht. Konsumenten, die die inneren Abläufe des Dialogs genau kennen, können den Dialog einfach nutzen, indem sie ihr eigenes <template>-Element erstellen und die von ihnen gewünschten Inhalte und Stile definieren (sogar unter Berücksichtigung globaler Stile, die anderswo definiert sind). Möglicherweise möchten wir jedoch spezifischere Design- und Strukturvorgaben für unser Element bereitstellen, um Best Practices zu berücksichtigen. Daher werden wir in diesem Artikel den Shadow DOM in unser Element integrieren.

Was ist der Shadow DOM?

In unserem Einführungsartikel sagten wir, der Shadow DOM sei "fähig, CSS und JavaScript zu isolieren, fast wie ein <iframe>". Wie ein <iframe> dringen Selektoren und Stile innerhalb eines Shadow DOM-Knotens nicht nach außen aus dem Shadow Root und Stile von außerhalb des Shadow Roots dringen nicht nach innen. Es gibt ein paar Ausnahmen, die vom übergeordneten Dokument geerbt werden, wie Schriftfamilie und Dokument-Schriftgrößen (z. B. rem), die intern überschrieben werden können.

Im Gegensatz zu einem <iframe> existieren jedoch alle Shadow Roots weiterhin im selben Dokument, sodass aller Code innerhalb eines gegebenen Kontexts geschrieben werden kann, ohne sich um Konflikte mit anderen Stilen oder Selektoren kümmern zu müssen.

Hinzufügen des Shadow DOM zu unserem Dialog

Um einen Shadow Root (den Basis-Knoten/Dokumentenfragment des Shadow-Trees) hinzuzufügen, müssen wir die Methode attachShadow unseres Elements aufrufen.

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
}

Durch den Aufruf von attachShadow mit mode: 'open' sagen wir unserem Element, dass es eine Referenz auf den Shadow Root in der Eigenschaft element.shadowRoot speichern soll. attachShadow gibt immer eine Referenz auf den Shadow Root zurück, aber hier müssen wir nichts damit machen.

Wenn wir die Methode mit mode: 'closed' aufgerufen hätten, wäre keine Referenz auf dem Element gespeichert worden und wir müssten unsere eigenen Mittel zur Speicherung und Abfrage mit einem WeakMap oder Object erstellen, wobei der Knoten selbst als Schlüssel und der Shadow Root als Wert gesetzt wird.

const shadowRoots = new WeakMap();

class ClosedRoot extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'closed' });
    shadowRoots.set(this, shadowRoot);
  }

  connectedCallback() {
    const shadowRoot = shadowRoots.get(this);
    shadowRoot.innerHTML = `<h1>Hello from a closed shadow root!</h1>`;
  }
}

Wir könnten auch eine Referenz auf den Shadow Root auf unserem Element selbst speichern, indem wir ein Symbol oder einen anderen Schlüssel verwenden, um zu versuchen, den Shadow Root privat zu machen.

Im Allgemeinen existiert der geschlossene Modus für Shadow Roots für native Elemente, die den Shadow DOM in ihrer Implementierung verwenden (wie <audio> oder <video>). Weiterhin haben wir für das Unit-Testing unserer Elemente möglicherweise keinen Zugriff auf das shadowRoots-Objekt, was es uns unmöglich macht, Änderungen innerhalb unseres Elements anzusprechen, je nachdem, wie unsere Bibliothek aufgebaut ist.

Es mag einige legitime Anwendungsfälle für benutzerdefinierte geschlossene Shadow Roots geben, aber sie sind selten und weit verbreitet, daher werden wir uns für unseren Dialog auf den offenen Shadow Root beschränken.

Nach der Implementierung des neuen offenen Shadow Roots stellen Sie möglicherweise fest, dass unser Element nun vollständig kaputt ist, wenn wir versuchen, es auszuführen.

See the Pen
Dialog example using template with shadow root
by Caleb Williams (@calebdwilliams)
auf CodePen.

Das liegt daran, dass alle bisherigen Inhalte zum traditionellen DOM (was wir als Light DOM bezeichnen werden) hinzugefügt und dort manipuliert wurden. Da unser Element nun einen angehängten Shadow DOM hat, gibt es keinen Auslass für das Rendern des Light DOM. Beginnen wir damit, diese Probleme zu beheben, indem wir unsere Inhalte in den Shadow DOM verschieben.

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
  
  connectedCallback() {
    const { shadowRoot } = this;
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    shadowRoot.appendChild(node);
    
    shadowRoot.querySelector('button').addEventListener('click', this.close);
    shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
    this.open = this.open;
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('button').removeEventListener('click', this.close);
    this.shadowRoot.querySelector('.overlay').removeEventListener('click', this.close);
  }
  
  set open(isOpen) {
    const { shadowRoot } = this;
    shadowRoot.querySelector('.wrapper').classList.toggle('open', isOpen);
    shadowRoot.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    if (isOpen) {
      this._wasFocused = document.activeElement;
      this.setAttribute('open', '');
      document.addEventListener('keydown', this._watchEscape);
      this.focus();
      shadowRoot.querySelector('button').focus();
    } else {
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape);
    }
  }
  
  close() {
    this.open = false;
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
        this.close();   
    }
  }
}

customElements.define('one-dialog', OneDialog);

Die wesentlichen Änderungen an unserem Dialog sind bisher relativ gering, aber sie haben große Auswirkungen. Zunächst sind alle unsere Selektoren (einschließlich unserer Stildefinitionen) intern begrenzt. Beispielsweise hat unsere Dialog-Vorlage nur einen Knopf intern, sodass unsere CSS nur button { ... } anspricht und diese Stile nicht in den Light DOM durchsickern.

Wir sind jedoch immer noch auf die Vorlage angewiesen, die sich außerhalb unseres Elements befindet. Ändern wir das, indem wir das Markup aus unserer Vorlage entfernen und es in das innerHTML unseres Shadow Roots einfügen.

See the Pen
Dialog example using only shadow root
by Caleb Williams (@calebdwilliams)
auf CodePen.

Inhalte aus dem Light DOM einschließen

Die Shadow DOM-Spezifikation enthält eine Möglichkeit, Inhalte von außerhalb des Shadow Roots innerhalb unseres benutzerdefinierten Elements rendern zu lassen. Für diejenigen unter Ihnen, die sich an AngularJS erinnern, ist dies ein ähnliches Konzept wie ng-transclude oder die Verwendung von props.children in React. Mit Web Components geschieht dies mithilfe des <slot>-Elements.

Ein einfaches Beispiel würde so aussehen

<div>
  <span>world <!-- this would be inserted into the slot element below --></span>
  <#shadow-root><!-- pseudo code -->
    <p>Hello <slot></slot></p>
  </#shadow-root>
</div>

Ein gegebener Shadow Root kann beliebig viele Slot-Elemente enthalten, die mit einem name-Attribut unterschieden werden können. Der erste Slot innerhalb des Shadow Roots ohne Namen ist der Standard-Slot, und alle nicht anderweitig zugewiesenen Inhalte fließen in diesen Knoten ein. Unser Dialog benötigt wirklich zwei Slots: eine Überschrift und einige Inhalte (die wir zum Standard machen).

See the Pen
Dialog example using shadow root and slots
by Caleb Williams (@calebdwilliams)
auf CodePen.

Ändern Sie nun den HTML-Teil unseres Dialogs und sehen Sie das Ergebnis. Alle Inhalte innerhalb des Light DOM werden in den Slot eingefügt, dem sie zugewiesen sind. Geslotterte Inhalte verbleiben im Light DOM, obwohl sie so gerendert werden, als wären sie innerhalb des Shadow DOM. Das bedeutet, dass diese Elemente weiterhin vollständig von einem Konsumenten gestylt werden können, der das Aussehen und Gefühl seiner Inhalte kontrollieren möchte.

Ein Autor eines Shadow Roots *kann* Inhalte innerhalb des Light DOM bis zu einem gewissen Grad mit der CSS-Pseudo-Klasse ::slotted() stylen; jedoch ist der DOM-Baum innerhalb geslotteter Elemente kollabiert, sodass nur einfache Selektoren funktionieren. Mit anderen Worten, wir könnten ein <strong>-Element innerhalb eines <p>-Elements im abgeflachten DOM-Baum in unserem vorherigen Beispiel nicht stylen.

Das Beste aus beiden Welten

Unser Dialog ist jetzt gut aufgestellt: Er hat gekapselte, semantische Markup-, Stil- und Verhaltensweisen; einige Konsumenten unseres Dialogs möchten jedoch möglicherweise immer noch ihre eigene Vorlage definieren. Glücklicherweise können wir durch die Kombination von zwei bereits gelernten Techniken Autoren erlauben, optional eine externe Vorlage zu definieren.

Dazu erlauben wir jeder Instanz unserer Komponente, auf eine optionale Vorlagen-ID zu verweisen. Zunächst müssen wir einen Getter und Setter für die template-Eigenschaft unserer Komponente definieren.

get template() {
  return this.getAttribute('template');
}

set template(template) {
  if (template) {
    this.setAttribute('template', template);
  } else {
    this.removeAttribute('template');
  }
  this.render();
}

Hier machen wir im Wesentlichen dasselbe wie bei unserer open-Eigenschaft, indem wir sie direkt mit ihrem entsprechenden Attribut verknüpfen. Aber am Ende führen wir eine neue Methode für unsere Komponente ein: render. Wir werden unsere Render-Methode verwenden, um den Inhalt unseres Shadow DOMs einzufügen und diese Funktionalität aus connectedCallback zu entfernen. Stattdessen rufen wir render auf, wenn unser Element verbunden wird.

connectedCallback() {
  this.render();
}

render() {
  const { shadowRoot, template } = this;
  const templateNode = document.getElementById(template);
  shadowRoot.innerHTML = '';
  if (templateNode) {
    const content = document.importNode(templateNode.content, true);
    shadowRoot.appendChild(content);
  } else {
    shadowRoot.innerHTML = `<!-- template text -->`;
  }
  shadowRoot.querySelector('button').addEventListener('click', this.close);
  shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
  this.open = this.open;
}

Unser Dialog hat nun einige sehr grundlegende Standardstile, bietet aber den Konsumenten auch die Möglichkeit, für jede Instanz eine neue Vorlage zu definieren. Wenn wir wollten, könnten wir sogar attributeChangedCallback verwenden, um diese Komponente basierend auf der Vorlage, auf die sie gerade verweist, aktualisieren zu lassen.

static get observedAttributes() { return ['open', 'template']; }

attributeChangedCallback(attrName, oldValue, newValue) {
  if (newValue !== oldValue) {
    switch (attrName) {
      /** Boolean attributes */
      case 'open':
        this[attrName] = this.hasAttribute(attrName);
        break;
      /** Value attributes */
      case 'template':
        this[attrName] = newValue;
        break;
    }
  }
}

See the Pen
Dialog example using shadow root, slots and template
by Caleb Williams (@calebdwilliams)
auf CodePen.

In der obigen Demo ändert die Änderung des Vorlagenattributs auf unserem <one-dialog>-Element, welches Design beim Rendern des Elements verwendet wird.

Strategien zum Stylen des Shadow DOM

Derzeit ist die einzige zuverlässige Methode, einen Shadow DOM-Knoten zu stylen, das Hinzufügen eines <style>-Elements zum inneren HTML des Shadow Roots. Dies funktioniert in fast allen Fällen gut, da Browser Stylesheets über diese Komponenten nach Möglichkeit deduplizieren. Dies fügt zwar einen gewissen Speicher-Overhead hinzu, aber im Allgemeinen nicht genug, um ihn zu bemerken.

Innerhalb dieser Style-Tags können wir CSS Custom Properties verwenden, um eine API für das Stylen unserer Komponenten bereitzustellen. Custom Properties können die Shadow-Grenze durchdringen und Inhalte innerhalb eines Shadow-Knotens beeinflussen.

Sie könnten fragen: "Können wir ein <link>-Element innerhalb eines Shadow Roots verwenden?". Und tatsächlich können wir das. Das Problem entsteht, wenn Sie versuchen, diese Komponente über mehrere Anwendungen hinweg wiederzuverwenden, da die CSS-Datei möglicherweise nicht an allen Apps an einem konsistenten Ort gespeichert ist. Wenn wir uns jedoch über den Speicherort des Stylesheets des Elements sicher sind, ist die Verwendung von <link> eine Option. Das Gleiche gilt für die Einbeziehung einer @import-Regel in einem Style-Tag.

Es ist auch erwähnenswert, dass nicht alle Komponenten die hier verwendete Art von Styling benötigen. Durch die Verwendung der CSS-Selektoren :host und :host-context können wir einfach primitivere Komponenten als Block-Level-Elemente definieren und Konsumenten die Möglichkeit geben, Klassen bereitzustellen, um Dinge wie Hintergrundfarben, Schrifteinstellungen und mehr zu stylen.

Unser Dialog ist dagegen ziemlich komplex. Etwas wie eine Listbox (bestehend aus einem Label und einem Kontrollkästchen) ist es nicht und kann lediglich als Oberfläche für die Komposition nativer Elemente dienen. Das ist eine ebenso gültige Styling-Strategie wie explizitere Stile (z. B. für Design-Systeme, bei denen alle Kontrollkästchen auf eine bestimmte Weise aussehen könnten). Es hängt weitgehend von Ihrem Anwendungsfall ab.

CSS-Benutzereigenschaften

Einer der Vorteile der Verwendung von CSS Custom Properties – auch CSS-Variablen genannt – ist, dass sie durch den Shadow DOM sickern. Dies ist absichtlich so gestaltet, um Komponentenautoren eine Oberfläche für die Thematisierung und das Stylen ihrer Komponenten von außen zu bieten. Es ist jedoch wichtig zu beachten, dass, da CSS kaskadiert, Änderungen an Custom Properties, die innerhalb eines Shadow Roots vorgenommen werden, nicht nach oben zurücksickern.

See the Pen
CSS custom properties and shadow DOM
by Caleb Williams (@calebdwilliams)
auf CodePen.

Kommentieren Sie im obigen Demo die Variablen im CSS-Panel aus oder entfernen Sie sie, und sehen Sie, wie sich dies auf den gerenderten Inhalt auswirkt. Danach können Sie sich die Stile im innerHTML des Shadow DOM ansehen. Sie werden sehen, wie der Shadow DOM seine eigene Eigenschaft definieren kann, die den Light DOM nicht beeinflusst.

Konstruierbare Stylesheets

Zum Zeitpunkt der Erstellung dieses Artikels gibt es eine vorgeschlagene Web-Funktion, die ein modulareres Styling von Shadow DOM- und Light DOM-Elementen mit konstruierbaren Stylesheets ermöglicht, die bereits in Chrome 73 implementiert wurde und positives Feedback von Mozilla erhalten hat.

Diese Funktion würde es Autoren ermöglichen, Stylesheets in ihren JavaScript-Dateien zu definieren, ähnlich wie sie normales CSS schreiben würden, und diese Stile über mehrere Knoten hinweg zu teilen. Eine einzelne Stylesheet könnte also an mehrere Shadow Roots und potenziell auch an das Dokument angehängt werden.

const everythingTomato = new CSSStyleSheet();
everythingTomato.replace('* { color: tomato; }');

document.adoptedStyleSheets = [everythingTomato];

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [everythingTomato];
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `<h1>CSS colors are fun</h1>`;
  }
}

Im obigen Beispiel würde das everythingTomato-Stylesheet gleichzeitig auf den Shadow Root und den Body des Dokuments angewendet werden. Diese Funktion wäre sehr nützlich für Teams, die Design-Systeme und Komponenten erstellen, die für die gemeinsame Nutzung über mehrere Anwendungen und Frameworks hinweg gedacht sind.

Im nächsten Demo können wir ein sehr grundlegendes Beispiel dafür sehen, wie dies genutzt werden kann und welche Macht konstruierbare Stylesheets bieten.

See the Pen
Construct style sheets demo
by Caleb Williams (@calebdwilliams)
auf CodePen.

In dieser Demo erstellen wir zwei Stylesheets und hängen sie an das Dokument und an das benutzerdefinierte Element an. Nach drei Sekunden entfernen wir ein Stylesheet aus unserem Shadow Root. In diesen drei Sekunden teilen sich jedoch das Dokument und der Shadow DOM dasselbe Stylesheet. Mit dem Polyfill, der in dieser Demo enthalten ist, sind tatsächlich zwei Style-Elemente vorhanden, aber Chrome führt dies nativ aus.

Diese Demo enthält auch ein Formular, das zeigt, wie die Regeln eines Stylesheets je nach Bedarf einfach und effektiv asynchron geändert werden können. Diese Ergänzung zur Web-Plattform kann ein mächtiger Verbündeter für diejenigen sein, die Design-Systeme erstellen, die sich über mehrere Frameworks erstrecken, oder für Website-Autoren, die Designs für ihre Websites bereitstellen möchten.

Es gibt auch einen Vorschlag für CSS Modules, der schließlich mit der Funktion adoptedStyleSheets verwendet werden könnte. Wenn dieser Vorschlag in seiner aktuellen Form implementiert wird, würde er das Importieren von CSS als Modul ermöglichen, ähnlich wie ECMAScript-Module.

import styles './styles.css';

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [styles];
  }
}

Teil und Thema

Eine weitere Funktion, die sich für das Styling von Web Components in Entwicklung befindet, sind die Pseudo-Selektoren ::part() und ::theme(). Die ::part()-Spezifikation ermöglicht es Autoren, Teile ihrer benutzerdefinierten Elemente zu definieren, die eine Oberfläche für das Styling bieten.

class SomeOtherComponent extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>h1 { color: rebeccapurple; }</style>
      <h1>Web components are <span part="description">AWESOME</span></h1>
    `;
  }
}
    
customElements.define('other-component', SomeOtherComponent);

In unserem globalen CSS könnten wir jedes Element ansprechen, das einen Teil mit dem Namen description hat, indem wir den CSS-Selektor ::part() aufrufen.

other-component::part(description) {
  color: tomato;
}

Im obigen Beispiel wäre die primäre Nachricht des <h1>-Tags in einer anderen Farbe als der Beschreibungsteil, was benutzerdefinierten Elementautoren die Möglichkeit gibt, Styling-APIs für ihre Komponenten freizulegen und die Kontrolle über die Teile zu behalten, über die sie die Kontrolle behalten möchten.

Der Unterschied zwischen ::part() und ::theme() besteht darin, dass ::part() gezielt ausgewählt werden muss, während ::theme() auf jeder Ebene verschachtelt sein kann. Das Folgende hätte den gleichen Effekt wie das obige CSS, würde aber auch für jedes andere Element funktionieren, das part="description" im gesamten Dokumentbaum enthält.

:root::theme(description) {
  color: tomato;
}

Wie konstruierbare Stylesheets ist ::part() in Chrome 73 implementiert worden.

Zusammenfassung

Unsere Dialogkomponente ist nun mehr oder weniger fertig. Sie enthält ihr eigenes Markup, Stile (ohne externe Abhängigkeiten) und Verhaltensweisen. Diese Komponente kann nun in Projekte integriert werden, die beliebige aktuelle oder zukünftige Frameworks verwenden, da sie auf Browser-Spezifikationen und nicht auf Drittanbieter-APIs basiert.

Einige der Kernkontrollen sind *etwas* umständlich und erfordern zumindest ein mittleres Wissen über die Funktionsweise des DOM. In unserem letzten Artikel werden wir über übergeordnete Tools und die Integration in beliebte Frameworks sprechen.

Artikelserie

  1. Eine Einführung in Web Components
  2. Erstellung wiederverwendbarer HTML-Vorlagen
  3. Creating a Custom Element from Scratch
  4. Encapsulating Style and Structure with Shadow DOM (Dieser Beitrag)
  5. Fortgeschrittene Werkzeuge für Web Components