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

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

Sexy Markup. Lasst uns loslegen

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 unangemessen wäre? Was wäre, wenn es 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>

Wie erfrischend! Diese App ist auch absolut sinnvoll. 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 Webkomponenten verfügbar sind, ist aber vermutlich die wichtigste. Webkomponenten wären ohne die durch benutzerdefinierte Elemente freigeschalteten Funktionen nicht möglich:

  1. Neue HTML/DOM-Elemente definieren
  2. Elemente erstellen, die von anderen Elementen ausgehen
  3. Benutzerdefinierte Funktionen logisch in einem Tag zusammenfassen
  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 von 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 hinzufügen (z.B. öffentliche Eigenschaften und Methoden). Mehr 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 Techniken zum Instanziieren von Elementen anwenden, wenn Sie den Konstruktor nicht verwenden möchten.

Elemente erweitern

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 das prototype von HTMLButtonElement und extends den Namen des Elements erbt. In diesem Fall gibt „button“ Folgendes an:

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

Benutzerdefinierte Elemente, die von nativen Elementen übernommen werden, werden als benutzerdefinierte Elemente der Typerweiterung bezeichnet. Sie erben von einer speziellen Version von HTMLElement, 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 Element <x-foo> erweitert, übernehmen Sie einfach seinen Prototyp und geben Sie an, von welchem Tag Sie es übernehmen:

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 im Abschnitt JS-Eigenschaften 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 gilt Folgendes:

Tut mir leid, <randomtag>. Du bist kein Standard und übernehmen die Einstellungen von HTMLUnknownElement.

Dasselbe gilt für benutzerdefinierte Elemente. Elemente mit gültigen benutzerdefinierten Elementnamen werden von HTMLElement übernommen. Sie können das überprüfen, indem Sie die Konsole ö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

Ungeklärte Elemente

Da benutzerdefinierte Elemente per Skript mit document.registerElement() registriert werden, können sie deklariert oder erstellt werden, bevor ihre Definition durch den Browser registriert wird. Du kannst beispielsweise <x-tabs> auf der Seite deklarieren, aber letztendlich document.registerElement('x-tabs') aufrufen.

Bevor Elemente auf ihre Definition aktualisiert werden, werden sie als nicht aufgelöste Elemente bezeichnet. Dies sind HTML-Elemente, die einen gültigen Namen für das benutzerdefinierte Element haben, aber nicht registriert wurden.

Diese Tabelle kann dir 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 Techniken 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:

<x-foo></x-foo>

DOM in JS erstellen:

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

Verwenden Sie den Operator new:

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

Typerweiterungselemente instanziieren

Die Instanziierung von benutzerdefinierten Elementen im Stil von Typerweiterungen kommt benutzerdefinierten Tags auffallend nahe.

Deklarieren:

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

Erstellen Sie ein DOM in JS:

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

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

Verwenden Sie den Operator new:

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

Sie wissen jetzt, wie Sie dem Browser mithilfe von document.registerElement() ein neues Tag mitteilen können. Das hat aber nichts mit dem Tag zu tun. Fügen wir Eigenschaften und Methoden hinzu.

JS-Eigenschaften und -Methoden hinzufügen

Das Besondere an benutzerdefinierten Elementen ist, dass Sie maßgeschneiderte Funktionen mit dem Element bündeln können, indem Sie Eigenschaften und Methoden in der Elementdefinition definieren. Stellen Sie sich dies als eine Möglichkeit vor, eine öffentliche API für Ihr Element zu 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 erstellen. 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 kann get/set verwendet werden.

Lebenszyklus-Callback-Methoden

Elemente können spezielle Methoden definieren, um auf interessante Zeiten ihres Vorhandenseins zu reagieren. Diese Methoden werden folgerichtig als Lebenszyklusereignisse bezeichnet. Jedes hat einen bestimmten Namen und Zweck:

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

Beispiel: Definieren von createdCallback() und attachedCallback() auf <x-foo>:

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, aber definiere sie, falls sie sinnvoll sind. Angenommen, Ihr Element ist ausreichend komplex und stellt eine Verbindung zu IndexedDB in createdCallback() her. 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> mit einer JavaScript API erstellt. Das Feld ist jedoch leer. Sollen wir ihm etwas HTML zum Rendern geben?

Lebenszyklus-Callbacks sind hier hilfreich. 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});

Die Instanziierung dieses Tags und die Überprüfung in den Entwicklertools (Rechtsklick und Auswahl von "Element untersuchen") sollten Folgendes anzeigen:

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

Interne Elemente in Shadow DOM kapseln

Shadow DOM ist an sich ein leistungsstarkes Tool zum Kapseln von Inhalten. Wenn du es zusammen mit benutzerdefinierten Elementen verwendest, wird es wie von Zauberhand!

Shadow DOM bietet benutzerdefinierte Elemente:

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

Das Erstellen eines Elements aus dem Shadow-DOM ähnelt dem Erstellen eines Elements, das einfaches Markup rendert. Die Differenz ist in 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 die .innerHTML 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, sehen Sie ein #shadow-root, das maximiert werden kann:

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

Das ist die Schattenwurzel!

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 sind wirklich sehr praktisch. Sehen wir uns einmal an, was genau 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. Ein Shadow DOM sorgt für die Kapselung des Elementstils (z. B. färbt p {color: orange;} nicht die gesamte Seite orange).

So gut!

Stile für benutzerdefinierte Elemente festlegen

Wie jedes HTML-Tag können Nutzer Ihres benutzerdefinierten Tags mithilfe von Selektoren einen Stil festlegen:

<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 geht viel viel tiefer, wenn man Shadow DOM in den Mix bringt. Benutzerdefinierte Elemente, die Shadow DOM verwenden, profitieren von den Vorteilen dieser Technologie.

Shadow DOM ermöglicht die Stilkapselung eines Elements. In einem Shadow Root definierte Stile gehen nicht aus dem Host aus und gehen nicht aus der Seite ein. Bei einem benutzerdefinierten Element ist das Element selbst der Host. Mit den Eigenschaften der Stilkapselung können benutzerdefinierte Elemente auch eigene Standardstile definieren.

Der Shadow DOM-Stil ist ein großes Thema. Wenn Sie mehr darüber erfahren möchten, empfehle ich einige meiner anderen Artikel:

Verhinderung von FOUC mit :unresolved

Um FOUC zu vermeiden, geben benutzerdefinierte Elemente eine neue CSS-Pseudoklasse an: :unresolved. Verwenden Sie sie für das Targeting auf nicht aufgelöste Elemente bis zu dem Punkt, an dem der Browser createdCallback() aufruft (siehe Lebenszyklusmethoden). Danach ist das Element kein unaufgelöstes Element mehr. Das Upgrade ist abgeschlossen und das Element wurde in seine Definition umgewandelt.

Beispiel: "x-foo"-Tags bei der Registrierung ausblenden:

<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 und nicht für Elemente, die von HTMLUnknownElement übernommen werden (siehe Upgrade von Elementen).

<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 die Browserunterstützung hervorragend ist, gibt es einen Polyfill, der von Polymer von Google und X-Tag von Mozilla verwendet wird.

Was ist mit HTMLElementElement passiert?

Für diejenigen, die die Standardisierungsarbeit durchgeführt haben, wissen Sie, dass es einmal <element> gab. Es waren die Bienenknie. Sie können damit neue Elemente deklaratorisch registrieren:

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

Leider gab es zu viele zeitliche Probleme mit dem Upgradeprozess, Eckfällen und Armageddon-ähnlichen Szenarien, um das Problem zu lösen. <element> musste auf Eis gelegt werden. Im August 2013 gab Dimitri Glazkov auf public-webapps bekannt, dass die Website zumindest vorerst entfernt wurde.

Beachten Sie, dass Polymer mit <polymer-element> eine deklarative Form der Elementregistrierung implementiert. Wie? Dabei kommen document.registerElement('polymer-element') und die Techniken zum Einsatz, 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>, erhalten wir ein Bild von Web-Komponenten. Markup kann wieder sexy sein!

Wenn Sie sich für die ersten Schritte mit Webkomponenten interessieren, empfehlen wir Ihnen Polymer. Es bietet mehr als genug, um loszulegen.