Wenn ihr mir auf Twitter folgt, ist euch sicher nicht entgangen, dass ich seit einiger Zeit an einer Version 2.0 meiner Web Component <html-import> schraube. Dieses etwas seltsame Custom Element dient mir vor allem zum schnellen Zusammenstecken von Präsentationen aus Slide-Modulen, könnte aber mit wenigen Updates auch für andere Use Cases relevant werden. Also wollte ich das Element richtig gründlich aufpolieren und aus ihm die beste Web Component bauen, die ich bauen konnte. Eine gute Web Component zeichnet sich meines Erachtens vor allem dadurch aus, dass sie sich in jederlei Hinsicht wie ein eingebautes HTML-Element verhält und alle Features von nativen Elementen so gut nachbildet, wie es die APIs rund um Custom Elements zulassen. Und da mein <html-import> eine Reihe von Events wirft (importdone, importfail usw.) wollte ich auch Event Handler zum Funktionieren bringen, und zwar sowohl die DOM-Property als auch die HTML-Attribut-Variante:

<!-- das soll gehen: -->
<html-import onimportdone="window.alert('ok')"></html-import>

<!-- und das hier auch: -->

<script>
  let el = document.querySelector("html-import");
  el.onimportfail = () => window.alert("fail");
<script>

Wichtig hierbei: ein Event Handler ist nicht das Gleiche wie ein Event Listener. Event Listener werden in der Praxis per addEventListener registriert und sind vielseitig justierbar (once: true usw.), während Event Handler einfach über ein on-irgendwas benanntes HTML-Attribut oder DOM-Property auf HTML-Elemente gesteckt werden. Es kann pro Event nur einen Event Handler auf einem Element geben und dieser kann nicht groß konfiguriert werden, während pro Event auf einem Element beliebig viele unterschiedlich konfigurierte Event Listener geben kann. Technisch gesehen sind Event Handler sind eine besondere Art von Event Listener:

<!-- Event Handler -->
<button onclick="window.alert(23)"></button>

<!-- Event Listener: -->
<script>
  let el = document.querySelector("button");
  el.addEventListener("click", () => window.alert(42), { once: true });
  el.addEventListener("click", () => window.alert(1337));
<script>

Dieser Code registriert insgesamt drei Event Listener für den Button, wovon einer per Event Handler definiert wurde.

Die Logik für Event Handler ist (leicht überraschend) keine Magie. Mit on beginnende Properties und Attribute werden nicht automagisch zu Andockstellen für Event Handler, sondern für jedes Event müssen Properties und Attribute von Hand eingebaut werden. Und wie sich herausstellen sollte, ist dieses Einbauen kein Selbstläufer.

Warum überhaupt Event Handler?

Event Handler genießen einen zweifelhaften Ruf. Zum Einen gibt es sie nur für einen Teil der eingebauten Events, zum Anderen kann es pro Event nur einen einzigen, nicht-konfigurierbaren Handler geben (anders als bei addEventListener). Hinzu kommt, dass die HTML-Attribut-API richtiggehend gefährlich ist – im Prinzip bietet sie ein deklaratives eval()! Eine generelle Hände-Weg-Policy, wie von MDN verordnet, hat also durchaus ihre Berechtigung, gerade in Anbetracht des Vorhandenseins von addEventListener als Alternative. Ich habe als Erwiderung nur ein schwächliches und ein ganz besonders schwaches Argument vorzubringen:

  • Nicht ganz so schwach finde ich den Hinweis, dass eine schnelle, dreckige onevent-Property bei Tests und Prototypen schon ganz praktisch sein kann. Ich als fleißiger (fast ausschließlicher) Test- und Prototyp-Autor bin diesbezüglich im Vergleich zu richtigen Entwicklern etwas voreingenommen, aber es ist nun mal so: Ich schreibe gerne mal ein onclick, solange der Code nicht Gefahr läuft, in irgendeiner Form von Produktiveinsatz zu landen.
  • Als schwächstes, aber mir wichtiges Argument bleibt außerdem, dass meine Prämisse immer noch lautet: eine gute Web Component verhält sich wie ein natives HTML-Element und das schließt nun mal funktionierende Event Handler ein. Wenn selbst vergleichsweise neue native HTML-Elemente wie <video> z.B. onplay als Property und HTML-Attribut unterstützen, dann sollte das auch mein <html-import> können. Bzgl. eval()-Gefahr kann ich mich ja einfach darauf verlassen, dass die Nutzer:innen der Komponente schlau genug sein werden, keinen Unfug zu bauen, denn sie schaffen das schließlich auch mit allen anderen Events auf allen anderen Elementen.

Da Minus mal Minus Plus ergibt, würde ich sagen, dass zwei schlechte Argumente in Kombination zumindest mal gut genug sind, um sich an der Implementierung von Event Handlern zu versuchen. Wie schwer kann's schon sein?

Wie Event Handler funktionieren

Wenn wir die Spezifikationen zu Events lesen, stellen wir fest: Event Handler sind gar nicht mal so trivial! Abgesehen von allerlei für uns irrelevanten Sonderfällen rund um Namespaces, Frames und Window-Objekte lässt sich die Spec auf das folgende Regelwerk eindampfen:

  1. Event Handler für ein Event foo können per HTML-Attribut oder DOM-Property angegeben werden. Im ersten Fall muss der Wert des Attributes ein String aus auführbarem JavaScript sein, im zweiten Fall muss es eine JavaScript-Funktion sein.
  2. HTML-Attribut und DOM-Property sind zwei Wege, den einen Handler für das eine Event auf dem Ziel-Element zu definieren. Der zuletzt gesetzte Event Handler (egal ob via Attribut oder via Property) ist der, der gilt.
  3. Auslesen der DOM-Property liefert den zum Zeitpunkt des Auslesens aktuellen Event Handler. Das ist, wenn zuletzt die DOM-Property gesetzt wurde, die gesetzte Funktion. Wenn zuletzt das HTML-Attribut gesetzt wurde, liefert das Auslesen der DOM-Property eine Funktion, die den JavaScript-String im Attribut-Wert wrappt und ausführt (d.h. quasi ein eval()) macht. Gibt es keinen aktuellen Event Handler, liefert das Auslesen der DOM-Property null.
  4. Wird die DOM-Property auf null gesetzt, wird der aktuelle Event Handler deaktiviert was gleichbedeutend mit keinem aktiven Event Handler ist. Wichtig hierbei: ein eventuell vorhandenes HTML-Attribut bleibt, wie es ist und wird nicht geändert, wenn sich die dazugehörige DOM-Property ändern (anders als z.B. das Attribut-Property-Zusammenspiel von id und class/className funktioniert)
  5. Das Entfernen eines vorher vorhandenen HTML-Handler-Attributs deaktiviert den dazugehörigen Event Handler ebenfalls, d.h. die DOM-Property liefert null. Ein nicht vorhandenes Handler-Attribut zu entfernen hat keinen Effekt. Attribut-Werte wie der leere String oder "null" deaktivieren den Handler ebenfalls nicht, sondern führen nur zu weitgehend funktionslosen Handler-Funktionen (ein eval("null") macht schließlich nichts Spannendes).
  6. Wird die DOM-Property auf null gesetzt, wird der aktuelle Event Handler deaktiviert was gleichbedeutend mit keinem aktiven Event Handler ist. Wichtig hierbei: ein eventuell vorhandenes HTML-Attribut bleibt wie es ist und wird nicht geändert, wenn sich die dazugehörige DOM-Property ändern (anders als z.B. das Attribut-Property-Zusammenspiel von id und class/className funktioniert)
  7. Wird ein vorhandener Handler ersetzt (egal ob per Attribut oder Property) übernimmt er in der Reihenfolge der ausgeführten Event-Callbacks die Position seines Vorgängers. Definieren wir einen Handler A, dann einen Listener B und ersetzen dann Handler A durch Handler C, ist die resultierende Ausführungsreihenfolge C-B, denn C übernimmt in der Event-Reihenfolge den Platz von A

Alles in allem sollte unser selbstgebautes Event-Handler-Handling auf Custom Elements genau so funktionieren, wie es onclick im folgenden Code macht:

const foo = document.querySelector("foo");

foo.addEventListener("click", () => window.alert("Listener 1") ); // Normaler Listener

foo.setAttribute("onclick", "window.alert('Attribut 1')") // Handler via Attribut

foo.getAttribute("onclick") // > "window.alert('Attribut 1')"

foo.onclick // > ƒunction onclick(event) { window.alert('Attribut 1') }

// Klick jetzt liefert: "Listener 1", "Attribut 1"

foo.addEventListener("click", () => window.alert("Listener 2") )

// Klick jetzt liefert: "Listener 1", "Attribut 1", "Listener 2"

foo.setAttribute("onclick", "window.alert('Attribut 2')") // Update des vorhandenen Handlers

// Klick jetzt liefert: "Listener 1", "Attribut 2", "Listener 2"

foo.onclick = () => window.alert("Property 1") // Update des vorhandenen Handlers

// Klick jetzt liefert: "Listener 1", "Property 1", "Listener 2"

foo.onclick = null // Handler dekativieren

// Klick jetzt liefert: "Listener 1", "Listener 2"

foo.onclick = () => window.alert("Property 2") // Handler wiederbeleben

// Klick jetzt liefert: "Listener 1", "Listener 2", "Property 2"

foo.removeAttribute("onclick") // Handler wieder deaktivieren

// Klick jetzt liefert: "Listener 1", "Listener 2"

Zusammengefasst:

  1. Event Handler stellen einen „Slot“ für einen Event Listener bereit, der per Attribut oder Property befüllt werden kann
  2. So registrierte Event Listener reihen sich in die Ausführung aller anderen Event Listener ein
  3. Das Update eines Event Handler übernimmt in der Ausführungsreihenfolge aller Event Listener den Platz seines Vorgängers
  4. Die Kopplung zwischen Property und Attribut ist unvollständig; Attribute-Updates ändern die Property mit, umgekehrt gilt das nicht

Dieses Verhalten ist für sich genommen nicht so unglaublich schwer richtig hinzubekommen, aber es ist dann doch ein gewisser Aufwand, der für jedes Event anfällt. Aus der Perspektive einer Web Component ist vor allem relevant, dass wir auf Updates der Handler-Attribute reagieren müssen. Und da ich keine Lust habe, all das bei jedem Event auf jeder Komponente zu berücksichtigen, habe ich ein kleines Mixin gebaut, mit dem sich das Verhalten von onclick für jedes beliebige Event von Custom Elements nachbilden lässt.

Event Handler für alle: OnEventMixin

OnEventMixin hackt mithilfe von gutem alten Prototype-Patching alles für Event Handler in vorhandene Custom-Element-Klassen hinein! Die Benutzung könnte einfacher nicht sein:

<script type="module">
  import OnEventMixin from "./oneventmixin.js";

  class MyFoo extends HTMLElement {
    // Triggert das DIY-Event "stuffhappens", aber es entsteht nicht
    // automatisch eine API für onfoo Event Handler
    connectedCallback() {
      this.addEventListener("click", () => {
        this.dispatchEvent(new Event("stuffhappens"));
      });
    }
  }

  // OnEventMixin fügt onstuffhappens zu MyFoo hinzu
  window.customElements.define("my-foo", OnEventMixin(MyFoo, ["stuffhappens"]));
</script>

<my-foo onstuffhappens="window.alert('Funktioniert')">
  Klick triggert stuffhappens-Event (inkl. Alert)
</my-foo>

Neben den eher grusligen HTML-Attributen funktionieren dank des Mixins auch die entsprechenden DOM-Properties mit gleichen Namen.

OnEventMixin gibt's auf Github und auf NPM:

$ npm -i @sirpepe/oneventmixin

Die Readme erklärt alles Wissenswerte, aber im Endeffekt stopfen wir einfach unsere Komponenten-Klasse und eine Liste von Events in die Funktion OnEventMixin hinein – mehr ist nicht zu tun. So einfach die Benutzung des Mixins ist, so ist die Implementierung doch eher etwas für die Freunde von schwererer JavaScript-Kriminalität. Der Mixin modifiziert immerhin die Klasse auf die folgende Weise:

  1. Pro Event wird ein Getter/Setter-Paar für die on-DOM-Property in die Zielklasse gehackt. Diese Getter und Setter initialisieren bei Erstbenutzung ein Objekt, das den eigentlichen Wert des Event Handlers verwaltet (v.a. die Reihenfolge). Dieses Objekt wird auf dem Ziel-Element hinter einem Symbol gespeichert und von den Gettern und Settern angesteuert.
  2. Wenn die Zielklasse static get observedAttributes() implementiert, wird die Liste der Attribute um die Handler-Attribute ergänzt, ansonsten wird observedAttributes erstmalig mit den Handler-Attributen in die Klasse gebastelt.
  3. Ein ggf. vorhandener attributeChangedCallback() wird dergestalt ergänzt, dass Updates der Handler-Attribute den zum Event passenden Event-Manager ansteuern. Attribute, die in den vorherigen observedAttributes gelistet waren, werden an den ursprünglichen attributeChangedCallback() weitergeleitet, aber auch nur diese! Der Event-Manager wird bei Bedarf erst im attributeChangedCallback() initialisiert und falls es noch keinen attributeChangedCallback() gibt, definiert der Mixin ihn erstmals.

Obwohl die Zielklasse an diversen Stellen modifiziert wird, arbeitet der Mixin fast perfekt minimalinvasiv: Die zusätzlichen on-Properties wurden explizit bestellt und der neue attributeChangedCallback() delegiert nur für vorher schon zu observierende Attribute an den originalen attributeChangedCallback(). Allein der Umstand, dass die observedAttributes ersetzt werden, könnte in Production als unerwünschte Nebenwirkung spürbar werden – dort tauchen notgedrungenermaßen die zusätzlichen Event-Handler-Attribute auf. Umgehen lässt sich das nicht wirklich:

  1. Der Mixin könnte, statt den Prototype zu patchen, eine Wrapper-Klasse konstruieren und hierüber die zusätzliche Funktionalität einspeisen. Problematisch wäre hieran, dass die Originalklasse und die Wrapper-Klasse dann nicht mehr das gleiche Objekt wären, womit Entwickler:innen leicht durcheinander kommen könnten – zumal das das observedAttributes-Phänomen auch nicht wirklich lösen würde.
  2. Das Verwirr-Risiko wäre gebannt, wenn der Mixin nicht als Mixin-Funktion, sondern als Wrapper-API um window.customElements.define() daherkäme; die Klasse würde erst in dem Moment einen Wrapper erhalten, in dem sie registriert wird. Damit wäre es aber auch unmöglich, Event-Handler-Funktionalität in Subklassen zu erben, da diese nur im Wrapper implementiert und dieser mit der define()-API nicht mehr zugänglich wäre.
  3. Das Management der Attribute in einem MutationObserver zu verlegen fällt aus, da Observer im Gegensatz zum attributeChangedCallback() asynchron sind. Das originale Verhalten der Event-Handler-Attribute ist damit nicht nachzubilden.
  4. Eine Umsetzung per Decorator würde nur etwas an der API ändern und kommt mir persönlich, solange es hierfür keinen beschlossenen ECMAScript-Standard gibt, ohnehin nicht in die Tüte.

Unterm Strich sollten sich die Änderungen an den observedAttributes unter fast allen Umständen verkraften lassen, denn darauf zugreifen wollen wir praktisch nie – es geht eigentlich immer allein um die Steuerung des attributeChangedCallback(). Am ehesten fährt der Mixin noch TypeScript-Nutzern in die Parade, denn TS kommt mit Klassen-Patching nicht wirklich klar und benötigt einen kleinen Workaround um die Mixin-Auswirkungen in saubere Klassen-Typen zu überführen.

Limitierungen von selbstdefinierten Event Handlern

Jenseits aller Mixin-Nebebenwirkungen haben selbstdefinierten Event Handler aber eine handfeste Limitierung: sie sind nicht auf allen Elementen verfügbar, nur auf Custom Elements. Alle eingebauten Event Handler sind im HTMLElement-Interface definiert und funktionieren daher allesamt auf allen HTML-Elementen. Auch Elemente, die ein Event A niemals feuern würden, können einen Event Handler für Event A haben. Das ist für Event Bubbling durchaus nützlich:

<!-- In Aktion: codepen.io/SirPepe/pen/MWbrWma -->
<div onchange="window.alert('Input geändert!')">
  <input type="text" value="Änder mich!">
</div>

Unsere selbstdefinierten Event Handler können das so ohne weiteres nicht. Zwar wäre es möglich, onfoo-DOM-APIs in HTMLElement hineinzupatchen, aber die Unterstützung der dazugehörigen HTML-Attribute ist nicht möglich. Diese Attribute müssten synchron überwacht werden, was aber nur mit attributeChangedCallback() in Custom Elements geht – MutationObserver sind, da asynchron, kein brauchbarer Ersatz. Somit bleiben unsere DIY-Handler auf den Einsatz in Custom Elements beschränkt, können dort aber durchaus mit Bubbling eingesetzt werden:

<script defer>
  customElements.define(
    "triggers-foo",
    OnEventMixin(class extends HTMLElement {}, ["foo"])
  );
  customElements.define(
    "receives-foo",
    OnEventMixin(class extends HTMLElement {}, ["foo"])
  );
  
  document.querySelector("triggers-foo").dispatchEvent(
    new Event("foo", { bubbles: true })
  );
</script>

<receives-foo onfoo="window.alert('Foo im Kindelement')">
  <triggers-foo></triggers-foo>
</receives-foo>

Umgekehrt gilt die Einschränkung natürlich nicht. Custom Elements erben (und sei es über Umwege) von HTMLElement und können daher Event Handler die Standard-Events haben, die dann auch für bubblende Events aus den eigenen Kindelementen greifen.

Fazit

Wie wir gesehen haben, sind Event Handler limitiert, gefährlich und in ihrer Implementierung nicht trivial. Wollen wir solche Features in unseren Web Components haben? Ich denke, dass Event Handler neben den schon genannten noch zwei weitere Eigenschaften haben, die uns in dieser Frage der Antwort „Ja“ etwas näher bringen können. Zum Einen sind Event Handler ein etabliertes HTML/DOM-Feature und wenn wir meiner Eingangsprämisse folgen, nach der eine gute Web Component ein sich nahtlos in das vorhandene HTML-Vokabular integrierendes Element ist, gehören Event Hander einfach dazu. Zum Anderen sind Event Handler, wie der Mixin zeigt, zwar kompliziert, aber generalisierbar. Alles, was wir tun müssen, um N Events in Event Handlern zu unterstützen, ist unsere Komponentenklasse einmal durch OnEventMixin zu schieben. Dadurch (und die Tatsache, dass der Mixin sehr klein ist) stellt sich eher die Frage: „Warum würden wir dieses Feature nicht haben wollen?“

Für <html-import> gibt es jetzt jedenfalls Unterstützung für die Event Handler onstart, ondone, onfail und onabort, ganz so, wie es die HTML-Götter wollen würden. Vorausgesetzt, sie würden ein Element wie <html-import> für eine gute Idee halten.