Mit benutzerdefinierten Elementen arbeiten

Boris Smus
Boris Smus

Einführung

Dem Web fehlt es an Ausdruckskraft. Sehen Sie sich dazu eine „moderne“ Webanwendung wie Gmail an:

Gmail

<div>-Suppe ist nicht modern. Und doch ist das so, wie wir Web-Apps entwickeln. Das ist traurig. Sollten wir nicht mehr von unserer Plattform verlangen?

Sexy Markup. Lass uns das machen

HTML ist ein hervorragendes Tool zum Strukturieren eines Dokuments, aber sein Vokabular ist auf Elemente beschränkt, die vom HTML-Standard definiert werden.

Was wäre, wenn das Markup für Gmail nicht so schrecklich wäre? Was wäre, wenn es so schön wäre:

<hangout-module>
    <hangout-chat from="Paul, Addy">
    <hangout-discussion>
        <hangout-message from="Paul" profile="profile.png"
            profile="118075919496626375791" datetime="2013-07-17T12:02">
        <p>Feelin' this Web Components thing.
        <p>Heard of it?
        </hangout-message>
    </hangout-discussion>
    </hangout-chat>
    <hangout-chat>...</hangout-chat>
</hangout-module>

Erfrischend! Diese App macht auch absolut Sinn. Sie ist sinnvoll, leicht verständlich und vor allem wartungsfreundlich. Ich/Sie in der Zukunft werden genau wissen, was es tut, indem Sie sich nur das deklarative Rückgrat ansehen.

Erste Schritte

Mit benutzerdefinierten Elementen können Webentwickler neue Arten von HTML-Elementen definieren. Die Spezifikation ist eine von mehreren neuen API-Primitiven, die unter dem Dach von Web Components zusammengefasst werden. Sie ist aber wahrscheinlich die wichtigste. Webkomponenten wären ohne die Funktionen, die durch benutzerdefinierte Elemente freigeschaltet werden, nicht möglich:

  1. Neue HTML-/DOM-Elemente definieren
  2. Elemente erstellen, die sich von anderen Elementen erstrecken
  3. Benutzerdefinierte Funktionen logisch in einem einzigen Tag bündeln
  4. API vorhandener DOM-Elemente erweitern

Neue Elemente registrieren

Benutzerdefinierte Elemente werden mit document.registerElement() erstellt:

var XFoo = document.registerElement('x-foo');
document.body.appendChild(new XFoo());

Das erste Argument für document.registerElement() ist der Tag-Name des Elements. Der Name muss einen Bindestrich (-) enthalten. <x-tags>, <my-element> und <my-awesome-app> sind also gültige Namen, <tabs> und <foo_bar> hingegen nicht. Diese Einschränkung ermöglicht es dem Parser, benutzerdefinierte Elemente von regulären Elementen zu unterscheiden, sorgt aber auch für eine Abwärtskompatibilität, wenn HTML neue Tags hinzugefügt werden.

Das zweite Argument ist ein (optionales) Objekt, das die prototype des Elements beschreibt. Hier können Sie Ihren Elementen benutzerdefinierte Funktionen (z.B. öffentliche Eigenschaften und Methoden) hinzufügen. Weitere Informationen dazu später.

Standardmäßig werden benutzerdefinierte Elemente von HTMLElement übernommen. Das vorherige Beispiel entspricht also:

var XFoo = document.registerElement('x-foo', {
    prototype: Object.create(HTMLElement.prototype)
});

Ein Aufruf von document.registerElement('x-foo') informiert den Browser über das neue Element und gibt einen Konstruktor zurück, mit dem Sie Instanzen von <x-foo> erstellen können. Alternativ können Sie die anderen Methoden zur Instanziierung von Elementen verwenden, wenn Sie den Konstruktor nicht verwenden möchten.

Elemente verlängern

Mit benutzerdefinierten Elementen können Sie vorhandene (native) HTML-Elemente sowie andere benutzerdefinierte Elemente erweitern. Wenn Sie ein Element erweitern möchten, müssen Sie registerElement() den Namen und prototype des Elements übergeben, von dem es übernommen werden soll.

Native Elemente erweitern

Angenommen, Sie sind mit „Otto Normalverbraucher“ <button> nicht zufrieden. Sie möchten die Funktionen der Schaltfläche erweitern, damit sie eine „Mega-Schaltfläche“ wird. Wenn Sie das <button>-Element erweitern möchten, erstellen Sie ein neues Element, das die prototype von HTMLButtonElement und extends den Namen des Elements erbt. In diesem Fall gilt für „button“:

var MegaButton = document.registerElement('mega-button', {
    prototype: Object.create(HTMLButtonElement.prototype),
    extends: 'button'
});

Benutzerdefinierte Elemente, die von nativen Elementen abgeleitet sind, werden als benutzerdefinierte Elemente der Typerweiterung bezeichnet. Sie werden von einer speziellen Version von HTMLElement abgeleitet, um auszudrücken, dass „Element X ein Y ist“.

Beispiel:

<button is="mega-button">

Benutzerdefiniertes Element erweitern

Wenn Sie ein <x-foo-extended>-Element erstellen möchten, das das benutzerdefinierte <x-foo>-Element erweitert, erben Sie einfach dessen Prototyp und geben Sie an, von welchem Tag Sie erben:

var XFooProto = Object.create(HTMLElement.prototype);
...

var XFooExtended = document.registerElement('x-foo-extended', {
    prototype: XFooProto,
    extends: 'x-foo'
});

Weitere Informationen zum Erstellen von Elementprototypen finden Sie unten unter JS-Properties und ‑Methoden hinzufügen.

So werden Elemente aktualisiert

Haben Sie sich schon einmal gefragt, warum der HTML-Parser bei nicht standardmäßigen Tags nicht ausrastet? Es ist beispielsweise in Ordnung, wenn wir <randomtag> auf der Seite deklarieren. Gemäß der HTML-Spezifikation:

Tut mir leid, <randomtag>. Sie sind nicht standardmäßig und übernehmen die Einstellungen von HTMLUnknownElement.

Das gilt nicht für benutzerdefinierte Elemente. Elemente mit gültigen Namen für benutzerdefinierte Elemente werden von HTMLElement übernommen. Sie können das überprüfen, indem Sie die Console öffnen: Ctrl + Shift + J (oder Cmd + Opt + J auf einem Mac) und die folgenden Codezeilen einfügen. Sie geben true zurück:

// "tabs" is not a valid custom element name
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype

// "x-tabs" is a valid custom element name
document.createElement('x-tabs').__proto__ == HTMLElement.prototype

Ungelöste Elemente

Da benutzerdefinierte Elemente per Script mit document.registerElement() registriert werden, können sie deklariert oder erstellt werden, bevor ihre Definition vom Browser registriert wird. Sie können beispielsweise <x-tabs> auf der Seite deklarieren, aber document.registerElement('x-tabs') erst viel später aufrufen.

Bevor Elemente auf ihre Definition aktualisiert werden, werden sie als nicht aufgelöste Elemente bezeichnet. Das sind HTML-Elemente mit einem gültigen Namen für benutzerdefinierte Elemente, die aber nicht registriert wurden.

Diese Tabelle kann Ihnen dabei helfen, den Überblick zu behalten:

Name Übernimmt von Beispiele
Ungelöstes Element HTMLElement <x-tabs>, <my-element>
Unbekanntes Element HTMLUnknownElement <tabs>, <foo_bar>

Elemente instanziieren

Die gängigen Methoden zum Erstellen von Elementen gelten auch für benutzerdefinierte Elemente. Wie jedes Standardelement können sie in HTML deklariert oder im DOM mit JavaScript erstellt werden.

Benutzerdefinierte Tags instanziieren

Deklarieren Sie sie:

<x-foo></x-foo>

DOM in JS erstellen:

var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
    alert('Thanks!');
});

Verwenden Sie den new-Operator:

var xFoo = new XFoo();
document.body.appendChild(xFoo);

Typerweiterungselemente instanziieren

Das Instanziieren benutzerdefinierter Elemente vom Typ „Typerweiterung“ ähnelt stark dem Erstellen benutzerdefinierter Tags.

Deklarieren Sie sie:

<!-- <button> "is a" mega button -->
<button is="mega-button">

DOM in JS erstellen:

var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true

Wie Sie sehen, gibt es jetzt eine überladene Version von document.createElement(), die das is=""-Attribut als zweiten Parameter verwendet.

Verwenden Sie den new-Operator:

var megaButton = new MegaButton();
document.body.appendChild(megaButton);

Bisher haben wir gelernt, wie Sie mit document.registerElement() dem Browser ein neues Tag mitteilen. Das hat aber nicht viel zu bedeuten. Fügen wir jetzt Eigenschaften und Methoden hinzu.

JS-Properties und ‑Methoden hinzufügen

Das Besondere an benutzerdefinierten Elementen ist, dass Sie maßgeschneiderte Funktionen mit dem Element kombinieren können, indem Sie Eigenschaften und Methoden in der Elementdefinition definieren. Sie können damit eine öffentliche API für Ihr Element erstellen.

Hier ein vollständiges Beispiel:

var XFooProto = Object.create(HTMLElement.prototype);

// 1. Give x-foo a foo() method.
XFooProto.foo = function() {
    alert('foo() called');
};

// 2. Define a property read-only "bar".
Object.defineProperty(XFooProto, "bar", {value: 5});

// 3. Register x-foo's definition.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. Instantiate an x-foo.
var xfoo = document.createElement('x-foo');

// 5. Add it to the page.
document.body.appendChild(xfoo);

Natürlich gibt es unzählige Möglichkeiten, eine prototype zu konstruieren. Wenn Sie keine Prototypen wie diesen erstellen möchten, hier eine kompaktere Version desselben:

var XFoo = document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    bar: {
      get: function () {
        return 5;
      }
    },
    foo: {
      value: function () {
        alert('foo() called');
      }
    }
  })
});

Das erste Format ermöglicht die Verwendung von ES5 Object.defineProperty. Bei der zweiten Methode ist die Verwendung von get/set zulässig.

Lebenszyklus-Callback-Methoden

Elemente können spezielle Methoden definieren, um interessante Zeiträume ihrer Existenz zu nutzen. Diese Methoden werden folgerichtig als Lebenszyklusereignisse bezeichnet. Jede hat einen bestimmten Namen und Zweck:

Name des Rückrufs Wird aufgerufen, wenn
createdCallback eine Instanz des Elements erstellt wird.
attachedCallback eine Instanz in das Dokument eingefügt wurde
detachedCallback eine Instanz aus dem Dokument entfernt wurde
attributeChangedCallback(attrName, oldVal, newVal) ein Attribut hinzugefügt, entfernt oder aktualisiert wurde

Beispiel:createdCallback() und attachedCallback() für <x-foo> definieren:

var proto = Object.create(HTMLElement.prototype);

proto.createdCallback = function() {...};
proto.attachedCallback = function() {...};

var XFoo = document.registerElement('x-foo', {prototype: proto});

Alle Lebenszyklus-Callbacks sind optional, sollten aber definiert werden, wenn es sinnvoll ist. Angenommen, Ihr Element ist ausreichend komplex und öffnet eine Verbindung zu IndexedDB in createdCallback(). Bevor es aus dem DOM entfernt wird, müssen Sie in detachedCallback() die erforderlichen Bereinigungsarbeiten durchführen. Hinweis:Sie sollten sich nicht darauf verlassen, z. B. wenn der Nutzer den Tab schließt. Betrachten Sie es jedoch als möglichen Optimierungs-Hook.

Ein weiterer Anwendungsfall für Lifecycle-Callbacks ist das Einrichten von Standard-Ereignis-Listenern für das Element:

proto.createdCallback = function() {
  this.addEventListener('click', function(e) {
    alert('Thanks!');
  });
};

Markup hinzufügen

Wir haben <x-foo> erstellt und ihm eine JavaScript-API zugewiesen, aber es ist leer. Sollen wir ihm etwas HTML zum Rendern geben?

Hier sind Lifecycle-Callbacks nützlich. Insbesondere können wir mit createdCallback() einem Element Standard-HTML zuweisen:

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    this.innerHTML = "**I'm an x-foo-with-markup!**";
};

var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});

Wenn Sie dieses Tag instanziieren und in den Entwicklertools prüfen (mit der rechten Maustaste klicken und „Element untersuchen“ auswählen), sollte Folgendes angezeigt werden:

▾<x-foo-with-markup>
  **I'm an x-foo-with-markup!**
</x-foo-with-markup>

Interne Elemente im Shadow-DOM kapseln

Das Shadow DOM ist ein leistungsstarkes Tool zum Kapseln von Inhalten. In Kombination mit benutzerdefinierten Elementen wird es magisch!

Mit Shadow DOM haben benutzerdefinierte Elemente folgende Vorteile:

  1. Eine Möglichkeit, die inneren Abläufe zu verbergen und so die Nutzer vor grausamen Implementierungsdetails zu schützen.
  2. Stilkapselung – kostenlos.

Das Erstellen eines Elements aus dem Shadow-DOM ähnelt dem Erstellen eines Elements, das grundlegendes Markup rendert. Der Unterschied liegt bei createdCallback():

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    // 1. Attach a shadow root on the element.
    var shadow = this.createShadowRoot();

    // 2. Fill it with markup goodness.
    shadow.innerHTML = "**I'm in the element's Shadow DOM!**";
};

var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});

Anstatt das .innerHTML-Attribut des Elements festzulegen, habe ich einen Schatten-Stamm für <x-foo-shadowdom> erstellt und dann mit Markup gefüllt. Wenn Sie in den Entwicklertools die Einstellung „Shadow DOM anzeigen“ aktiviert haben, wird ein maximierbares Symbol #shadow-root angezeigt:

<x-foo-shadowdom>
  #shadow-root
    **I'm in the element's Shadow DOM!**
</x-foo-shadowdom>

Das ist die Schatten-Wurzel.

Elemente aus einer Vorlage erstellen

HTML-Vorlagen sind ein weiteres neues API-Primitive, das gut in die Welt der benutzerdefinierten Elemente passt.

Beispiel:Registrieren eines Elements, das aus einem <template> und einem Shadow DOM erstellt wurde:

<template id="sdtemplate">
  <style>
    p { color: orange; }
  </style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<script>
  var proto = Object.create(HTMLElement.prototype, {
    createdCallback: {
      value: function() {
        var t = document.querySelector('#sdtemplate');
        var clone = document.importNode(t.content, true);
        this.createShadowRoot().appendChild(clone);
      }
    }
  });
  document.registerElement('x-foo-from-template', {prototype: proto});
</script>

<template id="sdtemplate">
  <style>:host p { color: orange; }</style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<div class="demoarea">
  <x-foo-from-template></x-foo-from-template>
</div>

Diese wenigen Codezeilen haben es in sich. Sehen wir uns an, was passiert:

  1. Wir haben ein neues Element in HTML registriert: <x-foo-from-template>
  2. Das DOM des Elements wurde aus einem <template> erstellt.
  3. Die beängstigenden Details des Elements werden mit Shadow DOM ausgeblendet
  4. Shadow-DOM ermöglicht die Kapselung des Elementstils (z. B. wird die gesamte Seite nicht orange, wenn p {color: orange;} verwendet wird).

Super!

Benutzerdefinierte Elemente stylen

Wie bei jedem HTML-Tag können Nutzer deines benutzerdefinierten Tags es mithilfe von Selektoren formatieren:

<style>
  app-panel {
    display: flex;
  }
  [is="x-item"] {
    transition: opacity 400ms ease-in-out;
    opacity: 0.3;
    flex: 1;
    text-align: center;
    border-radius: 50%;
  }
  [is="x-item"]:hover {
    opacity: 1.0;
    background: rgb(255, 0, 255);
    color: white;
  }
  app-panel > [is="x-item"] {
    padding: 5px;
    list-style: none;
    margin: 0 7px;
  }
</style>

<app-panel>
    <li is="x-item">Do</li>
    <li is="x-item">Re</li>
    <li is="x-item">Mi</li>
</app-panel>

Elemente mit Shadow-DOM stylen

Das Kaninchenloch wird noch viel tiefer, wenn Sie Shadow DOM hinzufügen. Benutzerdefinierte Elemente, die Shadow DOM verwenden, profitieren von den Vorteilen dieser Technologie.

Shadow-DOM sorgt für eine Stilkapselung eines Elements. In einem Schatten-Root definierte Stile ragen nicht über den Host hinaus und gehen auch nicht über die Seite hinaus. Bei einem benutzerdefinierten Element ist das Element selbst der Host. Mit den Eigenschaften der Stilkapselung können benutzerdefinierte Elemente auch Standardstile für sich selbst definieren.

Das Styling von Shadow DOM ist ein großes Thema. Wenn Sie mehr darüber erfahren möchten, empfehle ich Ihnen einige meiner anderen Artikel:

Verhinderung von FOUC mit :unresolved

Um FOUC zu minimieren, wird für benutzerdefinierte Elemente eine neue CSS-Pseudoklasse namens :unresolved definiert. Sie können damit auf nicht aufgelöste Elemente abzielen, bis der Browser Ihre createdCallback() aufruft (siehe Lebenszyklusmethoden). In diesem Fall ist das Element nicht mehr als nicht aufgelöst gekennzeichnet. Der Upgradevorgang ist abgeschlossen und das Element wurde in seine Definition umgewandelt.

Beispiel: „x-foo“-Tags einblenden, wenn sie registriert werden:

<style>
  x-foo {
    opacity: 1;
    transition: opacity 300ms;
  }
  x-foo:unresolved {
    opacity: 0;
  }
</style>

Beachten Sie, dass :unresolved nur für nicht aufgelöste Elemente gilt, nicht für Elemente, die von HTMLUnknownElement übernommen werden (siehe So werden Elemente aktualisiert).

<style>
  /* apply a dashed border to all unresolved elements */
  :unresolved {
    border: 1px dashed red;
    display: inline-block;
  }
  /* x-panel's that are unresolved are red */
  x-panel:unresolved {
    color: red;
  }
  /* once the definition of x-panel is registered, it becomes green */
  x-panel {
    color: green;
    display: block;
    padding: 5px;
    display: block;
  }
</style>

<panel>
    I'm black because :unresolved doesn't apply to "panel".
    It's not a valid custom element name.
</panel>

<x-panel>I'm red because I match x-panel:unresolved.</x-panel>

Verlauf und Browserunterstützung

Funktionserkennung

Bei der Feature-Erkennung wird geprüft, ob document.registerElement() vorhanden ist:

function supportsCustomElements() {
    return 'registerElement' in document;
}

if (supportsCustomElements()) {
    // Good to go!
} else {
    // Use other libraries to create components.
}

Unterstützte Browser

document.registerElement() wurde in Chrome 27 und Firefox 23 hinter einem Flag platziert. Die Spezifikation hat sich jedoch seitdem stark weiterentwickelt. Chrome 31 ist die erste Version, die die aktualisierte Spezifikation vollständig unterstützt.

Bis der Browsersupport ausgereift ist, gibt es eine Polyfill-Funktion, die von Polymer von Google und dem X-Tag von Mozilla verwendet wird.

Was ist mit HTMLElementElement passiert?

Wer die Standardisierungsarbeiten verfolgt hat, weiß, dass es einmal <element> gab. Es war das Nonplusultra. Sie können damit neue Elemente deklarativ registrieren:

<element name="my-element">
    ...
</element>

Leider gab es zu viele Zeitprobleme mit dem Upgradeprozess, zu viele Sonderfälle und zu viele Armageddon-ähnliche Szenarien, um alles zu klären. <element> musste auf Eis gelegt werden. Im August 2013 kündigte Dimitri Glazkov auf public-webapps an, dass die Website zumindest vorerst entfernt wird.

Beachten Sie, dass Polymer mit <polymer-element> eine deklarative Form der Elementregistrierung implementiert. Wie? Dabei wird document.registerElement('polymer-element') und die Technik verwendet, die ich unter Elemente aus einer Vorlage erstellen beschrieben habe.

Fazit

Mit benutzerdefinierten Elementen können wir das Vokabular von HTML erweitern, ihm neue Tricks beibringen und durch die Wormholes der Webplattform springen. Kombinieren wir sie mit den anderen neuen Plattformprimitiven wie Shadow DOM und <template>, wird das Bild von Web-Komponenten immer klarer. Markup kann wieder sexy sein!

Wenn Sie mit Webkomponenten beginnen möchten, empfehle ich Ihnen Polymer. Es bietet mehr als genug, um loszulegen.