Erstelle Deine eigenen HTML Elemente mit Web Components

Zwischen den Projekten nutze ich als Freelancer meine Zeit, um mich weiter zu bilden. Da ich meine JavaScript Skills in den letzten Jahren ein wenig habe schleifen lassen, soll es heute um eine aus meiner Sicht großartige Erweiterung gehen: Die Web Components.

Web Components wurden bereits 2012 als Standard veröffentlich und wurden seitdem nur zögerlich von den Standard Browsern implementiert. Inzwischen sind diese Komponenten aber in den meisten Browsern verfügbar, so dass man damit ruhigen Gewissens eigene HTML Elemente implementieren kann. Lediglich Safari führt Web Components als Technology Preview auf.

Das Grundprinzip von Web Components

Im Grunde genommen geht es um wiederverwendbaren, gekapselten Code. Wie wir alle wissen, war das in der Vergangenheit mit JavaScript nicht immer ganz einfach. Ich möchte mit diesem Artikel zeigen, wie man HTML Quellcode so gering wie möglich halten kann und Funktionalität in benutzerdefinierte Elemente kapselt. Grundsätzlich funktioniert das so:

customElements.define('mmn-fileupload', class extends HTMLElement {
    constructor() {
        super();

        // fancy javascript
    }
});

Einfach, oder? Mit diesem recht einfach Code kann man dann ein <mmn-fileupload> HTML Element benutzen. Die Funktionalität dieses Elements bestimmt ihr einfach.

Das ausführliche Beispiel

Einer meiner älteren Artikel, welcher ein Beispiel für Drag and Drop Uploads beschreibt, eignet sich hervorrang, um aufzuzeigen wie benutzerdefinierte Elemente funktionieren können. Zukünftig soll sich der HTML Text auf ein Minimum reduzieren und lediglich ein <mmn-upload-area> Element aufweisen, welches die gesamte Funktionalität des Drag and Drop Uploads kapselt.

Das Upload Element

Zunächst benötigen wir das Haupt Element. Zukünftig soll es lediglich ein <mmn-upload-area> Element geben, welches den kompletten Upload bereich inkl. Ablagefläche für hochzuladende Dateien, Bereich für Fehler- und Erfolgsmeldungen als auch einen Bereich für Thumbnails der hochgeladenen Bilder beinhaltet.

export class UploadArea extends HTMLElement
{
    constructor()
    {
        super();
    }

    // JavaScript Funktionalität für das Element
}

Das ist die Basis für unser <mmn-upload-area> HTML Element. Ich habe mich dazu entschieden das Element als Klasse zu schreiben. Somit ist es möglich die Klasse einfach in ein HTML Dokument zu importieren.

<mmn-upload-area></mmn-upload-area>
<script type="module">
    import { UploadArea } from "./assets/js/element/UploadArea.js";
    customElements.define('mmn-upload-area', UploadArea);
</script>

Innerhalb des HTML Dokuments importieren wir dann einfach die UploadArea JavaScript Klasse und definieren das Custom Element, so dass der Browser das HTML Element <mmn-upload-area> auch kennt. Würde das Element nicht definiert werden, würde der Browser es als Inline Element interpretieren. Bei der Namensgebung gibt es die Beschränkung, dass es einen Bindestrich im Namen des Elements geben muss. So kann der Browser Custom Elemente von den regulären HTML Elementen unterscheiden.

Eigenschaften

Welche Eigenschaften soll unser Upload Element eigentlich besitzen? Wirft man einen Blick in die urpsrüngliche Umsetzung, kristalisieren sich ein paar Eigenschaften heraus, die wir unbedingt in unserem Custom Element berücksichtigen sollten.

  • Maximale Dateigröße
    Wie groß darf eine hochzuladende Datei eigentlich sein? Unser Element sollte dies bei einem Upload unbedingt berücksichtigen.
  • Maximale Anzahl der hochzuladenden Dateien
    Da wir ein Drag and Drop Element programmieren, können auch mehrere Dateien gleichzeitig im Upload Bereich abglegt werden. Hier müssen wir definieren, wieviele Dateien gleichzeitig abgelegt werden dürfen.
  • Liste der validen Mime Types
    Welche Dateien dürfen überhaupt angegeben werden? Wir benötigen also auch eine Liste von validen Mime Types, die wir vor dem eigentlichen Upload überprüfen werden. Stimmt eine Datei nicht mit einem der Mime Types aus der Liste überein, brechen wir den Upload ab.
  • Liste der validen Dateien
    Ein Container, in dem wir die validen Dateien speichern können.
export class UploadArea extends HTMLElement
{
    #maxFileSize = 1048576; // 1mb
    #maxNumberOfFiles = 20;
    #validFiles = [];
    #validMimeTypes = [];

    get maxFileSize()
    {
        return this.#maxFileSize;
    }

    set maxFileSize(maxFileSize)
    {
        this.#maxFileSize = parseInt(maxFileSize);
    }

    get maxNumberOfFiles()
    {
        return this.#maxNumberOfFiles;
    }

    set maxNumberOfFiles(maxNumberOfFiles)
    {
        this.#maxNumberOfFiles = parseInt(maxNumberOfFiles);
    }

    get validFiles()
    {
        return this.#validFiles;
    }

    set validFiles(file)
    {
        this.#validFiles.push(file);
    }

    get validMimeTypes()
    {
        return this.#validMimeTypes;
    }

    set validMimeTypes(validMimeType)
    {
        this.#validMimeTypes.push(validMimeType);
    }
}

Die Klasse UploadArea wird um die oben genannten Eigenschaften erweitert. Dazu haben wir die entsprechenden Getter und Setter Methoden implementiert, welche es uns zu einem späteren Zeitpunkt erlauben die initialen Werte der Eigenschaften zu überschreiben bzw. zu erweitern.

Templates

Bis zu diesem Zeitpunkt haben wir noch keinerlei HTML definiert, welches erscheinen soll, wenn wir das <mmn-upload-area> Element verwenden. Zu diesem Zweck definieren wir ein HTML Template innerhalb eines <template> Elements, welches immer dann benutzt wird, wenn unser Upload Element in einem HTML Dokument verwendet wird.

<template id="mmn-upload-area">
    <style>
        <!-- Style Eigenschaften für dieses Template -->
    </style>

    <!-- messages -->
    <div class="mmn-message-area">
        <slot name="mmn-message"></slot>
    </div>

    <!-- dropzone -->
    <div class="mmn-dropzone">
        Lege Deine Dateien einfach per Drag and Drop in diesem Feld ab
    </div>

    <!-- progress bar -->
    <div class="mmn-progress">
        <div class="mmn-progress-bar" role="progressbar" style="width:0">&nbsp;</div>
        <span>0%</span>
    </div>

    <!-- thumbnails -->
    <div class="mmn-finish mmn-hidden">
        <h3>Bearbeitete Dateien</h3>
        <slot name="mmn-thumbnail"></slot>
    </div>
</template>

Ich für meinen Teil mag Kapselung und bin ein großer Freund des Prinzips Seperation of Concerns. Aus diesem Grund habe ich diesen HTML Text in eine eigene HTML Datei ausgelagert. Somit ist sie sauber getrennt von der JavaScript Logik. Der Code wird am Ende sehr viel einfacher zu lesen sein.

Die <slot> Elemente sind im Umfang der Web Components enthalten und stellen eine Art Platzhalter dar. Wir werden später in diesem Artikel darauf zurück kommen.

Jetzt müssen wir das Template nur noch in unserer Klasse laden.

export class UploadArea extends HTMLElement
{
    ...
    constructor()
    {
        super();

        fetch('./assets/js/template/upload-area.html')
            .then(response => response.text())
            .then(html => {
                const parser = new DOMParser();
                const template = parser
                    .parseFromString(html, 'text/html')
                    .querySelector('template')
                    .content;

                const shadowRoot = this.attachShadow({
                    mode: 'open'
                });

                shadowRoot.appendChild(template.cloneNode(true));
            })
            .catch(error => console.log(error));
    }
}

Mittels Fetch API lade ich hier den Inhalt unseres zuvor erstellten Templates. Das schöne an eigenen HTML Elementen ist, dass sie das Shadow DOM benutzen. Ein gekapseltes DOM, welches nur für unser HTML Element gilt. Auch hier werden die Vorzüge der Kapselung perfekt ausgenutzt.

Methoden

Welche Funktionalität soll unser HTML Element eigentlich haben?

  • Funktionalität für das dragleave Event
    Das Element muss auf das dragleave Event reagieren, indem eine CSS Klasse entfernt wird, sobald das Event getriggert wird.
  • Funktionalität für das dragover Event
    Das Element muss auf das dragover Event reagieren, indem eine CSS Klasse hinzugefügt wird, sobald das Event getriggert wird.
  • Funktionalität für das drop Event
    Das Element muss die abgelegten Dateien validieren und der Upload der validen Dateien muss gestartet werden. Erfolgreich hochgeladene Dateien müssen als Thumbnail dargestellt werden.

Wir erweitern den Konstruktor des Elements.

export class UploadArea extends HTMLElement
{
    ...
    constructor()
    {
        super();

        fetch('./assets/js/template/upload-area.html')
            .then(response => response.text())
            .then(html => {
                const parser = new DOMParser();
                const template = parser
                    .parseFromString(html, 'text/html')
                    .querySelector('template')
                    .content;

                const shadowRoot = this.attachShadow({
                    mode: 'open'
                });

                shadowRoot.appendChild(template.cloneNode(true));

                const dropzone = shadowRoot.querySelector('div.mmn-dropzone');
                dropzone.addEventListener('dragleave', this.onDragLeave, false);
                dropzone.addEventListener('dragover', this.onDragover, false);

                this.onDrop = this.onDrop.bind(this);
                dropzone.addEventListener('drop', this.onDrop, false);
            })
            .catch(error => console.log(error));
    }

    onDragleave(event)
    {
        event.preventDefault();
        this.classList.remove('mmn-drop');
    }

    onDragover(event)
    {
        event.preventDefault();
        this.classList.add('mmn-drop');
    }

    onDrop(event)
    {
        event.preventDefault();
        event.currentTarget.classList.remove('mmn-drop');
        this.upload(event.dataTransfer.files);
    }

    upload(files)
    {
        ...
    }
}

Um die genannte Funktionalität in das Element zu integrieren, müssen wir für jedes genannte Event einen Event Listener registrieren. Dieser Listener triggert dann die entsprechenden Methoden unseres HTML Elements.

Die Funktionalität für die dragleave und dragover Events ist simpel. Wir entfernen eine CSS Klasse oder entfernen diese wieder. Das Schlüsselwort this bezieht sich in diesem Kontext einfach auf das HTML Element, welches das Event ausgelöst hat.

Für das drop Event benötigen wir die Sichtbarkeit der JavaScript Klasse, so dass wir mit this die Upload Methode aufrufen können, sobald Dateien in der Dropzone abgelegt wurden. Aus diesem Grund ist das this Schlüsselwort mit dem Kontext der Klasse und nicht mit dem triggernden Element belegt.

Automatisch aufgerufene Funktionalität

Jedes benutzerdefiniert Element verfügt über so genannte Lifecycle Callbacks, die während der Lebenszeit des Elements automatisch aufgerufen werden.

  • connectedCallback
    Sobald das benutzerdefinierte Element in das DOM des HTML Dokuments hinzugefügt wird, wird diese Methode aufgerufen. Die Spezifikation schlägt vor, dass – soweit möglich – diese Methode zum Setup des Elements benutzt werden soll. Im Konstruktor nicht notwendige Funktionalität sollte in diese Methode verlegt werden.
  • disconnectedCallback
    Der entsprechende Gegenspieler zu connectedCallback. Wenn das Element aus dem DOM des HTML Dokuments entfernt wird, wird diese Methode aufgerufen.
  • adoptedCallback
    Wird jedes mal aufgerufen, wenn das Element in ein neues Dokument verschoben wird.
  • attributeChangedCallback
    Wird jedes mal aufgerufen, wenn sich Attribute des Elements ändern, entfernt oder hinzugefügt werden.

Ich benutze die connectedCallback Methode, um valide Mime Types zu definieren, die auf jede hochzuladende Datei angewendet werden sollen.

export class UploadArea extends HTMLElement
{
    ...
    connectedCallback()
    {
        this.validMimeTypes = 'image/jpg';
        this.validMimeTypes = 'image/jpeg';
        this.validMimeTypes = 'image/png';
    }
}

Erlaubt sind die Bildformate PNG und JPG/JPEG. Die zuvor definierte Setter-Methode sorgt dafür, dass jeder aufgeführte Mime-Type zur Collection der validen Mime Types hinzugefügt wird.

Validierung und Upload

Sobald das drop Event getriggert wird, soll das HTML Element prüfen, ob es sich bei den abgelegten Dateien um die zuvor angegebenen Mime-Types handelt. Gleichzeitig soll die maximale Anzahl der abgelegten Dateien und die maximale Größe in Bytes einer Datei geprüft werden.

export class UploadArea extends HTMLElement
{
    ...
    upload(files)
    {
        try {
            this.validate(files);
        } catch (error) {
            console.log(error);
        }
    }

    validate(files)
    {
        if (files.length > 20) {
            throw new Error('Es dürfen maximal ' + this.maxNumberOfFiles + ' Dateien abgelegt werden. Du versuchst gerade ' + files.length + ' Bilder hochzuladen.');
        }

        for (let i = 0; i < files.length; i++) {
            if (this.validMimeTypes.indexOf(files.item(i).type) == -1) {
                throw new Error('Die Datei ' + files.item(i).name + ' ist kein Bild. Du darfst hier nur Bilder (jpg und png) hochladen.');
            }
            
            if (files.item(i).size > this.maxFileSize) {
                throw new Error('Die Datei ' + files.item(i).name + ' ist zu groß. Ein Bild darf maximal ' + new Intl.NumberFormat('de-DE').format(this.maxFileSize) + ' Bytes groß sein.');
            }
        }
    }
}

Die Upload Methode validiert zunächst alle angegebenen Dateien. Entspricht eine Datei nicht den Vorgaben, wird ein Fehler geworfen und der Upload Prozess wird abgebrochen.

Den Upload Prozess selbst erledige ich mit dem guten alten XMLHttpRequest Klasse. Die Fetch API besitzt nach wie vor keine auf die Schnelle implementierbare Funktionalität, um den Fortschritt eines Uploads technisch darstellen zu können. Aus diesem Grund benutzen wir einfach das, was funktioniert.

export class UploadArea extends HTMLElement
{
    ...
    upload(files)
    {
        try {
            this.validate(files);

            const formdata = new FormData();
            for (let i = 0; i < files.length; i++) {
                this.validFiles = files.item(i);
                formdata.append('mmnfiles[]', files.item(i), files.item(i).name);
            }
            
            const progressbar = this.shadowRoot.querySelector('.mmn-progress-bar');
            const xhr = new XMLHttpRequest();

            xhr.upload.addEventListener('progress', (event) => {
                const completion = Math.ceil(event.loaded / event.total * 100);
                progressbar.style.width = completion + '%';
                progressbar.nextElementSibling.textContent = completion + '%';
            });

            xhr.addEventListener('load', (event) => {
                if (xhr.status === 200) {
                    let data = JSON.parse(xhr.responseText);
                    if (data.type == 'success') {
                        this.setMessage('success', data.message);

                        // thumbnails setzen
                        for (const [i, file] of Array.from(this.validFiles).entries()) {
                            this.setThumbnail(file);
                        }

                        // valid files array zurücksetzen
                        this.#validFiles = [];
                    }

                    if (data.type == 'error') {
                        // fehlermeldung darstellen
                        this.setData(data.message);
                    }
                }
            });

            xhr.open('POST', './upload.php');
            xhr.send(formdata);
        } catch (error) {
            // fehlermeldung darstellen
            this.setMessage('error', error.message);
        }
    }
    ...
}

Für den Upload-Prozess hat sich zum Vorgänger nicht viel verändert. Wir bilden ein FormData Objekt und fügen alle validaten Dateien hinzu. Das Objekt sorgt dafür, dass die durch das XMLHttpRequest aufgerufene PHP Datei das $_FILES Array benutzen kann.

Das XMLHttpRequest Objekt bekommt einen Event Listener für das progress Event, so dass der Fortschrittsbalken während des Uploads fortlaufend aktualisiert wird. Das load Event erledigt da Handling nach dem Upload für uns und stellt im Erfolgsfall eine Erfolgsmeldung und Thumbnails der hochgeladenen Dateien dar. Fehlermeldungen werden ebenfalls abgefangen und als Fehlermeldung dargestellt.

Die <slot> Platzhalter

Das <slot> Element kann lediglich im Zusammenhang mit dem <template> Element benutzt werden. Das Template nutzen wir für das Shadow DOM unseres Element. Es ist also ausschließlich für dieses Element bekannt und kann überall eingefügt werden. Um das Template mit Inhalten zu füllen, benutzen wir die <slot> Element. Jedes <slot> Element besitzt ein name Attribut, mit dem es innerhalb des Shadow DOM angesprochen werden kann.

In unserem Beispiel sind <slot> Elemente das Mittel der Wahl, weil wir so sehr einfach Markup wiederverwenden können. Kapselung, Wiederverwendbarkeit … was will das Entwickler-Herz mehr?

Also kapseln wir sowohl unsere Erfolgsmeldungen als auch unsere Thumbnails in eigenen Elementen.

export class Message extends HTMLElement
{
    #class = [];
    #text;

    get class()
    {
        return this.#class;
    }

    set class(classname)
    {
        if (this.#class.includes(classname) === false) {
            this.#class.push(classname);
        }
    }

    get text()
    {
        return this.#text;
    }

    set text(text)
    {
        this.#text = text;
    }

    constructor()
    {
        super();

        fetch('./assets/js/template/message.html')
            .then(response => response.text())
            .then(html => {
                const parser = new DOMParser();
                const template = parser
                    .parseFromString(html, 'text/html')
                    .querySelector('template')
                    .content;

                const shadowRoot = this.attachShadow({
                    mode: 'open'
                });

                shadowRoot.appendChild(template.cloneNode(true));
                shadowRoot.querySelector('strong').textContent = this.text;
                shadowRoot.querySelector('.mmn-message').classList.add(this.class.join(', '));
            })
            .catch(error => console.log(error));
    }
}

Wie auch schon beim <mmn-upload-area> Element werde ich auch hier ein Template benutzen, welches in einer gesonderten HTML Datei abgelegt ist.

<template id="mmn-message-template">
    <style>
        <!-- css definitionen -->
    </style>
    <div class="mmn-message">
        <strong></strong>
    </div>
</template>

Für die zu erstellenden Thumbnails gibt es ebenfalls ein eigens erstelltes Element, welches sowohl die JavaScript Logik als auch das HTML Template beinhaltet.

export class Thumbnail extends HTMLElement
{
    #file;

    get file()
    {
        return this.#file;
    }

    set file(file)
    {
        this.#file = file;
    }

    constructor()
    {
        super();

        fetch('./assets/js/template/thumbnail.html')
            .then(response => response.text())
            .then(html => {
                const parser = new DOMParser();
                const template = parser
                    .parseFromString(html, 'text/html')
                    .querySelector('template')
                    .content;

                const shadowRoot = this.attachShadow({
                    mode: 'open'
                });

                if (! this.file instanceof File) {
                    throw Error('Es wurde kein Bild angegeben.');
                }

                shadowRoot.appendChild(template.cloneNode(true));
                
                const reader = new FileReader();
                const image = shadowRoot.querySelector('img');

                console.log(image);

                reader.onload = ((element) => { 
                    return (event) => { 
                        element.src = event.target.result; 
                    }; 
                })(image);

                reader.readAsDataURL(this.file);
                image.nextElementSibling.textContent = this.file.name;
            })
            .catch(error => console.log(error));
    }
}

Das Thumbnail Element nimmt eine Datei und bildet aus dem Inhalt der Datei eine Data URL für das src Attribute eines <img> Elements. Das Markup für dieses Element sieht wie folgt aus.

<template id="mmn-thumbnail">
    <style>
        <!-- css definitionen -->
    </style>
    <div class="mmn-thumbnail">
        <img src="" alt="">
        <span></span>
    </div>
</template>

Um diese beiden neuen Elemente benutzen zu können, müssen wir diese den Custom Elements des HTML Dokuments noch bekannt geben.

<mmn-upload-area></mmn-upload-area>
<script type="module">
    import { Message } from "./assets/js/element/Message.js";
    import { Thumbnail } from "./assets/js/element/Thumbnail.js";
    import { UploadArea } from "./assets/js/element/UploadArea.js";

    customElements.define('mmn-message', Message);
    customElements.define('mmn-thumbnail', Thumbnail);
    customElements.define('mmn-upload-area', UploadArea);
</script>

Jetzt fehlen nur noch zwei Methoden innerhalb der UploadArea Klasse, um Messages und Thumbnails im Shadow DOM unseres <mmn-upload-area> Elements setzen zu können.

export class Message extends HTMLElement
{
    ...
    setMessage(type, text)
    {
        const elements = this.getElementsByTagName('mmn-message');
        Array.from(elements).forEach(element => element.remove());

        const message = document.createElement('mmn-message');
        message.setAttribute('slot', 'mmn-message');
        message.text = text;
        message.class = 'mmn-' + type;
        
        this.appendChild(message);
    }

    setThumbnail(file)
    {
        const thumbnail = document.createElement('mmn-thumbnail');
        thumbnail.file = file;
        thumbnail.setAttribute('slot', 'mmn-thumbnail');

        const thumbnailContainer = this.shadowRoot.querySelector('div.mmn-finish');
        if (thumbnailContainer.classList.contains('mmn-hidden')) {
            thumbnailContainer.classList.remove('mmn-hidden');
        }

        this.appendChild(thumbnail);
    }
    ...
}

In jeder der beiden Methoden wird ein benutzerdefiniertes Element erzeugt. Dies ist nur möglich, weil wir die neuen Elemente <mmn-message> und <mmn-thumbnail> zuvor im HTML Dokument definiert haben.

Woher genau weiß unser <mmn-upload-area> Element nun, wo welches Element eingefügt werden soll? Das <slot> Element besitzt ein name Attribut. Dieses sprechen wir in beiden Methoden an, indem für die erstellten Elemente jeweils ein slot Attribut erzeugt wird, welches den Inhalt des name Attributs des <slot> Elements besitzt. Somit müssen wir das erzeugte Kindelement nur noch in das Shadow DOM des <mmn-upload-area> Elements einfügen. Anhand der Attributierung werden die Elemente in die richtigen Slots eingehängt.

Mein persönliches Fazit zu Web Components

Ganz ehrlich? Bisher kannte ich diese Art von Kapselung lediglich von React Anwendungen. Das JavaScript das rein nativ in einer nicht ähnlich attraktiven Art und Weise kann, hat mich überrascht. Ich mag diese feine Art der Kapselung einfach sehr gern. Entgegen früherer Herangehensweisen ist dies ein ganz wesentlicher Schritt nach vorn.

Ich werde Web Components und dessen Möglichkeiten zukünftig einfach sehr viel öfter anwenden, weil es von allen gängigen Browsern Stand heute unterstützt wird. Ich verringere die Abhängigkeiten zu JavaScript Frameworks, die mich oft zwingen Kompromisse á la „Wenn Du das nutzen möchtest, musst Du aber jenes ebenfalls installieren“ einzugehen. Zudem habe ich in der letzten Zeit eher Erweiterungen von Komponenten für meine Kunden programmiert, weil diese nicht den erforderten Umfang in Gänze erfüllten. Mit Web Components kann ich HTML Elemente genau so definieren, wie sie für mich funktionieren sollen, so dass sie den Ansprüchen des Kunden genügen. Zumal das Konzept von Klassen weitere Vorteile, wie z.B. die Vererbung und Erweiterung mit sich bringen.

Was denkt ihr über Web Components? Nutzt ihr diese Möglichkeit bereits in Euren Projekten? Hinterlasst gern einen Kommentar.

Das Praxibeispiel

Natürlich gibt es auch ein funktionierendes Online Beispiel: http://s232888554.online.de/several/test/web-components/

Kommentar verfassen

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.