Das Ziel von Web Components liegt darin, wiederverwendbare Komponenten für das Web zu bauen. Damit diese sauber wiederverwendbar sind, liegt das Augenmerk primär auf der Eigenschaft der Kapselung.

Web Components besteht dabei primär aus drei Web-APIs, um diese Ziele zu erreichen:

Doch bevor wir uns mit einem Beispiel in die Praxis der einzelnen Bestandteile wagen, sollten wir ein gemeinsames Verständnis des Begriffs „Web-Komponente“ erlangen.

Komponenten im Web

Eigentlich kommt keine Webanwendung ohne HTML aus, egal ob serverseitig generiert oder im Client per JavaScript erzeugt. Unser Browser ist darauf spezialisiert, HTML zu parsen und die dort definierten Elemente grafisch darzustellen. So entsteht aus einem <input type="text"/> ein Eingabefeld oder das <video>-Element spielt ein Video ab und bietet uns zudem Eingabemöglichkeiten zum Pausieren oder Spulen an.

HTML bringt dabei nur eine begrenzte Anzahl von Elementen mit. Unsere Aufgabe besteht nun darin, aus diesen Basis-Komponenten eigene, höherwertige Komponenten zu erstellen.

Für diesen Artikel wollen wir dem Benutzer unserer Anwendung Feedback anzeigen. Dieses Feedback besteht aus einem Titel, Text und einem Typ. Sicherlich gibt es mehrere mögliche Lösungen, so eine Komponente in HTML abzubilden. Listing 1 zeigt eine dieser Lösungen, welche die Basis für diesen Artikel bildet.

<div class="alert alert-warn">
  <h4>Achtung</h4>
  <p>Etwas schlimmes ist passiert!</p>
</div>
<div class="alert alert-success">
  <h4>Gl&uuml;ckwunsch</h4>
  <p>Alles im gr&uuml;nen Bereich!</p>
</div>
Listing 1: Markup unserer Komponente

Zu einer Komponente gehört neben diesem Markup fast immer auch Styling. Da wir im Web unterwegs sind, nutzen wir zwei CSS-Regeln (s. Listing 2), um einen roten oder grünen Rahmen, je nach Typ, um die gesamte Komponente zu ziehen.

<style>
  .alert-warn { border: 1px solid red; }
  .alert-success { border: 1px solid green; }
</style>
Listing 2: Styling für unsere Komponente

Der dritte, optionale Bestandteil einer Komponente im Web besteht aus Verhalten und wird per JavaScript umgesetzt. Wir möchten dem Benutzer die Möglichkeit geben, die Benachrichtigung mit dem Klick auf einen Button zu schließen. Dies können wir zum Beispiel mit dem Code aus Listing 3 umsetzen. Wir fügen hierzu per JavaScript einen Button hinzu und sorgen dafür, dass bei einem Klick auf diesen die gesamte Komponente per CSS-Styling versteckt wird.

<script>
  document.querySelectorAll('.alert').forEach(node => {
    const button = document.createElement('button');
    button.innerHTML = '&times;';
    button.addEventListener('click', () => {
      node.style.display = 'none';
    });

    node.insertBefore(button, node.firstChild);
  });
</script>
Listing 3: JavaScript-Verhalten unserer Komponente

Der Button wird per JavaScript hinzugefügt und ist nicht bereits Teil des Markups, da dieser ohne JavaScript keine Funktionalität besitzt und dem Benutzer deswegen nicht hilft. Wir realisieren hier somit das Prinzip von Progressive Enhancement. Sollte das JavaScript, aus welchem Grund auch immer, nicht ausgeführt werden, kann der Benutzer die Benachrichtigung nicht schließen. Die Kernfunktionalität, das Anzeigen des Titels und Textes, wird allerdings nicht beeinträchtigt.

Bereits jetzt wird klar, dass jemand Fremdes, sollte er unsere Komponente wiederverwenden wollen, vieles beachten muss. Zum einen muss er sich an das von uns vorgegebene Markup halten. Zumindest die Klassen alert und alert-* sind verpflichtend, da ansonsten weder das Styling noch das Verhalten funktioniert. Zudem muss er daran denken, CSS und JavaScript auch an der passenden Stelle mit einzubinden.

Im Folgenden wollen wir uns anschauen, wie eine identische Komponente mit Web Components umgesetzt werden kann.

Custom Elements

Custom Elements ermöglichen es uns, anstatt des semantisch wenig aussagekräftigen div-Elements mit einer Klasse alert ein eigenes HTML-Element zu verwenden (s. Listing 4). Da dieses Element für den Browser noch unbekannt ist, rendert er dieses erstmal relativ neutral. Das Element my-alert wird somit ähnlich wie ein span behandelt und auch dargestellt.

<my-alert type="warn">
  <h4>Achtung</h4>
  <p>Etwas schlimmes ist passiert!</p>
</my-alert>
<my-alert type="success">
  <h4>Gl&uuml;ckwunsch</h4>
  <p>Alles im gr&uuml;nen Bereich!</p>
</my-alert>
Listing 4: Custom Element für unsere Komponente

Um dem Browser nun mitzuteilen, wie er mit dem unbekannten Element umgehen soll, müssen wir es ihm bekannt machen. Hierzu wird per JavaScript die CustomElementRegistry genutzt (s. Listing 5). Das erste Argument entspricht dabei dem Namen, unter dem das Custom Element anschließend im HTML referenziert wird. Es ist zu beachten, dass stets mindestens ein - im Namen enthalten sein muss. Als zweites Argument wird eine JavaScript-Klasse übergeben.

customElements.define('my-alert', MyAlert);
Listing 5: Registrierung unseres Custom Element

Immer wenn der Browser nun ein vorher definiertes Custom Element findet, erzeugt er eine Instanz der übergebenen Klasse. Zusätzlich gibt es einen definierten Lebenszyklus, der sich darin äußert, dass der Browser bei bestimmten Events definierte Methoden auf der vorher erzeugten Instanz aufruft.

Die Methode connectedCallback wird immer aufgerufen, wenn das Element an einen Knoten im DOM angehangen wird, disconnectedCallback wenn es entfernt wird. adoptedCallback ist für das Verschieben des Elements vorgesehen und attributeChangedCallback wird immer aufgerufen, wenn sich eines der Attribute des Elements ändert.

In unserem Beispiel können wir den connectedCallback nutzen, um den Button zu erzeugen, dessen Logik zu implementieren und ihn anschließend in den DOM einzuhängen (s. Listing 6). Da unsere Komponente das generische HTMLElement erweitert und auch ansonsten keine besondere Funktionalität eines bestehenden HTML-Elements wiederverwendet werden soll, handelt es sich um ein sogenanntes Autonomous Custom Element.

class MyAlert extends HTMLElement {
  connectedCallback() {
    const button = document.createElement('button');
    button.innerHTML = '&times;';
    button.addEventListener('click', () => {
      this.style.display = 'none';
    });

    this.appendChild(button);
    this.classList.add(this.getAttribute('type'));
  }
}
Listing 6: JavaScript-Klasse unserer Komponente

Möchten wir ein Custom Element schreiben, das sich wie eine Liste verhält, benötigen wir ein Customized Built-in-Element. Um ein solches zu verwenden, müssen drei Stellen in Listing 6 geändert werden.

Zuerst muss unsere eigene Klasse nicht mehr von HTMLElement, sondern vom passenden spezifischen Element, zum Beispiel HTMLUListElement, erben. Zudem muss auch beim Registrieren des Custom Element mit angegeben werden, welches Element von der Komponente erweitert wird (s. Listing 7).

customElements.define('my-alert', MyAlert, { extends: 'ul' });
Listing 7: Definition eines Customized Built-in-Elements

Die dritte Änderung entsteht bei der Verwendung einer so definierten Komponente. Anstatt den definierten Namen als Element-Namen zu verwenden, nutzen wir das Attribut is (s. Listing 8).

<ul is="my-alert">
  ...
</ul>
Listing 8: Verwendung eines Customized Built-in-Elements

Shadow DOM

Der zweite Bestandteil von Web Components ist der Shadow DOM. Mit diesem ist es möglich, an ein beliebiges Element einen parallelen und versteckten DOM-Baum anzufügen. Dabei entsteht eine hohe Kapselung zwischen diesem Shadow DOM und dem eigentlichen DOM. Listing 9 zeigt die Verwendung des Shadow DOM für unsere Beispielkomponente.

class MyAlert extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'}); // 4
  }

  connectedCallback() {
    const style = document.createElement('style');
    style.textContent = `
      .warn { border: 1px solid red; display: block; }
      .success { border: 1px solid green; display: block; }`;

    const button = document.createElement('button');
    button.innerHTML = '&times;';
    button.addEventListener('click', () => {
      this.style.display = 'none';
    });

    const wrapper = document.createElement('div');
    wrapper.classList.add(this.getAttribute('type'));

    wrapper.appendChild(style); // 22
    wrapper.appendChild(button);
    this.childNodes.forEach(node => {
      wrapper.appendChild(node.cloneNode(true));
    });

    this.shadowRoot.appendChild(wrapper);
  }
}
Listing 9: Verwendung des Shadow DOM in unserer Komponente

Sobald ein Shadow DOM an ein Element angehängt wird, werden alle Kinder des Elementes nicht mehr angezeigt. Deshalb müssen wir in unserer Komponente nun nicht mehr nur den Button hinzufügen, sondern auch die eigentlichen Kinder in den Shadow DOM kopieren.

Elemente innerhalb des so entstandenen Shadow DOMs sind von JavaScript aus nun nur noch explizit erreichbar oder zu finden. Versuchen wir zum Beispiel, den Button mit document.querySelectorAll('button') zu selektieren, erhalten wir eine leere Liste. Es ist allerdings weiterhin möglich, den Button zu erreichen, indem wir direkt auf den Shadow Root des Elements zugreifen (z. B. durch document.querySelector('my-alert').shadowRoot.querySelector('button')).

Ein direkt sichtbares Ergebnis dieser Zugriffsregel ist, dass Elemente im Shadow DOM auch nicht mehr von im DOM definierten CSS-Regeln beeinflusst werden. Entfernen wir beispielsweise Zeile 22 aus Listing 9 werden keine Rahmen mehr angezeigt.

Andersherum ermöglicht die Kapselung es, innerhalb des Shadow DOM ein style-Element mit CSS hinzuzufügen, ohne dass diese Regeln Elemente außerhalb des Shadow DOM betreffen. Somit würde ein zusätzliches <div class="warn"> im HTML auch weiterhin ohne Rahmen angezeigt.

Neben dem in Zeile 4 aus Listing 9 verwendeten Mode open gibt es auch noch eine weitere Variante: closed. Geben wir diese beim Erzeugen des Shadow DOM an, wird der JavaScript-seitige Zugriff auf den Shadow DOM noch weiter erschwert.

Ändern wir die Komponente aus Listing 9 auf die in Listing 10 gezeigte Variante, funktioniert auch der direkte Zugriff auf den Button nicht mehr, da document.querySelector('my-alert').shadowRoot bereits null zurückliefert. Das explizite Merken des Shadow DOM in einer eigenen Variablen ist hier notwendig, da der closed-Mode den Zugriff auch innerhalb der eigenen Komponente verhindert.

class MyAlert extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({mode: 'closed'});
  }

  connectedCallback() {
    const style = document.createElement('style');
    style.textContent = `
      .warn { border: 1px solid red; display: block; }
      .success { border: 1px solid green; display: block; }`;

    const button = document.createElement('button');
    button.innerHTML = '&times;';
    button.addEventListener('click', () => {
      this.style.display = 'none';
    });

    const wrapper = document.createElement('div');
    wrapper.classList.add(this.getAttribute('type'));

    wrapper.appendChild(style);
    wrapper.appendChild(button);
    this.childNodes.forEach(node => {
      wrapper.appendChild(node.cloneNode(true));
    });
    this.shadow.appendChild(wrapper);
  }
}
Listing 10: Shadow DOM mit Mode closed

HTML Templates

Das dritte und letzte Web-API, das zu Web Components gehört, sind HTML Templates. Diese bestehen aus den beiden HTML-Elementen template und slot. Das template-Element existiert bereits seit Längerem und wird dazu verwendet, einen HTML-Schnipsel auszugeben, ohne dass der Browser diesen interpretiert. Listing 11 definiert ein Template für unsere Komponente. Dieses Template kann nun innerhalb unseres Custom Element verwendet werden (s. Listing 12).

<template id="alert">
  <style>
    .warn { border: 1px solid red; display: block; }
    .success { border: 1px solid green; display: block; }
  </style>
  <h1>Achtung</h1>
  <p>Achtung, Achtung!</p>
  <hr />
  <p>Ihnen wird nichts passieren. Versprochen!</p>
</template>
Listing 11: HTML-Template
class MyAlert extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
  }

  connectedCallback() {
    const button = document.createElement('button');
    button.innerHTML = '&times;';
    button.addEventListener('click', () => {
      this.style.display = 'none';
    });

    const template = document.getElementById('alert').content;

    const wrapper = document.createElement('div');
    wrapper.classList.add(this.getAttribute('type'));
    wrapper.appendChild(button);
    wrapper.appendChild(template.cloneNode(true));

    this.shadowRoot.appendChild(wrapper);
  }
}
Listing 12: Verwendung des HTML-Templates aus unserem Custom Element

Der eigentliche Inhalt des Elementes my-alert wird somit durch den des Templates ersetzt, und anstelle einer h4 für den Titel wird nun das h1 genutzt. Zudem wird der Inhalt des style-Elements nur innerhalb unserer Komponente ausgewertet und weitere h1-Elemente auf der Seite sind von der roten Schrift nicht betroffen.

Der zweite Bestandteil von HTML Templates ist das slot-Element. Mit diesem wurde ein rudimentärer Template-Mechanismus für den Shadow DOM implementiert.

Die Änderung unserer Komponente auf das Template von Listing 11 führt nun dazu, dass alle Benachrichtigungen auf der Seite denselben Titel und Text enthalten. Es soll jedoch jede Benachrichtigung einen individuellen Titel und Text erhalten können. Hierzu erweitern wir das Template um zwei slot-Elemente (s. Listing 13).

<template id="alert">
  <style>
    .warn { border: 1px solid red; display: block; }
    .success { border: 1px solid green; display: block; }
  </style>
  <slot name="title"><h1>Achtung</h1></slot>
  <slot name="text"><p>Achtung, Achtung!</p></slot>
  <hr />
  <p>Ihnen wird nichts passieren. Versprochen!</p>
</template>
Listing 13: Erweiterung des HTML-Templates um Slots

Definieren wir nun unsere Benachrichtigungen auf der Seite wie in Listing 14, dann ersetzt der Browser beim Anzeigen der Seite die beiden slot-Elemente durch die Werte innerhalb des Custom Element.

<my-alert type="warn">
  <h4 slot="title">Gef&auml;hrlich</h4>
  <p slot="text">Etwas schlimmes ist passiert!</p>
</my-alert>

<my-alert type="success">
  <h1 slot="title">Gl&uuml;ckwunsch</h1>
  <p slot="text">Alles im gr&uuml;nen Bereich!</p>
</my-alert>
Listing 14: Verwendung von Slots aus dem Custom Element

Fazit

In diesem Artikel haben wir gemeinsam die Welt der Web Components anhand einer beispielhaften Komponente erkundet. Web Components bestehen dabei aus den drei Spezifikationen Custom Elements, Shadow DOM und HTML Templates.

Kombinieren wir diese Spezifikationen, ist es möglich, eigene HTML-Elemente zu erzeugen, die zudem ihre Implementierungsdetails vor ihrer Umwelt verstecken, also wegkapseln. Hierdurch ist es möglich, Komponenten zu erstellen, die anschließend auch in anderen Anwendungen wiederverwendet werden können.

Für generische Komponenten gibt es bereits mehrere Anbieter, bei denen wir fertige Komponenten finden und in unsere Anwendung integrieren können. Zu diesen Anbietern gehören unter anderem:

Bei den meisten Features, die von Browsern umgesetzt werden müssen, dauert es ein wenig, bis diese flächendeckend zur Verfügung stehen und somit benutzbar sind.

Betrachten wir doch einmal die Statistiken auf „Can I Use“ zu Custom Elements, Shadow DOM und HTML Templates. Es zeigt sich, dass alle drei Features in den meisten modernen Browsern bereits angekommen sind. Lediglich im Edge von Microsoft fehlen die Features noch. Allerdings gibt es für beides Polyfills (s. Custom Element Polyfill und Shadow DOM Polyfill).