Mit benutzerdefinierten Elementen arbeiten

Boris Smus
Boris Smus

Einleitung

Im Web mangelt es an Ausdruckskraft. Sehen Sie sich zum besseren Verständnis eine „moderne“ Web-App wie Gmail an:

Gmail

<div>-Suppe ist nicht modern. Und dennoch entwickeln wir Webanwendungen. Das ist traurig. Sollten wir nicht mehr von unserer Plattform verlangen?

Sexy Markup. Lasst uns das schaffen

HTML ist ein hervorragendes Tool zum Strukturieren eines Dokuments, sein Vokabular ist jedoch auf Elemente beschränkt, die im HTML-Standard definiert sind.

Was wäre, wenn das Markup für Gmail nicht grausam wäre? Was wäre, wenn es wunderschö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 Sinn. Sie sind aussagekräftig, leicht verständlich und lassen sich im besten Fall pflegen. Wenn Sie dann das deklarative Backbonenetzwerk analysieren, wissen Sie genau, was es tut.

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 landen, aber vielleicht auch die wichtigste. Webkomponenten existieren nicht ohne die Funktionen, die durch benutzerdefinierte Elemente freigeschaltet werden:

  1. Neue HTML/DOM-Elemente definieren
  2. Elemente erstellen, die über andere Elemente hinausgehen
  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 von document.registerElement() ist der Tag-Name des Elements. Der Name muss einen Bindestrich (-) enthalten. So sind beispielsweise <x-tags>, <my-element> und <my-awesome-app> gültige Namen, <tabs> und <foo_bar> nicht. Durch diese Einschränkung kann der Parser benutzerdefinierte Elemente von regulären Elementen unterscheiden. Außerdem wird dadurch die Forward-Kompatibilität gewährleistet, wenn neue Tags zu HTML hinzugefügt werden.

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

Benutzerdefinierte Elemente übernehmen standardmäßig von HTMLElement. Daher entspricht das vorherige Beispiel:

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

Durch einen Aufruf von document.registerElement('x-foo') wird der Browser über das neue Element informiert 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. Zum Erweitern eines Elements müssen Sie registerElement() den Namen und prototype des Elements übergeben, von dem die Elemente übernommen werden sollen.

Native Elemente erweitern

Angenommen, Sie sind nicht zufrieden mit Joe <button>. Sie möchten die Funktionen durch eine „Mega-Schaltfläche“ ergänzen. Wenn Sie das <button>-Element erweitern möchten, erstellen Sie ein neues Element, das den prototype von HTMLButtonElement und extends den Namen des Elements übernimmt. In diesem Fall „button“:

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 für Typerweiterung bezeichnet. Sie übernehmen von einer spezialisierten Version von HTMLElement, um zu sagen, „Element X ist ein Y“.

Beispiel:

<button is="mega-button">

Benutzerdefiniertes Element erweitern

Um ein <x-foo-extended>-Element zu erstellen, das das benutzerdefinierte <x-foo>-Element erweitert, übernehmen Sie einfach dessen Prototyp und nennen Sie das Tag, von dem Sie es übernehmen möchten:

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.

Upgrade von Elementen

Haben Sie sich jemals gefragt, warum der HTML-Parser keine Anpassung an nicht standardmäßige Tags vornimmt? Zum Beispiel ist es völlig in Ordnung, wenn wir <randomtag> auf der Seite deklarieren. Gemäß der HTML-Spezifikation gilt Folgendes:

Tut uns leid, <randomtag>! Sie sind kein Standard und übernehmen die Daten von HTMLUnknownElement.

Dasselbe gilt nicht für benutzerdefinierte Elemente. Elemente mit gültigen Namen für benutzerdefinierte Elemente übernehmen von HTMLElement. Sie können dies überprüfen, indem Sie die Konsole starten: 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

Nicht aufgelöste Elemente

Da benutzerdefinierte Elemente von einem Skript 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') viel später aufrufen.

Ein Upgrade von Elementen auf ihre Definition wird 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.

Die folgende Tabelle kann dabei helfen, den Überblick zu behalten:

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

Elemente instanziieren

Die üblichen Verfahren zum Erstellen von Elementen gelten auch für benutzerdefinierte Elemente. Wie alle Standardelemente können sie in HTML deklariert oder mit JavaScript im DOM 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

Die Instanziierung von benutzerdefinierten Elementen im Stil einer Typerweiterung kommt sehr nahe an benutzerdefinierten 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 überlastete Version von document.createElement(), die das Attribut is="" als zweiten Parameter verwendet.

Verwenden Sie den new-Operator:

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

Bisher haben Sie gelernt, wie Sie den Browser mit document.registerElement() über ein neues Tag informieren. Allerdings bietet es nicht viel. Lassen Sie uns Eigenschaften und Methoden hinzufügen.

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 in der Elementdefinition Eigenschaften und Methoden definieren. Betrachten Sie dies als eine Möglichkeit, 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 kein Fan davon sind, Prototypen wie diesen zu erstellen, finden Sie hier eine kompakte Version davon:

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. Das zweite ermöglicht die Verwendung von get/set.

Lebenszyklus-Callback-Methoden

Elemente können spezielle Methoden definieren, um interessante Zeiten ihres Bestehens zu nutzen. Diese Methoden werden entsprechend als Lebenszyklus-Callbacks bezeichnet. Jedes Element hat einen bestimmten Namen und Zweck:

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

Beispiel: Definition von createdCallback() und attachedCallback() für <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, sollten aber definiert werden, wenn sie sinnvoll sind. Angenommen, Ihr Element ist ausreichend komplex und öffnet eine Verbindung zu IndexedDB in createdCallback(). Führen Sie die erforderlichen Bereinigungsarbeiten in detachedCallback() aus, bevor es aus dem DOM entfernt wird. Hinweis:Sie sollten sich nicht darauf verlassen, z. B. wenn der Nutzer den Tab schließt, sondern es als möglichen Optimierungs-Hook betrachten.

Ein weiterer Anwendungsfall für Lebenszyklus-Callbacks ist das Einrichten von Standard-Event-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, aber das Feld ist leer. Sollen wir ihm HTML zum Rendern geben?

Lebenszyklus-Callbacks sind hier praktisch. Speziell kann createdCallback() verwendet werden, um einem Element Standard-HTML zuzuweisen:

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 (mit der rechten Maustaste klicken, „Element untersuchen“) prüfen, sollte Folgendes angezeigt werden:

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

Interne Elemente in Shadow-DOM kapseln

Shadow DOM allein ist ein leistungsstarkes Tool zum Kapseln von Inhalten. Wenn du sie in Verbindung mit benutzerdefinierten Elementen verwendest, wird das Ganze noch magisch!

Das Shadow-DOM bietet benutzerdefinierte Elemente:

  1. Eine Möglichkeit, ihre Eindrücke zu verbergen und die Nutzer vor blutigen Implementierungsdetails zu schützen.
  2. Stilkapselung... kostenlos.

Das Erstellen eines Elements aus einem Shadow-DOM ist mit dem Erstellen eines Elements mit einfachem Markup vergleichbar. Die Differenz liegt 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 eine Shadow Root für <x-foo-shadowdom> erstellt und dann mit Markup gefüllt. Wenn in den Entwicklertools die Einstellung „Schatten-DOM anzeigen“ aktiviert ist, siehst du 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-Primitiv, 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 sehr ausdrucksstark. Betrachten wir alles, 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 mithilfe von Shadow DOM verborgen
  4. Durch Shadow-DOM wird der Stil des Elements verkapselt (z. B. wird durch p {color: orange;} nicht die gesamte Seite in Orange dargestellt).

Super!

Benutzerdefinierte Elemente gestalten

Wie jedes HTML-Tag können Nutzer Ihres benutzerdefinierten Tags es mithilfe von Selektoren gestalten:

<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>

Stile für Elemente gestalten, die Shadow-DOM verwenden

Das Kaninchenloch ist viel tief eintauchen, wenn du Shadow DOM in die Mischung einbaust. Benutzerdefinierte Elemente, die Shadow DOM verwenden, haben viele Vorteile.

Shadow-DOM fügt ein Element mit Stilkapselung ein. In einem Shadow Root definierte Stile gelangen nicht über den Host hinaus und dringen nicht von der Seite aus ein. Bei einem benutzerdefinierten Element ist das Element selbst der Host. Dank der Eigenschaften der Stilkapselung können benutzerdefinierte Elemente auch Standardstile für sich selbst definieren.

„Shadow DOM-Stile“ sind ein wichtiges Thema. Wenn Sie mehr darüber erfahren möchten, empfehle ich Ihnen einige meiner anderen Artikel:

FOUC-Prävention mit :unresolved

Um FOUC zu verringern, geben benutzerdefinierte Elemente die neue CSS-Pseudoklasse :unresolved an. Verwenden Sie das Element für das Targeting auf nicht aufgelöste Elemente, bis der Browser die 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 nach der Registrierung einblenden:

<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 Upgrade von Elementen ausführen).

<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>

Verlaufs- und Browserunterstützung

Funktionserkennung

Bei der Funktionserkennung muss geprüft werden, 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 erstmals hinter einer Flagge gelandet. Seitdem hat sich die Spezifikation jedoch stark weiterentwickelt. Chrome 31 ist die erste Version, die die aktualisierte Spezifikation unterstützt.

Bis die Browserunterstützung optimal ist, gibt es einen polyfill für Polymer von Google und X-Tag von Mozilla.

Was ist mit HTMLElementElement passiert?

Diejenigen, die der Standardisierung gefolgt sind, wissen, dass es einmal <element> gab. Es waren die Bienen Knie. Sie könnten sie verwenden, um neue Elemente deklarativ zu registrieren:

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

Leider gab es beim Upgradeprozess, in einigen wenigen Fällen und in Armageddon-ähnlichen Szenarien zu viele zeitliche Probleme, als dass es ein Ergebnis gab. <element> musste aufgeschoben werden. Im August 2013 veröffentlichte Dimitri Glazkov einen Beitrag auf public- Web-Apps, um zumindest vorerst das Entfernen anzukündigen.

Polymer implementiert mit <polymer-element> eine deklarative Form der Elementregistrierung. Wie geht das? Dabei werden document.registerElement('polymer-element') und die Techniken verwendet, die ich unter Elemente aus einer Vorlage erstellen beschrieben habe.

Fazit

Mit benutzerdefinierten Elementen können wir HTML-Vokabular erweitern, ihm neue Tricks beibringen und durch die Wurmlöcher der Webplattform springen. Wenn Sie sie mit den anderen neuen Plattform-Primitiven wie Shadow DOM und <template> kombinieren, erkennen wir das Bild der Webkomponenten. Markup kann schon wieder sexy sein!

Wenn Sie sich für den Einstieg in Webkomponenten interessieren, sollten Sie sich Polymer ansehen. Das ist mehr als genug für einen aktiven Lifestyle.