Die bisherigen Teile dieser Serie haben gezeigt, was Web Components sind und wie man mit ihnen eigene HTML-Elemente erfinden kann. Seither sind Web Components auch ganz gut im allgemeinen Bewusstsein der Frontend-Entwickler angekommen, was man vor allem an der auf Twitter aufkommenden Kritik merkt. Eine besonders häufig formulierte Befürchtung ist, dass Web Components jedes etablierte HTML-Element durch eine unsemantische Eigenkonstruktion ersetzen könnten. In diesem Szenario würde z.B. aus <a href="https://www.google.de"> ohne guten Grund ein Monstrum der Marke <polymer-anchor data-href="https://www.google.de">, das in wichtigen Details (z.B. Barrierefreiheit oder Browserunterstützung) nicht an das Original heranreicht.

Es ist in der Tat nicht auszuschließen, dass demnächst ohne komplett überzeugenden Grund zusätzliche Varianten von <a> erfunden werden, aber es ist definitiv nicht nötig, dabei das Rad stets komplett neu zu erfinden und auf die Fähigkeiten der Originale zu verzichten. Web Components beinhalten einen Mechanismus, der es erlaubt vorhandene Elemente mit wenig Aufwand um neuen Fähigkeiten zu ergänzen. Mit normalem DOM-Code ist das etwas knifflig, mit Polymer jedoch ein Kinderspiel.

Der Extend-Mechanismus

Wie wir im ersten Teil der Serie gelernt haben, ist eine der neuen Technologien rund um Web Components die Funktion document.registerElement(). Mit ihr kann man beim Browser komplett selbst erfundene Elemente anmelden:

document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    methodBar:{
      value: function(){
        console.log('Hallo Welt!');
      }
    }
  })
});

An document.registerElement() übergibt man neben dem Namen des neuen Elements (hier x-foo) auch ein Konfigurationsobjekt, das unter anderem den Prototypen für das neue Element festlegt (hier ein neues Objekt, das seinerseits HTMLElement.prototype als Prototypen hat). Als Prototyp kann jedes HTML-Element fungieren, so dass man sein selbstgebautes Element zu z.B. einem Sonderfall von <a> könnte, einfach indem man HTMLAnchorElement.prototype statt HTMLElement.prototype verwendet. Außer einer veränderten Prototypen-Kette bringt das allein aber nicht viel und <x-foo> wird dadurch nicht zu einem anklickbaren Link.

Um wirklich (ohne das Rad neu zu erfinden) eine Abwandlung von <a> zu erstellen, muss man einen etwas anderen Weg gehen. Zum Einen muss man bei der Element-Anmeldung dem Konfigurationsobjekt eine extends-Eigenschaft mitgeben, die den Tag des Elements angibt, von dem wir einen Sonderfall anlegen möchten:

document.registerElement('x-foo', {
  prototype: Object.create(HTMLAnchorElement.prototype, {
    methodBar:{
      value: function(){
        console.log('Hallo Welt!');
      }
    }
  }),
  extends: 'a'
});

Wichtig ist zum Anderen, dass der Prototyp des Elements in extend auch der Prototyp des Objekts in prototype ist. Die Benutzung des neuen Elements erfolgt dann nicht über einen neuen Tag <x-foo>, sondern das Element wird im is-Attribut eines normalen <a>-Elements angegeben:

<a is="x-foo" href="https://www.google.de">Google</a>

Dieses Element ist ein fast ganz normaler Link, nur ergänzt durch unsere Erweiterung in Form der JavaScript-Funktion methodBar(). Die Spezifikationen für Custom Elements nennen solche Konstruktionen type extensions. An sich funktioniert unsere Erweiterung jetzt auch, aber richtig toll ist das Ergebnis noch nicht:

  1. Die rohe API von document.registerElement() ist äußerst unbequem, v.A. das Setup der Prototypen-Kette ist ausgesprochen mühsam
  2. Alles, was über den Einbau einer kleinen JS-Extramethode herausgeht (z.B. Shadow DOM) würde richtig anstrengend werden
  3. Mangels Polyfills funktioniert der bisher gezeigte Code nur in den allerneuesten Browser (d.h. aktuellem Chrome)

Zum Glück ist das Erweitern von Elementen mit Polymer ein Kinderspiel. Es ist sogar so einfach, dass wir uns für den nächsten Schritt direkt vornehmen können, eine durchaus realitäsnahe Erweiterung für <a>-Elemente zu schreiben.

Type Extensions mit Polymer

QR-Codes (nicht lachen!) sind nichts anderes als Links in Bildform. Also macht es Sinn, sie in HTML wie folgt einzubinden:

<a is="qr-code" href="https://www.google.de">Google</a>

Ein solcher Link sollte statt des Link-Texts einen QR-Code anzeigen. Ergänzend sollte man, da es sich ja um Bilder handelt, optionale height- und width-Attribute angeben können. Wenn sich eins der Größen-Attribute oder das href-Attribut ändert, sollte der Code neu berechnet werden. Davon abgesehen sollte sich das Element wie ein herkömmlicher Link verhalten: es muss anklickbar sein, sich per Tastatur bedienen lassen und generell keine schwerwiegenden Nachteile gegenüber einem herkömmlichen Link haben.

Eine gut funktionierende QR-Code-Library ist auf Github schnell gefunden und wird zusammen mit dem üblichen Boilerplate-Code für ein neues Polymer-Element in eine HTML-Datei geschrieben:

<script src="qrcode.js"></script>
<link rel="import" href="bower_components/polymer/polymer.html">

<polymer-element name="qr-code">

  <template>
    <span id="Code"></span>
  </template>

  <script>
    Polymer('qr-code', {
    });
  </script>

</polymer-element>

Das „Element“ qr-code, das wir hier neu anlegen, wird hinterher als Wert im is-Attribut von <a>-Elementen fungieren. Das <span>-Element im Shadow-DOM-Template nimmt den erzeugten QR-Code auf.

Der Prototyp unseres qr-code-Elements braucht als erstes eine Methode zum erstellen neuer QR-Codes. Diese Objekte, erstellt durch die QR-Library, werden in der qrcode-Eigenschaft der Element-Instanz gespeichert (this.qrcode). Eingesetzt wird diese Methode wenn das Element bereit ist, d.h. der ready-Callback feuert:

Polymer('qr-code', {

  createCode: function(){
    return this.qrcode = new QRCode(this.$.Code, {
      text: this.getAttribute('href'),
      width: this.getAttribute('width') || 128,
      height: this.getAttribute('height') || 128
    });
  },

  ready: function(){
    this.createCode();
  }

});

Um auf Attribut-Änderungen reagieren zu können, bedienen wir uns des attributeChanged-Events. Wenn sich nur das href-Attribut ändert, können wir die makeCode()-Methode von QRCode-Objekten nutzen; nur bei Änderungen der Maße muss eine komplett neue QRCode-Instanz erstellt werden:

Polymer('qr-code', {

  ...

  attributeChanged: function(attr, oldVal, newVal){
    if(attr === 'href'){
      this.qrcode.makeCode(newVal);
    }
    if(attr === 'width' || attr === 'height'){
      this.createCode();
    }
  }

});

Stand jetzt ist unser Werk immer noch ein normales Custom Element. Und wie machen wir jetzt eine <a>-Erweiterung daraus? Es könnte einfacher nicht sein:

<polymer-element name="qr-code" extends="a">
  ...
</polymer-element>

Das ganze komplizierte Prototypen-Setup übernimmt Polymer und unsere Element-Erweiterung funktioniert einfach!

Selbstgebaute Custom Elements lassen sich in Polymer auch erweitern, wenn auch auf etwas andere Art und Weise. Was eigentlich gar nicht geht, sind Mehrfacherweiterungen.

Mixins statt Mehrfacherweiterungen

Mehrfacherweiterungen nach dem Muster <a is="foo bar baz"> gibt es nicht. Das ist auch einigermaßen nachvollziehbar, denn letztlich laufen Erweiterungen immer noch auf Prototypen-Ketten hinaus und da ist klar, dass ein Element in der Kette nicht mit mehr als einem anderen verbunden sein kann. Aber das als zweites Argument in die Polymer()-Funktion gesteckte Konfigurationsobjekt kann natürlich ein aus mehreren Objekten zusammengesetztes Objekt sein – Mixins sind hier die beste Lösung.

Nehmen wir einmal an, wir wollten ein Set von Social-Media-Buttons bauen. Jeder Social-Media-Button muss eine URL kennen, für die er Tweet-Links, Like-Schaltflächen o.Ä. bereitstellen soll. Es macht Sinn, diese Funktionalität in eine entsprechende Social-Media-Button-„Basisklasse“ auszulagern:

<polymer-element name="social-media-button" attributes="url">
<script>
  Polymer('social-media-button', {
    url: null,
    ready: function(){
      this.url = this.url || window.location.href;
    }
  });
</script>
</polymer-element>

Erweiterungen dieses allgemeinen Social-Media-Buttons könnten dann konkrete Facebook- und Twitter-Buttons sein. Diese sind keine Type Extensions sondern eigenständige Elemente, da die Custom-Elements-Spezifikation Type Extensions nur für native Elemente vorsieht. Polymer behilft sich, indem aus selbstdefinierten Elementen abgeleitete selbstdefinierte Elemente einfach per Prototypen-Kette hintereinandergeschaltet werden und so komplett neue Elemente entstehen, die die gleichen Fähigkeiten wie ihre „Basisklassen“ haben.

<polymer-element name="twitter-button" extends="social-media-button" attributes="via">
  <template>
    <iframe allowtransparency="true" frameborder="0" scrolling="no"
            src="https://platform.twitter.com/widgets/tweet_button.html?url={{encodedUrl}}&amp;via={{via}}"
            style="width:130px; height:20px;"></iframe>
  </template>
  <script>
    Polymer('twitter-button', {
      via: 'sir_pepe',
      encodedUrl: null,
      observe: {
        url: 'encode'
      },
      encode: function(){
        this.encodedUrl = encodeURIComponent(this.url);
      }
    });
  </script>
</polymer-element>


<polymer-element name="facebook-button" extends="social-media-button" attributes="share">
  <template>
    <iframe allowTransparency="true" frameborder="0" scrolling="no"
            src="//www.facebook.com/plugins/like.php?href={{encodedUrl}}&amp;width&amp;layout=standard&amp;action=like&amp;show_faces=false&amp;share={{share}}&amp;height=35&amp;appId=263047413871308" style="border:none; overflow:hidden; height:35px;" ></iframe>
  </template>
  <script>
    Polymer('facebook-button', {
      share: true,
      encodedUrl: null,
      observe: {
        url: 'encode'
      },
      encode: function(){
        this.encodedUrl = encodeURIComponent(this.url);
      }
    });
  </script>
</polymer-element>

Beide Elemente haben große Unterschiede im Shadow DOM und dezent andere Features (via-Attribut beim Twitter-Button, share-Attribut beim Facebook-Widget) aber sie haben auch ein gemeinsames Feature: beide erzeugen (und verwenden) eine encodedUrl-Eigenschaft. Wenn nicht alle Social-Media-Buttons diese Eigenschaft benötigen, hat sie in der „Basisklasse“ nichts verloren, andererseits ist es auch etwas blöd, diesen Code doppelt vorliegen zu haben. Die Lösung besteht darin, die gemeinsamen Teile des Konfigurations-Objekts in ein eigenes Objekt auszulagern …

var encodedUrlMixin = {
  encodedUrl: null,
  observe: {
    url: 'encode'
  },
  encode: function(){
    this.encodedUrl = encodeURIComponent(this.url);
  }
};

… und dieses Objekt mit den individuellen Konfigurations-Objekten der Buttons zu kombinieren. Hierfür kann jede beliebige Mixin-Funktion verwendet werden; Polymer bringt in Form von Platform.mixin() auch eine mit, die so gut wie jede andere ist:

<polymer-element name="twitter-button" extends="social-media-button" attributes="via">
  <template>
    ...
  </template>
  <script>
    Polymer('twitter-button', Platform.mixin({
      via: 'sir_pepe',
    }, encodedUrlMixin));
  </script>
</polymer-element>


<polymer-element name="facebook-button" extends="social-media-button" attributes="share">
  <template>
    ...
  <script>
    Polymer('facebook-button', Platform.mixin({
      share: true
    }, encodedUrlMixin));
  </script>
</polymer-element>

Und schon funktionieren beide Buttons! Der Schlüssel zu zwischen Komponeten geteilter Funktionalität ist also die Kombination von normaler „Vererbung“ und flexiblen Mixins.

Fazit

Werden dank Web Components viele komische neue Dinge als zweifelhafte HTML-Element umgesetzt werden? Vermutlich. Als unsemantische Div-Suppe muss das Ganze allerdings nicht enden, da, wie wir gesehen haben, sich auch einfach native Elemente erweitern lassen. Und für komplexere Funktionalitäts-Transplantationen stehen Mixins bereit, die ein ganz eigener Teil der Web-Component-Welt werden können. Es ist also alles halb so wild.