Ich hatte schon vor einiger Zeit mit React experimentiert und finde das Konzept davon auch gut. Jetzt bin ich aber auf den Beitrag Web Components will replace your frontend framework gestoßen und dadurch mit Web Components in Berührung gekommen.

Kurz gesagt, handelt es sich bei Web Components um selbstdefinierte HTML-Tags, deren Inhalt und Verhalten man mit Javascript beeinflussen kann. Somit bietet schon HTML die entsprechenden Möglichkeiten, wie man sie bei React mit den Components bekommt.

Grundsätzlicher Aufbau

Die Definition von Web Components erfolgt mittels Javascript über die CustomElementRegistry, welche über window.customElements erreichbar ist. Die Methode define muss mit dem Namen des neuen HTML-Tags und einer Javascript-Klasse für die Funktionalität aufgerufen werden. Der Name muss mit einem Buchstaben beginnen und mindestens einen Bindestrich enthalten. Abgesehen von diesen Beschränkungen sind alle Spielereien wie <math-α> und <emoji-🙂> möglich.

Die neue Klasse muss von der Klasse HTMLElement abgeleitet werden und im Konstruktor muss als erster Aufruf super() stehen. Danach kann der neue HTML-Tag ganz normal verwendet werden1:

  1. Die Beispiele hier im Dokument sind so komplakt, dass man ein neues Fenster mit der URL data:text/html, (mit abschließendem Komma) öffnen und den Code in der Konsole (Strg+K) ausführen und damit experimentieren kann. Mit F5 kann man jederzeit alles zurücksetzen und über die Historie die gewünschten, alten Befehle aufrufen. 

class MyComponent extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'})
            .innerHTML = 'This is a <strong>new</strong> component';
    }
}

window.customElements.define('my-component', MyComponent);

document.body.innerHTML = '<my-component></my-component>';
document.body.appendChild(document.createElement('my-component'));

Elemente, die im HTML bereits für der Registrierung enthalten sind, werden mit dem Aufruf von define in eine Web Component umgewandelt. Davor werden sie wie unbekannte HTML-Tags wie ein <span> verarbeitet.

An den Konstruktur werden einige Anforderungen gestellt: Zu beachten ist, dass keine neuen Elemente erstellt werden, was die Veränderungen von innerHTML und dem Style sehr einschränkt. Diese Änderungen müssen zu einem späteren Zeitpunkt (siehe Elemente der Klasse) erfolgen, ansonsten führt dies zu merkwürdigen und nichtssagenden Fehlermeldungen meist beim Erzeugen des Elements. Auf den ShadowDOM wiederum darf zugegriffen werden und es ist auch sinnvoll, ihn gleich im Konstruktor zu initialisieren, wenn man damit arbeiten will. Außerdem wird empfohlen, die Eventlistener im Konstruktor zu registrieren.

Shadow DOM oder innerHTML

Ziel der Web Components ist es, eine Kapsel zu bieten, sodass es mit der Außenwelt zu keinen Konflikten bzgl. IDs oder CSS kommen kann. Hierzu muss man für den Inhalt des neuen Elements einen Shadow DOM einrichten, der unabhängig vom Rest des HTMLs operiert. Auf diese Weise gibt es keine Konflikte, wenn mehrere Instanzen des neuen Elements innerhalb immer die selben IDs verwenden. CSS von außerhalb wird nicht innerhalb angewandt und umgekehrt.

Den Shadow DOM erstellt man mit dem Aufruf this.attachShadow({mode: 'open'}), kann dann mit this.shadowRoot darauf zugreifen und damit wie mit document arbeiten.

Man muss nicht mit einem Shadow DOM arbeiten, sondern kann auch innerHTML verwenden. Wenn man bestehende HTML-Tags erweitert, kann man auch nicht für alle Elemente einen Shadow DOM nutzen und this.innerHTML muss verwendet werden. Damit fehlt allerdings die Kapselung bzgl. IDs und CSS.

CSS im Shadow DOM

Für den Shadow DOM gibt es einige spezielle CSS-Selectoren. Nützlich ist davon :host, um innerhalb des Elements das Element selbst anzusprechen. Dies ist insbesondere notwendig, wenn man bestehende HTML-Tags erweitert.

window.customElements.define('my-component', class extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'})
            .innerHTML = `
<style>
:host { background: blue; }
p { background: orange; }
</style>
<p>inner</p>`;
    }
});

document.body.innerHTML = `
<style>
p { background: green; }
my-component { background: red; }
</style>
<p>outer</p>
<my-component></my-component>
`;

CSS-Variablen für anpassbare Gestaltung

Um eine gewisse Flexibilität bei der Gestaltung zu erreichen, dann man CSS-Variablen nutzen:

window.customElements.define('my-component', class extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'})
            .innerHTML = `
<style>
:host { --bg: blue; } /* Default value, if not set outside */
p { background: var(--bg); }
</style>
<p>inner</p>`;
    }
});

document.body.innerHTML = `
<style>
p { background: green; }
my-component { --bg: red; }
</style>
<my-component></my-component>
`;

Elemente im Shadow-DOM mit ::part addressieren

TODO: ::part() - CSS: Cascading Style Sheets

Externe Style-Sheets in Web Components

Da der Shadow DOM eine Barriere für das CSS darstellt, kann man Style-Sheets nicht in <head> laden, sondern muss diese innerhalb des Shadow DOMs einbinden. Dabei kann man aber bei der Initialisierung in <head> die Datei vorladen lassen.

const polyfillCss = document.createElement('link');
const link = document.createElement('link');
link.rel = "preload";
polyfillCss.rel = link.as = "stylesheet";
polyfillCss.type = link.type = "text/css";
polyfillCss.href = link.href = 'style.css';

document.head.append(link);

window.customElements.define('my-component', class extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'})
            .appendChild(polyfillCss.cloneNode(true));
    }
});

Warum preload und nicht prefetch der richtige Wert ist, ist hier erklärt.

Event-Target über Shadow-DOM-Grenze hinweg

TODO: Event.composedPath()

Erweitern von bestehenden HTML-Tags

Ein wenig anders sind die Aufrufe, wenn man bestehende HTML-Tags erweitert. Hierbei muss man eine andere Elternklasse verwenden, bei define() als dritten Parameter ein Objekt mit extends: '…' mit dem Ursprungselement angeben und die Elemente mit dem Attribut is erstellen, das angibt, welches das neue Element ist.

class MyButton extends HTMLButtonElement {
    connectedCallback() {
        this.innerHTML = '<strong>inner</strong>';
    }
}
customElements.define('my-button', MyButton, { extends: 'button' });

document.body.innerHTML = '<button is=my-button>';

class MyP extends HTMLParagraphElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'})
            .innerHTML = `
<style>
:host { border: 2px dashed; }
</style>
inner`;
    }
}
customElements.define('my-p', MyP, { extends: 'p' });

document.body.appendChild(document.createElement('p', { is: 'my-p' }));

Anmerkung: <button> ist auch eines der Elemente, das keinen Shadow DOM haben darf, weshalb erst im connectedCallback auf innerHTML zugegriffen werden kann.

Elemente der Klasse

Eigene Methoden und Eigenschaften

Die Methoden und Attribute der Klasse sind über das DOM-Element verfügbar. Dies ermöglicht es, eigene Methoden für komplexe Aktionen (z. B. updateStateBySelection()) und (ggf. nur lesbare) Attribute mit get name() und set name(val) zu definieren.

Private Variablen

Javascript wurde nicht mit dem Gedanken der objektorientierten Programmierung entwurfen und die OOP-Elemente wie Klassen wurden erst im Nachhinein hinzugefügt. Daher fühlen diese Konstrukte sich auch teilweise eigenartig an und unterstützen (noch) nicht alle Elemente von OOP.

So haben zum Beispiel die Klassen keine privaten Attribute, weil hierfür Javascript schon von Anfang an Kapseln (Closures) biete. Die Erstellung ist auch simpel – außer für Programmierer, die das Closure-Konzept nicht kennen.

const MyComponent = (function() {
    let priv = 0;

    return class MyComponent extends HTMLElement {
        constructor() {
            super();
            this.attachShadow({mode: 'open'})
                .innerHTML = `<p>This is number <strong>${++priv}</strong>.</p>`;
        }
    }
})();

window.customElements.define('my-component', MyComponent);

document.body.innerHTML = '<my-component></my-component><my-component>';
document.body.appendChild(document.createElement('my-component'));

<template> oder HTML-String

Den Inhalt des Shadow DOMs (oder direkt des Elements) kann man entweder mit innerHTML = '…' bestimmen oder man nutzt das Template-Element im HTML. Mit beiden Wegen lässt sich auch mit <style> entsprechend CSS für die Teile der Web Component definieren.

Für die Kapselung ergeben sich damit zwei Wege: * Innerhalb einer Javascript-Bibliothek kann das notwendige HTML und CSS für die Komponente in einem String hinterlegt werden, sodass für den Einsatz (und Updates) nur eine Datei angefasst werden muss. Diesen String kann man auch mit entsprechenden Werkzeugen aus einzelnen Dateien beim Zusammenstellen der Bibliothek generieren, um eine saubere Trennung zwischen HTML/CSS/Javascript im Quelltext zu haben. * Die andere Möglichkeit ist den Inhalt in einem Template im HTML zu definieren. Somit kann man die Teile während der Erstellung beeinflussen und Texte und CSS passend zur Sprache und anderen Einstellungen des Nutzers erstellen. Der Code in der Javascript-Bibliothek bleibt statisch und alle variablen Teile sind im HTML und können so generiert werden.

Slots

TODO

Weiter Informationsquellen