How to Center in CSS, Scrollbar Edition

Veröffentlicht am 30. November 2022

Der älteste Witz des bekannten Webdev-Universums besteht darin, dass es nicht komplett trivial ist, Elemente mit CSS vertikal zu zentrieren. Dieser Witz ist spätestens seit Frühjahr 2021, als mit dem Internet Explorer 11 der letzte nicht perfekt Flexbox unterstützende Browser auf das Abstellgleis befördert wurde, hinfällig. Seither, so wissen wir alle, könnte es einfacher nicht sein:

.wrapper {
  display: flex;
  align-items: center;
}

Flexbox (und Grid) bieten direkte Unterstützung für vertikale Zentrierung und das Problem ist damit ein für allemal komplett erschlagen. Außer per Flexbox und Grid ist Zentrierung außerdem mit absoluter Positionierung und einer Transformation zu erreichen. Dieses Verfahren braucht ein paar Zeilen mehr, ist dafür aber auf dem zu zentrierenden Element statt auf dessen Container anzuwenden:

.wrapper {
  position: relative;
}

.element {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}

Diese Variante, die sich zunutze macht, dass sich Prozentangaben bei Transformationen auf die Maße des Elements statt auf die des Containers beziehen, hat den Vorteil, dass durch die Angabe von left und translateX en passant auch eine horizontale Zentrierung zu machen ist. Beide Varianten funktionieren ganz ausgezeichnet … solange das zu zentrierende Element kleiner (für vertikale Zentrierung: weniger hoch) ist, als sein Elternelement. Aber was, wenn nicht?

Vertikale Zentrierung schlägt Scrollbalken

Nehmen wir einmal an, wir wollten ein Element variabler Größe innerhalb eines Containers darstellen. Das Element soll in unserem Beispiel vertikal zentriert sein, solange dafür der Platz ausreicht und wenn der Container für sein Element zu klein ist, sollen Scrollbalken auf dem Container die Möglichkeit bieten, das komplette Element zu betrachten. Auf diese Weise könnte etwa ein Grafikprogramm, bei kleinen Zoomstufen die gesamte Malfläche darstellen und bei starker Vergrößerung vertikales und/oder horizontales Scrollen ermöglichen.

Das klingt zunächst ganz einfach, Scrollbalken in Webseiten sind schließlich kein Hexenwerk: Wir müssen nur dem Container overflow:auto verpassen, und schon taucht (wenn nötig) das gewünschte Scroll-UI auf. Problem gelöst! Es sei denn, wir wollen scrollen und vertikal zentrieren:

Links ein vertikal zentriertes Element, das oben und unten aus seinem kleineren Container herausläuft. Rechts hat der Container scollbalken, doch der oben überstehende Teil des Kindelements ist trotzdem abgeschnitten.

Das sieht gar nicht mal so gut aus! Die horizontale CSS-Zentrierung ist offenbar unverwüstlich und zentriert auch das, was eigentlich nicht mehr in seinen Container passt. Die Mitte des Containers und die Mitte des zu zentrierenden Elements (die Rot-Blau-Grenze) liegen aufeinander und wenn der Container weniger hoch als sein Inhalt ist, läuft es oben und unten über die Grenze seines Elternelements. So weit, so erwartbar. Der mit overflow: auto hinzugefügte Scrollbalken macht aber nur den unteren über den Rand laufenden Teil des zu großen Elements erschließbar! Alles, was oben übersteht, der gesamte überstehende rote Anteil, wird einfach abgeschnitten und ist komplett unerreichbar (man achte auf die Scroll-Position des rechten Containers im obigen Bild). Möglicherweise wichtiger Inhalt verschwindet auf diese Weise komplett im Overflow-Orkus. Das kann so nicht bleiben!

Eine Formel für bedingte vertikale Zentrierung … mit Nachteilen

Um unser Ursprungsziel zu erreichen, benötigen wir im Prinzip eine bedingte vertikale Zentrierung, die nur bei ausreichend Platz greift. Sobald der zu zentrierende Inhalt zu groß für seinen Container wird, muss der Zentrierungsmechanismus seine Arbeit einstellen und das Element normal an die obere linke Ecke seines Containers andocken, damit der Scrollbalken seine Arbeit verrichten kann. Am einfachsten geht das, indem wir

  1. Anstelle von Flexbox oder Grid als Zentrierungsmechanismus position:absolute wählen
  2. top mithilfe von calc() errechnen, statt einfach top:50% zu verwenden und für die Maße des Elements mit transform: translateY(-50%) zu kompensieren
  3. Einen Top-Overflow unterbinden, indem wir das calc()-Ergebnis aus Schritt 2 mithilfe von max() auf mindestens 0px beschränken

Im Endeffekt:

.element {
  top: max(0px, calc((100% - 600px) / 2));
}
Links die schlechte Scroll-Lösung aus dem vorherigen Beispiel, rechts die bessere Lösung nach der neuen Formel.

Der erste Parameter für max() ist der für uns nicht zu unterschreitende Top-Offset von 0px, während der zweite Parameter unser eigentlicher Wunsch-Offset ist. Für vertikale Zentrierung ist das die Hälfte des Platzes, der übrig bleibt, wenn wir von der Höhe des Containers (100%) die Höhe des zu zentrierenden Elements (im Beispiel 600px) abziehen. Das Ergebnis des calc()-Ausdrucks wird negativ, wenn des zu zentrierende Element höher als sein Container ist, aber vor diesen Auswirkungen bewahrt uns die Limitierung auf mindestens 0px. Im schlimmsten Fall, bei zu wenig Platz, dockt das Element also oben an und ist auf ganzer Höhe erscrollbar.

Der Haken an dieser Lösung: die Maße des zu zentrierenden Elements müssen bekannt sein. Prozentangaben in top beziehen sich auf das Elternelement und können sich auch auf nichts anderes beziehen; durch die Angaben wie top (ggf. kombiniert mit left, bottom und right) ergeben sich die Maße des Elements schließlich erst. Die Höhe des bedingt zu zentrierenden Elements müssen wir also kennen und in die „Formel“ für top eintragen.

Grundsätzlich könnten wir die top-Formel auch in der transform-Eigenschaft verwenden, haben dort dann aber das umgekehrte Problem: Wir müssten die Dimensionen des Elternelements kennen und fest eintragen. Prozentangaben in transform-Werten beziehen sich auf das betroffene Element selbst, denn eine Transformation ist nur eine Veränderung des Koordinatensystems für die zu zeichnenden Pixel. Deshalb bleibt der „Platzverbrauch“ eines Elements vor und nach einer Transformation gleich; die Pixel sind transformiert, doch das CSS-Layout bleibt, wie es war:

Ein per CSS transformiertes, von CSS umflossenes Quadrat demonstriert, dass nur das Rendering eines Elements verändert wird, das eigentliche Layout jedoch nicht.

Egal ob wir die Verschiebung des Elements per Transformation oder per top versuchen, komplette Flexibilität ist nicht drin   entweder, die Maße des Containers oder des zu zentrierenden Kindelements müssen wir kennen. Es sei denn, wir verwenden ein ganz bestimmtes Feature aus dem Dunstkreis der nagelneuen Container Queries!

Eine flexiblere Formel für bedingte vertikale Zentrierung

Container Queries sind im Prinzip Media Queries für Elemente – Queries, die sich nicht auf der Maße des Bildschirms, sondern auf die Eigenschaften ausgewählter Elternelemente (der Query Container) beziehen. Container Queries sind aber nicht einfach nur ein Standard für besseres Responsive Design, sondern führen auch eine Reihe von interessanten neuen Einheiten ein:

  • cqw: 1% der Breite eines Query Containers
  • cqh: 1% der Höhe eines Query Containers
  • cqi/cqb: 1% Der Inline- bzw. Block-Größe eines Query Containers
  • cqmin/cqmax: Der kleinere bzw. größere Wert von cqi und cqb

Das bedeutet: in einer CSS-Transformation eines Elements haben wir, wenn sein Elternelement ein Query-Container ist, sowohl die Maße des Elements selbst (als %) als auch die des Elternelements (als cqw bzw. cqh) zur Hand! Die top-Formel, entsprechend adaptiert und in transform verwendet, kann damit das Element in eine zentrierte Position verschieben. Das Elternelement muss nur noch zum Query-Container erklärt werden (damit sich cqw und cqh auf etwas beziehen können) und schon klappt es:

.wrapper {
  container-type: size; /* Maße von .wrapper definieren cqh/cqw */
  overflow: auto;
}
.wrapper .element {
  transform: translateY(max(0%, calc((100cqh - 100%) / 2)));
}

Selbst wenn sich die Maße von Container oder Content ändern, das Element bleibt zentriert und die Scrollbalken greifen, wenn nötig, ein! Besonders charmant finde ich an dieser Lösung den Grund für das Erscheinen und Verschwinden der Scrollbalken. Das Element wird zwar zentriert, doch da dies per Transformation geschieht, manifestiert sich der „Platzverbrauch“ des zentrierten Elements durchgehend an der linken oberen Ecke. Wird das Elternelement zu klein, findet sich auch die Transformation der Darstellung an dieser Position ein und - da ab dann der Platzverbrauch auch größer als der umgebende Container ist - die Scrollbalken tauchen auf. Keine Tricks jenseits der Transformation notwendig!

Fazit, Nachteile und Browserunterstützung

Wo sind die Haken bei dieser Lösung? Container Queries fehlen stand Dezember 2022 noch im Firefox. Support im Nightly Build ist schon da, aber noch nicht im regulären Release. Die Transformation bildet natürlich einen neuen Stacking Context und die Angabe von container-type auf dem Wrapper-Element aktiviert implizites Layout-, Style-, und Size-Containment. Das alles, abgesehen vom Firefox-Support, sind keine echten Haken, sondern fallen eher in die Kategorie der zu beachtenden Dinge. Ein reiner Selbstläufer ist das vertikale Zentrieren mit CSS dann doch nicht, jedenfalls nicht, wenn Scrollbalken ins Spiel kommen. Der älteste Witz des bekannten Web-Universums ist und bleibt auch Ende 2022 noch ein ganz klein wenig relevant.

100.000 MutationObserver vs. 100.000 Funktionen

Veröffentlicht am 6. Juli 2021

Ist die Performance eines MutationObserver mit 100.000 Callbacks besser als die Performance von 100.000 MutationObservers mit je einem Callback? Und wenn ja, wie groß ist der Unterschied? Diese Frage tat sich vor mir auf, als ich einst an einer Browser-Extension schraubte, die das DOM einer Seite überwacht und in diesem Kontext sind sowohl Performance als auch MutationObserver wichtig. Also habe ich ein paar Experimente gestartet, die nicht nur eine Antwort auf die Ausgangsfrage, sondern auch einige Erkenntnisse zu den Unterschieden zwischen den diversen Browsern zutage geführt haben. Und weil der ganze Prozess ein ziemlich gutes Beispiel für zielgerichtete Performance-Forschung im Entwickler-Alltag ist, will ich meinen Weg zum Ergebnis in diesem Post nachzeichnen. Falls das uninteressant ist, geht's hier direkt zum Fazit.

MutationObserver

Der MutationObserver ist im modernen Browser das Mittel der Wahl um über Änderungen am DOM im Bilde zu bleiben. Die API ist überschaubar, aber gewöhnungsbedürftig:

// Ziel der Überwachung
const targetNode = document.querySelector(".foo");

// Optionen der Überwachung
const config = { attributes: true, childList: true };

// Handler-Funkion
const callback = function(mutations) {
  for(const mutation of mutations) {
    if (mutation.type === "childList") {
      console.log("Inhalt geändert");
    } else if (mutation.type === "attributes") {
      console.log("Attribut " + mutation.attributeName + " geändert");
    }
  }
};

// Setup
const observer = new MutationObserver(callback);

// Start
observer.observe(targetNode, config);

// Ende via observer.disconnect()

Die etwas komplizierte Konstruktion von MutationObserver wurde aus Performance- und Stabilitätsproblemen mit Mutation Events geboren. Die Mutation Records (die Parameter der Handler-Funktion) trudeln asynchron ein und werden gebündelt, so dass auch ein größeres innerHTML, das viele DOM-Nodes durch andere Nodes ersetzt, nur einen einzigen Funktionsaufruf verursacht. In den Überwachungs-Optionen lässt sich festlegen, ob nur das Ziel-Element überwacht wird, oder ob auch dessen gesamter Sub-Tree betrachtet werden soll und welche Änderungen (Attribute, Text, Kind-Elemente) mitgeschnitten werden sollen.

Alles schön und gut, aber wie sehen die Performance-Charakteristika von MutationObservers aus?

Gibt es überhaupt ein Performance-Problem mit MutationObserver?

Mit modernem Browser-Tooling können wir bei der JavaScript-Performance-Analyse extrem tief ins Detail gehen, doch der erste Schuss sollte eigentlich immer dem Prinzip „stumpf ist Trumpf“ folgen. Wir bilden eine möglichst einfache Hypothese, die, wenn zutreffend, alle weiteren Untersuchungen überflüssig macht. Und dann versuchen wir, diese Hypothese in einem möglichst einfachen Experiment zu widerlegen. Sollte das gelingen, müssen wir weitere Untersuchungen anzustellen und/oder die Hypothese anpassen, während wir, wenn die Hypothese auf die erste Beobachtung passt, nichts weiter zu untersuchen haben.

Für diese erste Messreihe genügt der folgende, extrem billige Versuchsaufbau:

// Gewagte Hypothese: 100.000 MutationObserver sind kein Problem

let result = 0;
let target = document.querySelector("div");

for (let i = 0; i < 100000; i++) {
  new MutationObserver(function handler() {
    result++;
  }).observe(target, { attributes: true });
}

document.querySelector("button").onclick = () => {
  target.setAttribute("data-test", "1")
  setTimeout(() => console.log(result), 3000);
};

Einfacher kann's nicht werden: das Programm kommt als Inline-Script in ein HTML-Dokument mit einem <div> und einem <button> und fertig! Die Handler-Funktion zählt eine später ausgegebene Variable hoch, damit die JavaScript-Engines nicht auf die Idee kommen, den gesamten Code (der ohne diese Variable keine wahrnehmbare Auswirkung hätte) einfach wegzuoptimieren. Als Nächstes sollten wir den Versuchsaufbau in einem privaten Tab öffnen (kein Cache, keine Browser-Extensions) und mit den Devtools messen, ob sich irgendwelche Performance-Auffälligkeiten einstellen, doch in diesem Fall ist bereits beim Laden der Seite, noch vor dem Nachmessen, klar, dass es keine gute Idee sein kann, 100.000 MutationObserver zu betreiben:

Anscheinend braucht mein relativ neuer Desktop-PC mit seinen über 9000 CPU-Kernen mehr als 4 Sekunden um 100.000 einfachstmögliche MutationObserver in einer einfachstmöglichen Webseite einfach nur zu starten ‐ und hier wird nur das oben gezeigte Inline-Script evaluiert, zu einer DOM-Mutation kam es noch gar nicht! Die Test-Webseite von der lokalen Festplatte zu laden dauerte bereits spürbar lang, da das Inline-Script den HTML-Parser blockierte. Damit ist auf jeden Fall schon mal geklärt, dass MutationObserver für Performance nicht per se egal sind. Wir können außerdem erkennen, dass der Löwenanteil der vergangenen Zeit (self time) auf das Starten der Observation, d.h. den Aufruf von observe() entfällt.

Triggern wir per Button-Klick eine DOM-Mutation, so sehen wir einen mit 0,5 Sekunden recht stattlichen Trödel-Block in den Microtasks, worin sich das eigentliche MutationObserver-Geschehen abspielt:

Das ist nicht so absurd viel (denn es sind immerhin 100.000 MutationObserver), aber es ist auch nicht nichts. Unsere Ausgangshypothese, nach der 100.000 MutationObserver keinen wirklichen Einfluss auf die JS-Performance haben, ist damit dahin und wir dürfen weiterforschen.

Als Nächstes ist natürlich die Gegenprobe mit 100.000 Funktionen und einem einzigen Observer angesagt, die in einem ähnlich simplen Versuchtsaufbau stattfindet:

// Hypothese: 100.000 Funktionen sind genau so lahm wie 100.000 Observer

let result = 0;
let target = document.querySelector("div");

let handlers = [];

for (let i = 0; i < 100000; i++) {
  handlers.push(function handler() {
    result++;
  });
}

new MutationObserver(function rootHandler() {
  for (const handler of handlers) {
    handler();
  }
}).observe(target, { attributes: true });

document.querySelector("button").onclick = () => {
  target.setAttribute("data-test", "1");
  setTimeout(() => console.log(result), 3000);
};

Non-Arrow-Functions sind bei Performance-Tests immer sehr praktisch, da wir ihnen problemlos Namen verpassen können, die ihrerseits das Profiling-Ergebnis besser lesbar machen. Das Programm bauen wir wieder als Inline-Script in ein einfaches HTML-Dokument ein, laden im privaten Tab und messen nach:

Diese Variante ist so schnell, dass die Messung der Laufzeiten des Mutations-Microtasks gerade so erfasst wurde, während der einmalige Aufruf von observe() im Profiling noch nicht mal auftaucht. Damit scheint die Ausgangsfrage beantwortet: Es gibt einen nicht nur mess-, sondern sogar spürbaren Unterschied zwischen einem Observer mit N Callbacks und N Observern mit einem Callback. Zeit für den Feierabend?

Generelles Phänomen oder Browser-Spezialität?

Die bisherigen Versuche haben alle nur in Chrome stattgefunden, doch es gibt (Stand Mitte 2021) noch ein paar weitere Browser, die nicht Chromium-basiert sind. Es lohnt sich, die Versuche auch dort zu reproduzieren, um auszuschließen, dass wir es mit Chrome-Bugs zu tun haben. Öffnen wir also den 100.000-Observer-Versuch in Firefox und messen mal nach, ob dort auch mehrere Sekunden für observe() ins Land gehen:

Dieser hier 161 Millisekunden dauernde „Funktionsaufruf“ ist kein Funktionsaufruf im eigentlichen Sinne, sondern die Gesamtheit des Inline-Scripts (quasi dessen main()-Funktion). Wo also Chrome allein 4 Sekunden benötigt, um 100.000-mal observe() zu starten, macht Firefox den gleichen Job plus alles andere (z.B. 100.000-mal new MutationObserver()) in einem Bruchteil der Zeit. Das ist schon ein ausgesprochen bemerkenswerter Unterschied – kann es sein, dass in Chrome ein Bug schlummert, der observe() besonders langsam macht? Die Antwort wird offenbar, wenn wir im Firefox eine erste DOM-Mutation auslösen:

Oh, 21 Sekunden für das Click-Event, das die DOM-Änderung verursacht!? Sportlich! Es sieht aus, als würden Chrome und Firefox einen Teil der Arbeit, die bei oder zwischen observe() und der ersten DOM-Mutation anfällt, zu unterschiedlichen Zeitpunkten ausführen. Offenbar wird einiges an Arbeit, das in Chrome beim Aufruf von observe() stattfindet, im Firefox erst dann ausgeführt, wenn tatsächlich ein zu observierendes Ereignis stattfindet. Um welche Arbeit es sich dabei genau handelt, vermag ich den Spezifikationen nicht zu entnehmen, was nahelegt, dass es sich um ein reines Implementierungsdetail handelt. Beide Browser setzen die Standards korrekt um, nehmen aber unterschiedliche Wege zum Ziel (was durchaus nicht ungewöhnlich ist). Firefox versucht, den Start des Observers zu optimieren, indem Arbeit aufgeschoben wird, während Chrome probiert, nach einmaligen (vergleichsweise langsamen) Observer-Setup den ersten Callback-Aufruf möglichst flott zu gestalten.

Festzuhalten bleibt: einen MutationObserver zu starten ist keine Kleinigkeit, auch wenn sich der Effekt in verschiedenen Browsern unterschiedlich manifestiert.

Wie groß ist das Problem wirklich?

Die bisherigen Versuchsaufbauten haben erst mal nur gezeigt, dass MutatationObserver für JavaScript-Performance relevant sind, wenn sie zu tausenden gestartet werden. Was ist aber, wenn es nur hunderte oder gar noch weniger Observer gibt? Eine ermüdende Messreihe (durchgeführt mit Chrome auf einem neuen Desktop-Ryzen ohne Drosselung o.Ä.) führt zu folgendem Bild:

Alles unterhalb von 1000 Observern ist nicht messbar. Darüber steigt die Laufzeit für observe() an, wobei es erst ab 10.000 wirklich kritisch wird. Das wären nicht unmöglich viele Observer, aber auf jeden Fall extrem viele. Unterhalb dieser Extrem-Grenze gibt es also kein wirkliches Problem damit, viele MutationObserver zu betreiben.

Was im Übrigen keine signifikante Rolle zu spielen scheint, ist die Komplexität der observierten DOM-Struktur. Egal wie viele Elemente sich innerhalb oder im Umfeld des Observations-Zieles befinden, der Effekt ist nahezu nicht messbar.

Fazit

Die Ausgangsfrage „ist die Performance eines MutationObserver mit 100 Callbacks besser als die Performance von 100 MutationObservers mit je einem Callback?“ lässt sich also wie folgt beantworten:

  1. Ein MutationObserver, dessen Callback viele Funktionen aufruft, performt grundsätzlich besser als viele MutationObservers mit je einem Callback.
  2. Ein signifikanter Unterschied tritt nicht bei Mutation Events an sich auf, sondern beim Starten der Observation. Im Firefox scheint das „starten der Observation“ beim ersten Mutation Event stattzufinden und nicht, wie in Chrome, beim Aufruf von observe(). Ab dem ersten Aufruf zeigen beide Browser das gleiche Verhalten.
  3. Insgesamt ist die Performance des MutationObserver auf modernen Geräten so gut, dass sich die Unterschiede bei weniger als 1000 Observern nicht wirklich zu Buche schlagen.

Als Best Practices für MutationObservers können wir festhalten:

  1. Es ist kein Problem, 10 oder 20 MutationObserver auf einmal zu betreiben
  2. Falls es sich für den aktuellen Use Case anbietet, könnte das Event-Delegation-Pattern auf MutationObserver angewendet werden: einfach den Document Root observieren, subtree auf true stellen und im Callback je nach betroffenem Element verfahren.
  3. Falls jede Millisekunde zählt, aber DOM-Updates nicht ab Laden der Seite zu erwarten sind, könnte der Aufruf von observe() aus dem Critical Path genommen werden, z.B. via requestIdleCallback(). Firefox macht das quasi von sich aus (schiebt die Arbeit zum ersten Mutation Event), aber mit requestIdleCallback() oder anderen Aufschieb-Mechanismen können wir eine vergleichbare Optimerung auch in anderen Browsern umsetzen.

Im Klartext: modernes JavaScript/DOM ist so schnell, dass MutationObserver per se kein Performance-Problem darstellen, selbst wenn wir hunderte von ihnen auf einmal betreiben. Zwar sind Einzel-Observer messbar schneller, aber der Unterschied ist zu vernachlässigen, solange wir keine vollends absurden Mengen von Observers zu Felde führen. Würde ich nach Möglichkeiten suchen, mein Web-Projekt schneller zu machen, würde ich mich zuerst um andere Baustellen kümmern.

Code-Golf mit JavaScript-Arrays und regulären Ausdrücken

Veröffentlicht am 1. Juni 2021

Vor kurzem wollte ich aus einem <code>-Element die Sprache der enthaltenen Programmiersprache extrahieren. HTML5 etablierte hierfür schon in der Jungsteinzeit eine Konvention, nach der diese Information in der Klasse des fraglichen <code>-Elements zu lagern hat:

<code class="language-javascript">/* JS-Code hier */<code>

Im Prinzip ist das keine große Herausforderung: wir greifen uns den className des DOM-Knotens, wenden den regulären Ausdruck /language-(\S*)/ darauf an und geben aus, was auch immer die Capturing Group eingefangen hat (in diesem Fall 0 bis N Nicht-Whitespace-Zeichen). Wenn der reguläre Ausdruck entweder gar nicht matcht oder die Capturing Group null Zeichen einsammelt, soll als Fallback der String "none" herauskommen. Zu Testzwecken können wir die DOM-Elemente durch normale JavaScript-Objekte ersetzen und uns unseren Dreamcode aufmalen:

// So soll's funktionieren
console.log(getLanguage({ className: "language-c" })); // "c"
console.log(getLanguage({ className: "foo language-json bar" })); // "json"
console.log(getLanguage({ className: "foo language- bar" })); // "none"
console.log(getLanguage({ className: "foo bar" })); // "none"
console.log(getLanguage({ className: "" })); // "none"

Es gibt natürlich eine Menge in diesem Beispiel nicht berücksichtigte Edge Cases, aber es ist ja auch nur ein vereinfachtes Beispiel. Implementieren wir doch einfach mal die Funktion getLanguage() auf die Weise, wie man es in JavaScript seit der bereits angesprochen Jungsteinzeit macht:

function getLanguage(source) {
  const match = /language-(\S*)/.exec(source.className);
  if (match && match[1]) {
    return match[1];
  }
  return "none";
}

Die Regexp-Methode exec() liefert entweder null oder ein etwas seltsames Array, das neben den Ergebnissen des Regexp-Matches noch allerlei Extrafelder enthält. Technisch gesehen ist das kein Problem, denn Arrays sind in JavaScript fast ganz normale Objekte und das einzig besondere an ihnen sind die numerischen Keys und die sich automatisch anpassende length – doch weitere Felder, wie im Falle des RegExpArrays index und input, können in JS auf im Prinzip jedes Array gesteckt werden. Der Umgang mit dem Rückgabewert von exec() ist das eigentliche in unserer Funktion zu lösende Problem. Wir müssen uns in getLanguage() sowohl gegen null wappnen, als auch dagegen, dass die Capturing Group (\S*) bei Inputs wie "language-" einen leeren String liefert. Alles ist bedacht und die Funktion macht genau, was sie machen soll.

Das einzige Problem mit dem obigen Code ist: der Jungsteinzeit entsprechend ist das JavaScript auf syntaktischem Faustkeil-Niveau. Wir setzen eine ganze Menge Zeichen und Statements für eine recht einfache Aufgabe ein! Spielen wir doch einmal eine Runde Code-Golf und versuchen, die Funktion etwas kompakter zu gestalten – ohne die Lesbarkeit allzu arg leiden zu lassen.

Das Handling von null können wir auf relativ unkontroverse Weise abdecken:

function getLanguage(source) {
  const match = /language-(\S*)/.exec(source.className) ?? [];
  if (match[1]) {
    return match[1];
  }
  return "none";
}

Jetzt wird match im Erfolgsfall ein RegexpArray und im Nicht-Match-Fall ein leeres Array enthalten. Der halbwegs neue Nullish Coalescing Operator ?? könnte an dieser Stelle im Prinzip auch durch althergebrachte || ersetzt werden, wobei ersteres die etwas präzisere Formulierung der angestrebten Operation ist. Während das logische Oder a || b zu b evaluiert, wenn a falsy ist, liefert a ?? b nur b, wenn a entweder null oder undefined ist. Normalerweise würde || eine mögliche Fehlerquelle darstellen, da auch leere Strings, die Zahl 0 usw. zu b führen könnten, aber das kann hier nicht passieren (null und ein Array sind die einzigen möglichen Werte). Trotzdem bleiben wir einfach mal bei ??, weil das etwas genauer ausdrückt, was hier unsere Intention ist.

Da wir nun sicher sind, dass match immer ein Array ist, können wir an das zweite Element (den von der Capturing Group eingefangenen Wert) auch per Destructuring statt mit Index-Zugriff herankommen:

function getLanguage(source) {
  const [ , match ] = /language-(\S*)/.exec(source.className) ?? [];
  if (match) {
    return match;
  }
  return "none";
}

Durch ein (oder mehrere) Extra-Kommata in einem Array-Destructuring können wir Elemente überspringen und so recht bequem an das in unserem Fall zweite Element herankommen. Sollten der reguläre Ausdruck keinen Treffer gefunden haben und wir auf das leere Array zurückfallen, würde match zu undefined, was das If-Statement abfängt.

Dieses If-Statement könnten wir nun durch logisches Oder (|| statt ??, um auch leere Strings zu none zu machen) ersetzen und damit den Code auf ganze zwei Zeilen eindampfen:

function getLanguage(source) {
  const [, match] = /language-(\S*)/.exec(source.className) ?? [];
  return match || "none";
}

So weit, so okay, aber ein bisschen viel Hexerei mit sehr ähnlichen Operatoren. Da geht noch was! Wenn wir den regulären Ausdruck /language-(\S*)/ durch /language-(\S+)/ ersetzen, können wir verhindern, dass der String "language-" matcht und damit ein leerer String aus der Capturing Group fällt. Unter diesen Umständen können wir das logische Oder durch einen Default-Wert im Destructuring Assignment ersetzen:

function getLanguage(source) {
  const [, match = "none"] = /language-(\S+)/.exec(source.className) ?? [];
  return match;
}

Das lässt sich zwar jetzt einigermaßen gut als „match wird das zweite Element oder "none"“ lesen, aber so richtig gut gefällt es mir noch nicht. Das Komma, das das erste Element im Destructuring Assignment überspringt, springt uns optisch nicht gerade entgegen und in Fällen, in denen es mehr als ein Komma gibt, wird der gesamte Ausdruck noch schwieriger zu verstehen:

// Das wievielte Element ist jetzt was?
const [,, foo, bar ,, baz] = ["a", "b", "c", "d", "e", "f", "g", "h"];

Doch der Kommasalat muss nicht sein: Objekt-Destructuring für Arrays hilft! Denn Arrays sind, wie beim RegExpArray schon erwähnt, in erster Näherung nur eine Sonderform von JS-Objekten mit ein paar Konventionen (numerische Keys, length-Property). Dass sie mit dem sogenannten Array-Destructuring verwendet werden können, liegt daran, dass sie das entsprechende Iterator-Protokoll implementieren, aber es spricht nichts dagegen, sie wie ganz normale Objekte mit Objekt-Destructuring zu behandeln:

const arr = ["a", "b", "c", "d", "e", "f", "g", "h"];
const [ first ] = arr; // > klappt, ergibt "a"
const { length } = arr; // > klappt, ergibt 8

Während wir in diesem Beispiel auf die Information length anhand ihres Namens zugreifen, greifen wir auf den Wert first anhand seiner Position im Array zu. Allerdings hat auch first einen Namen im Array, nämlich seinen Index bzw. Objekt-Key: 0. Auf diesen können wir nicht ganz ohne weiteres in Objekt-Destructuring zugreifen …

const arr = ["a", "b", "c", "d", "e", "f", "g", "h"];
const { 0 } = arr; // > SyntaxError

… was aber allein daran liegt, dass 0 kein gültiger Variablenname ist. Extrahieren wir aber 0 aus dem Array/Objekt und überführen es im Destructuring Assignment in einen neuen Namen, klappt es ganz problemlos:

const arr = ["a", "b", "c", "d", "e", "f", "g", "h"];
const { 0: first, 2: third } = arr;
// first = "a", third = "c"

Ich persönlich verwende Array-Destructuring fast nur, wenn die Liste der zu extrahierenden Elementen an Stelle 0 beginnt und verwende ansonsten lieber Objekt-Destructuring. Es liest sich einfach viel sprechender als eine Reihe von Kommata:

function getLanguage(source) {
  const { 1: match = "none" } = /language-(\S+)/.exec(source.className) ?? [];
  return match;
}

In Klartext: wende den regulären Ausdruck an, greife den zweiten Treffer aus dem garantiert nicht null-wertigen (RegExp-) Array heraus, überführe ihn in die Variable match und setze "none" ein, falls es keinen zweiten Treffer gibt. Bäm!

Ob diese konkrete Funktion jetzt wirklich der syntaktische Hauptgewinn ist, sei mal dahingestellt, denn das Sonderzeichen-Rauschen ist vergleichsweise intensiv. Möglicherweise könnte es helfen, den regulären Ausdruck in eine eigene Variable auszulagern, doch egal, was für Feintuning noch möglich wäre: Wir können aus unserer Tour über den Golfplatz zwei, wie ich finde, universelle Erkenntnisse für kommende Programmierabenteuer mitnehmen:

  1. In vielen Fällen kann || den Job von ?? übernehmen, gerade wenn wir uns aus der statischen TypeScript-Welt heraushalten. Auch wenn es oft (und auch in unserem Beispiel mit exec()) wirklich keinen Unterschied macht, finde ich, dass eine bewusste Wahl der Operatoren schon dazu beiträgt, die Intention einer Zeile Code exakt zu kommunizieren.
  2. Dass Arrays am Ende des Tages normale Objekte sind, macht sie zu einem potenziellen Ziel für alle möglichen Objekt-Operationen. Wir können Objekt-Destructuring auf Arrays anwenden und auch Object.assign([,,1], [2]) funktioniert und liefert das erwartete Ergebnis – keine Notwendingkeit für irgendwelche Array-spezifischen Funktionen!

Fest steht aber auch: jeder Gewinn an Lesbarkeit, Produktivität oder Performance löst sich in Rauch auf, sobald man sich nach dem Update von zwei JavaScript-Zeilen zum Schreiben eines ellenlangen Artikels über Code-Golf veranlasst sieht. Trotzdem vielen Dank an die Testleser Andreas, morbidick, Stefan und Frederik!

Das Warum und das Wie von Event Handlern in Web Components

Veröffentlicht am 9. März 2021

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.