Natives GZIP in Browsern, Node und Deno

Veröffentlicht am 24. Januar 2024

Das Komprimieren von mittelgroßen JSON-Payloads ist im Web-Frontend keine so ungewöhnliche Anforderung. Ein ausreichend kleines JavaScript-Objekt kann in eine Base64-Repräsentation eines JSON-Strings verwandelt werden, was es wiederum ermöglicht, Applikationsdaten in URLs vorzuhalten. Auf diese Weise können Webapps Speicher- (via Bookmark) und Sharing-Features (via Copy-and-paste der URL) anbieten, ohne ein komplexes Backend zu benötigen, denn ihre URLs sind die Datenbanken. Beispiele für solche Apps sind Babel-Sandbox und der TypeScript-Playground. Beide speichern ihre Applikations-States in ihren URLs – den Source Code Base64-codiert, den Rest als ganz normale Query-Parameter. Und da ich im Moment etwas ganz Ähnliches vorhabe, machte ich mich auf den Weg, die Implementierungen von Babel- und TypeScript-Sandbox zu erforschen und den State-in-URL-Ansatz 1:1 zu stibitzen. Jedenfalls war das der Plan.

lz-string

Dank Open Source war schnell klar, wie die Babel- und TypeScript-Sandboxes funktionieren: Beide verwenden die extrem gut abgehangene JavaScript-Library lz-string, die mithilfe des ebenfalls extrem gut abgehangenen LZW-Algorithmus JavaScript-Strings komprimiert und die Ergebnisse direkt als URL-kompatibles Base64 ausspuckt. Die Kompression reduziert die Länge der jeweiligen Code-Samples und die Base64-Codierung macht das Endergebnis für URLs besser verdaulich. Im Prinzip können URLs auch Unicode enthalten und laut eines Papers der University of Stack Overflow können URLs im Prinzip sehr sehr lang werden, aber im Sinne der besseren Handhabbarkeit sind Kompression und Base64 schon sehr sinnvoll.

lz-string ist ziemlich großartig. Die Library ist extrem stabil, einfach zu benutzen, dabei trotzdem vielseitig und sie kommt mit exakt null eigenen Dependencies daher. Leider stellte sie sich aber als trotzdem nicht wirklich brauchbar für meine Zwecke heraus … denn der Applikations-State, den ich in URLs unterzubringen gedenke, ist viel größer als der State der Babel- und TS-Sandboxes.

Bei allen Vorzügen hat die lz-string-Library doch eine Eigenschaft, die nicht nur positiv ist: Der LZW-Algorithmus ist über 40 Jahre alt und nicht ganz so leistungsstark wie modernere Verfahren. Also sah ich mich veranlasst, NPM nach Alternativen zu durchwühlen und fand wenig Brauchbares. Abgesehen vom allgegenwärtigen Qualitätslimbo ist eine Grundregel von Datenkompression, dass Verbesserungen Kosten haben. Moderne Algorithmen sind viel komplexer als LZW und bezahlen für ihre besseren Kompressionsraten mit teilweise signifikant längeren Laufzeiten und/oder erheblich größeren JS-Bundles. Für Web-Frontends stellt lz-string offenbar einen ziemlich optimalen Kompromiss dar – dumm nur, dass dieser Kompromiss für meine Pläne schlichtweg nicht genug Kompressionsleistung mitbrachte. Mir grauste es bereits vor der Auseinandersetzung mit Web Workers oder WASM, bis mir per Zufall auffiel, dass ich für mein Vorhaben nicht eine andere JavaScript-Libraray benötigte, sondern einfach gar keine!

Compression Streams API

Alle moderneren Browser (sowie Deno und Node) unterstützen offenbar seit Ewigkeiten die Compression Streams API, mit der wir JavaScript-Autor:innen ohne irgendwelche Dependencies Zugriff auf DEFLATE und die DEFLATE-Wrapper gzip und zlib bekommen! Der Deflate-Algoritmus und seine Wrapper-Formate sind schließlich für HTTP-Kompression ohnehin in jedem Browser vorhanden und eine JavaScript-Durchbindung anzubieten ist nicht die verrückteste Idee. Unbekannt war mir die API trotzdem und leicht zu entdecken war sie in der Flut der mittelmäßigen NPM-Packages und AI-generiertem SEO-Spam auch nicht.

Die API ist noch kein fertiger Webstandard und es gibt diverse offene Fragen und Feature Requests, aber die grundsätzliche Funktionalität ist in allen Browsern und JS-Runtimes verfügbar:

const deflateStream = someStream.pipeThrough(
  new CompressionStream("deflate-raw")
);
const compressedData = new Blob(
  await Array.fromAsync(deflateStream)
);

Zur Erklärung:

  • someStream im obigen Beispiel ist ein ReadableStream, der u. a. aus Responses oder Blobs stammen kann
  • neben deflate-raw (DEFLATE, RFC1951) stehen im Moment nur die DEFLATE-Wrapper-Formate deflate (ZLIB, RFC1950, benannt nach seinem entsprechenden HTTP Content-Encoding) und gzip (GZIP, RFC1952) zur Verfügung. Weitere Formate wie Brotli sind in die Diskussion. Die aktuellen APIs erlauben keine Änderung der Standard-Parameter der diversen Formate, insbesondere können keine Dictionaries angegeben werden (Bug #27)
  • Der Blob compressedData verwendet Array.fromAsync(), um den ReadableStream via Async Iteration zu konsumieren und den Inhalt in ein Array zu überführen.

Um mit der API meinen Use Case der App-State-Kompression umzusetzen, brauchte es noch ein paar weitere Zeilen für das Handling von JSON, Base64 und Unicode, aber nicht besonders viele:

async function compress(inputData) {
  const json = JSON.stringify(inputData);
  const deflateStream = new Blob([json])
    .stream()
    .pipeThrough(new CompressionStream("deflate-raw"));
  let binString = "";
  for await (const bytes of deflateStream) {
    binString += String.fromCodePoint(...bytes);
  }
  return btoa(binString);
}

async function decompress(inputBase64) {
  const binString = atob(inputBase64);
  const inputBytes = Uint8Array.from(binString, (s) => s.codePointAt(0));
  const inflateStream = new Blob([inputBytes])
    .stream()
    .pipeThrough(new DecompressionStream("deflate-raw"));
  let json = "";
  for await (const bytes of inflateStream) {
    json += new TextDecoder().decode(bytes);
  }
  return JSON.parse(json);
}

const appState = { user: "foo@bar.de", password: "hunter2" };

const compressedState = await compress(appState);
// Ergebnis: "q1YqLU4tUrJSSsvPd0hKLNJLSVXSUSpILC4uzy9KAYpnlOaVpBYZKdUCAA"

const decompressedState = await decompress(compressedState);
// Ergebnis:  { user: "foo@bar.de", password: "hunter2" }

Diese Implementierung verzichtet auf das Stand Anfang 2024 noch nicht universell unterstützte Array.fromAsync() sowie auf FileReader. Mit FileReader ist das Lesen von Blobs als Base64 und Text nicht nur asynchron, sondern im Prinzip einfacher, aber mangels Promise-Unterstützung auch nicht gänzlich unumständlich. Außerdem stellt sich schnell heraus, dass wir mit Compression Streams auf den Performance-Bonus des asynchronen FileReader bequem verzichten können.

Compression Streams und lz-string im Vergleich

Für meine Bedürfnisse gewinnt Compression Streams gegen lz-string schon allein dadurch, dass es (de facto) ein Webstandard ist. Zwar ist lz-string als Library ein Traum – klein, einfach zu benutzen, vielseitig, ohne eigene Dependencies – aber null Dependencies sind immer noch besser als eine Dependency. Einen hemdsärmligen Performance-Vergleich habe ich aber trotzdem mal angestellt und meine compress()-Funktion gegen compressToEncodedURIComponent() aus lz-string antreten lassen. Als Inputs dienten:

  • Das Winz-Objekt { user: "foo@bar.de", password: "hunter2" }
  • 23k mit json-generator.com erzeugte Zufallsdaten mit vergleichsweise wenig Redundanzen
  • Ein 14k großes Objekt, das einen App-State von meinem Projekt enthält … mit vergleichsweise vielen Redundanzen, v.a. in den Objekt-Keys

Die Laufzeit-Performance von Compression Streams ist immer um ein Vielfaches besser als die von lz-string. Das soll nicht bedeuten, dass lz-string langsam ist, sondern nur, dass Compression Streams noch schneller sind. Für meinen Use Case der periodischen App-State-Speicherung wäre beides locker ausreichend.

In Hinblick auf die Kompressionsrate schlagen Compression Streams auch immer lz-string, aber der Vorsprung ist nicht immer identisch ausgeprägt. Beim sehr kleinen Objekt ist die Differenz vernachlässigbar, bei den zufälligen Daten sind Compression Streams ca. 30% besser und bei meinem hochredundanten App-State fast 3x besser. Die Struktur des letztgenannten Objekts enthält eine ganze Reihe von Redundanzen und komprimiert daher natürlich besonders gut.

Letztlich sind für mich und meinen Use Case Compression Streams ein klarer Sieger gegen lz-string: Sie sind schneller, besser und ohne Dependencies auf jeder Plattform inkl. der Serverseite verfügbar.

Weitere Alternativen zu Compression Streams

Compression Streams haben in meinen Augen drei nennenswerte Vorteile:

  1. Null Dependencies
  2. Cross-Platform-Verfügbarkeit (alle Browser + Node + Deno)
  3. De-Facto-Webstandard (hohe zu erwartende Stabilität)

Fairerweise muss man aber auch sagen, dass es das wirklich alle Vorteilen sind, die Compression Streams zu bieten haben. Wer sich mit der Installation von Dependencies oder der Beschränkung auf einzelne Plattformen anfreunden kann, kann sich eine ganze Reihe anderer Vorteile ins Haus holen:

  1. Außer DEFLATE und seinen Wrappern haben Compression Streams zurzeit keine weiteren Algorithmen anzubieten. Viel moderne Kompressionsverfahren wie etwa Brotli werden von Libraries wie brotli-wasm unterstützt, aber eben um den Preis von Dependency-Installation und WASM-Gefummel.
  2. Compression Streams bieten z.Z. keine API zum Konfigurieren des DEFLATE-Algorithmus, was abhängig von den zu komprimierenden Daten ein ernsthaftes Hindernis darstellen kann. JS-Implementierungen wie deflate-js haben diesbezüglich deutlich mehr zu bieten.
  3. Wer auf Browser-Unterstützung verzichten kann, findet in den Server-JS-Plattformen moderne und konfigurierbare Kompressions-Algorithmen (z.B. Nodes eingebautem Zlib-Modul) vor.

Um es ganz deutlich zu machen: je nach anstehender Aufgabe können Compression Streams mit ihren Einschränkungen bzgl. Algorithmen und Konfigurierbarkeit komplett unbrauchbar sein. Wer für Megabytes an Game-Assets maximale Kompression benötigt, wird mit einer für ein paar Kilobytes JSON ausreichenden Lösung nicht glücklich werden. Wer ohnehin schon über 9000 Dependencies installiert hat und bereits in den Kessel mit WASM-Zaubertrank gefallen ist, hat keine Nachteile durch die zusätzliche Installation von brotli-wasm. Aber wer einfach nur ein bisschen JSON in URLs oder Local Storage quetschen möchte, ist mit Compression Streams durch den Browser out of the box hervorragend versorgt.

Compression Streams auf dem Stand von Anfang 2024 ist definitiv nicht unter allen Umständen die erste Wahl, aber wie so viele Webstandards eine 80/20-Lösung, die bei nüchterner Betrachtung für viele Anwendungsfälle ausreichend ist. Speziell für den Use Case der in URLs (oder Local Storage) hinterlegten App-States sind Compression Streams völlig okay und sogar in allen Belangen besser als das sonst hierfür so populäre lz-string.

Fragen zu HTML5 und Co beantwortet 28 - getElementsByTagName, neue Elemente, zirkuläre Referenzen, FormData

Veröffentlicht am 3. Januar 2024

Nach drei kurzen Jahren Pause wird es mal wieder Zeit für einen Eintrag im Erfolgsformat „Webentwicklungs-Fragen vom Erklärbär beantwortet“. Es ist nicht so, als hätte es in der Zwischenzeit keine weiteren Fragen von eurer Seite gegeben, aber aus einer Reihe von Gründen konnten diese nicht in Artikel überführt werden. Damit ist jetzt Schluss! Und wenn ihr mein Backlog weiter gefüllt halten möchtet, könnt ihr mir eure Fragen zu Frontend-Themen aller Art wie gewohnt per E-Mail oder Fediverse übersenden.

Was ist so schlimm an getElementsByTagName()?

Du hast in einem Podcast mal erwähnt, dass man getElementsByTagName() nicht benutzen sollte. Warum? Ist es die Spezifikation selbst oder geht es um Performance-Probleme?

Es gibt im Wesentlichen zwei Argumente, die gegen die Benutzung von getElementsByTagName() sprechen: das Vorhandensein einer mächtigeren Alternative und tatsächlich zumindest mögliche Performance-Probleme.

Zum einen gibt nicht wirklich einen Grund, getElementsByTagName() zu verwenden, wenn wir querySelectorAll() als Alternative zur Verfügung haben. Alles, was getElementsByTagName() kann, kann querySelectorAll() auch, plus eine ganze Menge mehr – eben Elemente anhand von mehr als nur ihres HTML-Tags auswählen. Das größere Problem ist aber tatsächlich die Performance und ein etwas unintuitives Verhalten.

getElementsByTagName() liefert eine HTMLCollection mit den selektierten Elementen als Inhalt. Eine HTMLCollection sieht zwar aus, als wäre sie einfach nur ein weiteres der zahllosen (und harmlosen) Listen-Objekte im DOM, ist aber in aller Regel live. DOM-Manipulationen, die nach getElementsByTagName() stattfinden, verändern also das zuvor ermittelte Ergebnis und sorgen auf der Performance-Seite für entsprechenden Mehraufwand. Und intuitives Programmieren sieht auch anders aus!

// Ausgangslage: <div class="a"></div&ht;<div class="b"></div>
const divs = document.getElementsByTagName("div");
console.log(divs.length, divs[0], divs[1]); // > 2, div.a, div.b
document.body.insertAdjacentHTML("afterbegin", '<div class="x"></div>');
console.log(divs.length, divs[0], divs[1]); // > 3, div.x, div.a - WTF!

Wenn selbst die DOM-Spezifikationen HTMLCollection als „a historical artifact we cannot rid the web of“ bezeichnen, ist es vielleicht wirklich an der Zeit, diesen Objekt-Typ und dazugehörige APIs wie getElementsByTagName() nicht mehr zu verwenden.

Neue HTML-Elemente erfinden, ohne sie zu registrieren?

Mir ist aufgefallen, dass ich ein neues HTML-Tag benutzen und auch per CSS gestalten kann, ohne es als Web Component zu registrieren. Alles Wichtige scheint zu funktionieren. Kann es sein, dass so eine spontane Erstellung von Elementen erlaubt ist? Und wenn ja, warum verwenden wir dann überhaupt noch Klassen und IDs? Kann ich nicht einfach statt <div class="mainbox"> gleich <main-box> schreiben?

Dass der Browser angesichts eines unbekannten HTML-Tags nicht mit einer Fehlermeldung abstürzt, sondern versucht, das Beste daraus zu machen, ist ein Fehlerbehandlungsmechanismus. Und das erklärt auch, warum wir unregistrierte HTML-Elemente nicht benutzen sollten: es ist besser, keine Fehler zu machen, als den Browser unsere Fehler ausbügeln zu lassen.

Auf technischer Ebene wir das unbekannte Element vom Browser als HTMLUnknownElement verarbeitet, das für unbekannte Elemente in etwa den Funktionsumfang eines Span-Elements bereitstellt. Klassen, IDs, CSS, Data-Attribute, all das funktioniert in allen gängigen Browsern auf unbekannten Elementen. Sofern der verwendete Tag-Name einen Bindestrich enthält, ist ein solches nicht-angemeldetes unbekanntes Element sogar einigermaßen zukunftssicher, da sich der Tag im geschützten Namensraum für benutzerdefinierte Elemente befindet – es wird also nie ein neues natives Element gleichen Namens eingeführt werden.

Trotzdem ist und bleibt das HTMLUnknownElement ein reiner Fehlerbehandlungsmechanismus, den wir nicht absichtlich einsetzen sollten: nicht nur sehen Webentwickler, die ungültige Elemente verwenden, wie schlampige Handwerker aus, es wäre auch extrem unkompliziert, das Element korrekt zu registrieren:

window.customElements.define(
  "tipp-box",
  class extends HTMLElement {}
);

Das ist so wenig Aufwand, dass nicht wirklich etwas dagegen spricht, es richtig zu machen. Und sollte sich eines Tages herausstellen, dass das Element ein paar Extras wie eingebaute ARIA-Rules benötigt, ist die Klasse, an die diese Extras angebaut werden können, auch bereits vorhanden.

Unregistrierte Elemente fallen in die bei HTML sehr umfangreiche Kategorie „ist nicht erlaubt, funktioniert aber“. Dort befinden sich unter anderem auch Framesets, die auch niemand (mehr) verwendet, obwohl es rein technisch möglich wäre und auch immer möglich bleiben wird.

Bester Weg, zirkuläre Objektstrukturen zu erkennen?

Wie kann ich am besten herauszufinden, ob eine Property eines Objekts eine Referenz auf das eigene Objekt oder Teile des eigenen Objekts besitzt? Ich verlasse mich bisher darauf, dass JSON.stringify() bei zirkulären Objekten einen Fehler wirft, aber vielleicht gibt es ja einen besseren Weg?

Das Wichtigste vorweg: JSON.stringify() wirft zwar tatsächlich Fehler bei zirkulären Objektreferenzen, hat aber auch große blinde Flecken in Form von nicht-JSON-kompatiblen Objekten:

const x = {
  a: new Map(),
};

// Böse zirkuläre Referenz
x.a.set("foo", x);

// Aber kein Fehler: JSON.stringify macht Maps zu {}
JSON.stringify(x);

JSON ist lange vor der Einführung von Maps und JavaScript definiert worden und kann daher mit Maps nichts Sinnvolles anstellen. Stattdessen verwandelt es Maps unter Missachtung ihres Inhalts einfach in {}, wodurch zirkuläre Referenzen unentdeckt bleiben. Andererseits wirft JSON.stringify() aber auch gerne Fehler bei Objekten ohne zirkuläre Referenzen:

// Fehler ohne zirkuläre Referenzen: JSON mag kein BigInt
JSON.stringify({ x: 42n }); // Error!

Wir können JSON.stringify() also durchaus nutzen, um einige Arten von zirkulären Referenzen zu erkennen, aber es ist nicht für alle Use Cases geeignet. Dort, wo JSON.stringify() nicht taugt, könnten wir manuell den Objekt-Tree nach zirkulären Referenzen absuchen, aber auch dann gibt es diverse Edge Cases, für die es nicht immer eine eindeutige beste Lösung geben wird:

  • Sollten Properties, die Symbols oder Non-Enumerable sind, ebenfalls untersucht werden?
  • Objekte könnten privaten State (via privaten Klassenfeldern oder Closures) haben, die zirkuläre Referenzen halten könnten … was für uns aber nicht feststellbar wäre.
  • Objekt-Getter könnten nichtdeterministische Ergebnisse liefern uns sich so der Zirkularitätsfrage entziehen.

Was als der wirklich beste Weg ist, hängt letztlich davon ab, ob und wie wir diese Edge Cases berücksichtigen wollen. Möglicherweise ist JSON.stringify() schon der beste und praktikabelste Kompromiss!

Wie kann ich ein Formular absenden, ohne ein Formular haben zu müssen?

Ich möchte meinem Server vorspielen, jemand hätte ein Formular ausgefüllt und abgeschickt. Wie kann ich das nur mit JavaScript, ohne <form>-Elemente erreichen?

FormData hilft! Einfach ein FormData-Objekt erstellen, mittels append() Daten (Key-Value-Paare) einfügen und dann via fetch() versenden:

const fd = new FormData();
fd.append("foo", "23");
fd.append("bar", "42");
fetch("/", {
  method: "POST",
  body: fd
});

Dass diese Möglichkeit besteht, ist auch tatsächlich nicht schlecht. Für normale User Interfaces ist natürlich ein normales, echtes Formular immer das Mittel der Wahl, denn <form>-Elemente liefern Features wie Barrierefreiheit, Formularvalidierung und JS-Ausfall-Sicherheit zum Quasi-Nulltarif mit. Selbst für abgefahrenste JavaScript-Vorhaben ist es immer besser, ein normales Formular anzulegen und sein Verhalten mit eigener Logik zu ergänzen (z. B. per abgefangenem submit-Event), statt das Rad mit JS von 0 neu zu erfinden.

Aber es gibt auch einige wenige Umstände, unter denen eine reine JS-Lösung das einzig verfügbare Mittel ist, wie etwa Web Worker. Fetch-API und FormData sind auch in Worker Scopes verfügbar, <form>-Elemente hingegen nicht. In diesem Fall führt also kein Weg an einer reinen JavaScript-Lösung vorbei. Gleiches gilt, wenn wir versuchen, ein Form-Associated Custom Element zu bauen, das mehr als einen Value repräsentieren soll (vergleichbar mit einem <select multiple>).

Weitere Fragen?

Habt ihr auch dringende Fragen zu Frontend-Technologien? Nur her damit! Alle Fragen in diesem Post wurden mir per E-Mail oder Fediverse gestellt und ihr könnt das genau so machen! Einfach über einen der genannten Kanäle anschreiben oder gleich das komplette Erklärbären-Paket kommen lassen.

Unsortierte Erkenntnisse zu Formular-Elementen mit Custom Elements, Teil 1 von N

Veröffentlicht am 12. Dezember 2023

In der Theorie können wir Webentwickler:innen mit Custom Elements alles erreichen, was der Browser mit nativen Elementen schafft. Eigene Tags, eigene Attribute, eigene Events, Kapselung mit Shadow DOM – an Werkzeugen herrscht kein Mangel. Selbst eigene Formular-Inputs sind machbar, aber wer auf Mastodon folgt, hat über diverse unzusammenhängende Posts mitbekommen, dass das gar nicht mal so einfach ist. Web-Formulare sind an sich schon kompliziert genug, aber eigene Formular-Elemente zu entwerfen, ist wahrlich noch ein dickes Stück komplexer. In diesem Artikel fasse ich ein paar der Dinge, die ich bis dato zu diesem Thema gelernt habe, etwas weniger unzusammenhängend als auf Mastodon zusammen. Weitere Artikel könnten folgen.

Begrifflichkeiten und Ziele

Das Ziel meiner Experimente ist, mit Custom Elements neue Formularfelder zu bauen, die im Hinblick auf Features, Verhalten und APIs den nativen Elementen in nichts nachstehen. Abstriche in auch nur einer dieser Kategorien zu machen, ruiniert die Kompatibilität der resultierenden Elemente. Nur Custom Elements, die sich 1:1 wie native Elemente verhalten, sind für sowohl für den Einsatz in handgeschriebenem HTML als auch in fetten Frontend-Frameworks als auch für Vanilla-DOM-Programmierung geeignet … und wenn diese Kompatibilität zu all dem nicht das Ziel ist, sollte man statt einer Web Component einfach eine Fette-Frontend-Framework-Komponente schreiben und sich sehr, sehr viel Arbeit ersparen. Keine Kompromisse!

Zuletzt möchte ich noch ein paar nicht offensichtliche Begriffe definieren, die ich im Folgenden zu Felde führen werde:

  • CFI: Custom Form Input, ein von mir soeben erfundener Begriff für Custom Elements, die im Allgemeinen form associated sind, im Speziellen den Use Case des Eingabefelds bedienen und vor allem die o.g. Kompatibilitäts-Ziele verfolgen.
  • Content-Attribut: via HTML oder setAttribute() gesetzter Attribut-Wert. Immer ein String und tritt praktisch immer zusammen mit einem entsprechenden IDL-Attribut auf.
  • IDL-Attribut: Per JavaScript Getter/Setter-Paar definierte API für ein Attribut. Kann andere Typen als string haben und tritt praktisch immer zusammen mit einem entsprechenden Content Attribut auf.
  • Constraint Validation: Auch als „HTML5-Validierungs-API“ bekannte eingebaute Validierungs-Features für Formulare.

Und nun stürzen wir uns einfach direkt in die nicht mehr ganz so unzusammenhängende Sammlung von Erkenntnissen zu Formular-Elementen, Teil 1 von N!

API-Boilerplate

Eine Custom-Element-Klasse kann sich per static formAssociated = true zum Form-Associated Element erklären und damit in den Club der Formular-Elemente <input>, <fieldset> und Co eintreten. Per attachInternals() erhalten wir Zugriff auf APIs für den Formular-Element-Zustand (v.a. setFormValue()). Wichtig hierbei ist, dass die Element Internals neben solchen privaten APIs auch allerlei öffentliche APIs enthalten: die checkValidity()-Methode, der form-Getter und alles Sonstige, was man an JS-APIs auf Inputs, Selects und Textareas so erwartet. Diese APIs muss eine CFI-Klasse mauell bereitstellen, in etwa wie folgt:

class CFI extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();

  get labels() {
    return this.#internals.labels;
  }

  get form() {
   return this.#internals.form;
  }

  get willValidate() {
    return this.#internals.willValidate;
  }

  get validity() {
    return this.#internals.validity;
  }
  
  get validationMessage() {
    return this.#internals.validationMessage;
  }
  
  checkValidity() {
    return this.#internals.checkValidity();
  }

  reportValidity() {
    return this.#internals.reportValidity();
  }
}

Das implementiert alle JavaScript-Features, die man auf Formular-Inputs standardmäßig erwarten darf – abgesehen von jenen, die Wechselwirkungen mit Content- und IDL-Attributen haben, wie z. B. required, value und name. Und wo wir gerade bei name sind …

Formulare absenden

Damit ein CFI von einem Formular mit abgesendet werden kann, braucht es lediglich ein Content-Attribut name und absendbare Daten („submission value“). Letzteres kann über die setFormValue()-Methode der Element Internals definiert werden und ersteres benötigt streng genommen keinen IDL-Gegenpart und daher keine wirkliche Implementierung. Die minimale absendbare CFI-Klasse sieht also wie folgt aus:

class CFI extends HTMLElement {
  static formAssociated = true;
  constructor() {
    super();
    this.attachInternals().setFormValue("foobar");
  }
}

Die funktionierende Demo zeigt, dass es nicht nötig ist, name ordentlich (d. h. mit IDL-Attribut) zu definieren, aber im Rahmen der für uns definierten Ziele würde dieser Aufwand natürlich schon anfallen.

value State und Attribute

Wie gesehen wird der eigentliche Formularfeld-Wert über die setFormValue()-Methode der Element Internals festgelegt, wobei jedoch die Content- und IDL-Attribute value auch eine Rolle spielen. Der grobe Zusammenhang sieht wie folgt aus:

  1. Der form value initialisiert sich aus dem Content-Attribut value (sofern vorhanden). Standardwert ist der leere String.
  2. Ein dirty flag initialisert sich false.
  3. Der IDL-Getter value reflektiert den aktuellen form value, der IDL-Setter value setzt den aktuellen form value, aber nicht das Content-Attribut value.
  4. Solange das dirty flag false ist, führen Updates des Content-Attributes value zu Updates des form value. Wenn das dirty flag true ist, passiert das nicht mehr, denn getätigte Nutzereingaben sollten selbst bei sich veränderndem DOM erhalten bleiben.
  5. Das dirty flag wird true, sobald der IDL-Setter verwendet wird oder ein Nutzer auf eine Weise mit dem Input interagiert, die den form value verändert.
  6. Form-Resets (siehe unten) setzten das dirty flag zurück auf false und den form value auf den Wert des Content-Attributs value (sofern vorhanden). Standardwert ist auch hier der leere String.

Das dirty flag in der obigen Beschreibung stammt direkt aus den Spezifikationen und ist nicht mit dem user interacted flag zu verwechseln: letzteres erfährt beim Form-Reset selbst keinen Reset und ist ausschließlich dafür zuständig, dass die Pseudoklassen :user-valid und :user-invalid korrekt greifen.

Deaktivierung und Form-Resets

Standard-<input> und Co können mithilfe des boolschen disabled-Attribut (Content- und IDL-Attribut) deaktiviert werden, was für unsere Custom Elements genau so gelten sollte. Eine Deaktivierung kann aber auch ausgelöst werden, indem auf dem nächstgelegene Vorfahren-<fieldset> (sofern vorhanden) dessen disabled-Attribut (als Content- oder IDL-Attribut) gesetzt wird! Für Custom Elements bedeutet das: Der tatsächliche Aktiviertheits-Zustand eines CFI ergibt sich aus dem eigenen disabled-Attribut und dem disabled-Zustand des nächstgelegenen relevanten Fieldsets. Änderungen am letztgenannten Zustand sind über den Lifecycle-Callback formDisabledCallback() beobachtbar.

Der Aktiviertheits-Zustand eines Formularfelds ist nicht mit dem Mutability-Zustand zu verwechseln. Letzterer wird ausschließlich vom readonly-Attribut des betroffenen Formularfelds gesteuert und belässt das Element, anders als der Aktiviertheits-Zustand, validierbar und absendbar.

Einen Lifecycle-Callback gibt es auch für Formular-Resets. Dieser Tage sind Formular-Reset-Buttons nicht in Mode, aber sie können vorkommen! CFI sollten daher via formResetCallback() einen eigenen Reset-Algorithmus implementieren, der mindestens den formValue (auf den Wert des Content-Attributes value) und das dazugehörige dirty flag (auf false) zurücksetzt.

Formularvalidierung

Mit der setValidity()-Methode von Element Internals können wir unsere CFI im Rahmen der guten alten Contraint Validation API als ungültig ausgefüllt markieren. Das kann auf verschiedene Weisen nützlich sein, für die die drei möglichen Parameter von setValidity() gezielt jongliert werden wollen.

Parameter 1, validity, ist eine Sammlung von mit ValidityState-Objekten übereinstimmenden Flags, die die typischen Validierungsfehler anzeigen (valueMissing, tooLong etc.). Der Parameter bestimmt, wenig überraschend, den neuen ValidityState des CFI. Nicht angegebene Flags werden per Default auf false gesetzt, d. h. die entsprechenden Fehler liegen dann nicht vor.

Parameter 2, message, ist die anzuzeigende Fehlermeldung als String. Er muss (als nicht-leerer String) angegeben werden, wenn Parameter 1 mindestens einen Flag auf true setzt, kann aber weggelassen oder auf einen leeren String gesetzt werden, wenn kein Fehler vorliegt. Zusammen mit Parameter 1 ist es so ein Leichtes, setCustomValidity() für CFI zu implementieren (Demo):

class CFI extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();
  setCustomValidity(msg) {
    this.#internals.setValidity({ customError: true }, msg);
  }
  get validationMessage() {
    return this.#internals.validationMessage;
  }
}

Parameter 3, anchor, hat einen extrem spezifischen, aber auch extrem nützlichen Use Case. Wenn unser CFI in seinem Shadow DOM andere Form-Associated Elements enthält und primär als Wrapper um diese Elemente fungiert (z. B. im Rahmen einer Pattern Library), muss der Validierungs-State des CFI den Validierungs-State der gewrappten Elemente widerspiegeln. Das ist nötig, da die Elemente im Shadow DOM vom Formular des CFI isoliert sind und somit selbst gar nicht der Constraint Validation unterliegen. Die Elemente können aber schon dafür genutzt werden, Validierungsfehler anzuzeigen, bei Auftreten eines Fehlers Fokus zu erhalten und ganz allgemein ist es im genannten Szenario korrekt, wenn die Verursacher-Elemente ihr Standard-Fehlerverhalten zeigen und das Wrapper-Element nur das Bindeglied zwischen Formular und Shadow DOM spielt.

In Konsequenz bedeutet das: wenn ein CFI ein Wrapper um andere Inputs ist und Validierungsfehler aus den gewrappen Inputs stammen, können wir mit dem anchor-Parameter den Validierungs-State des CFI als Fehler auf dem gewrappen Input anzeigen. Das ist z. B. im Pattern-Library-Szenario auch genau, was wir wollen. Umgekehrt folgt daraus, dass wenn das CFI kein Wrapper um andere Inputs ist, es keinen anchor und damit auch keine Standard-UX für Validierungsfehler-Anzeige gibt. Solche Non-Wrapper-CFI müssten hierfür eine ganz eigene UX erfinden.

Zwischenstand und Ausblick

Die in diesem Post gesammelten Erkenntnisse konnte ich in einem @formElement-Decorator so zusammenfassen, dass es damit (und mit Ornament) einigermaßen einfach möglich wird, simple Wrapper-CFI zu definieren. Die folgenden Zeilen implementieren eine Abstraktion über ein <input type="number">, die nur ganze Zahlen zulässt, aber u. a. required und disabled weiter als Attribute unterstützt:

import { render, html } from "uhtml";
import { define, attr, int, bool, reactive, debounce } from "@sirpepe/ornament";

@define("int-input")
@formElement()
export class IntInput extends HTMLElement {
  #root = this.attachShadow({
    mode: "closed",
    delegatesFocus: true
  });

  @attr(bool()) accessor required = false;
  @attr(int({ nullable: true })) accessor min = null;
  @attr(int({ nullable: true })) accessor max = null;

  @reactive()
  @debounce({ fn: debounce.raf() })
  render() {
    render(
      this.#root,
      html`
        <input
          value="${this.defaultValue}"
          min=${this.max ?? ""}
          min=${this.max ?? ""}
          step="1"
          type="number"
          ?disabled=${this.disabledState}
          ?required=${this.required} />`
    );
  }
}

Für das, was es tut, ist das fast eine akzeptable Code-Menge! Immerhin leisten diese paar Zeilen:

  1. Definition eines neuen Form-Associated Custom Element
  2. Implementierung der öffentlichen Standard-Formular-APIs (setCustomValidity() etc.)
  3. Implementierung der öffentlichen Standard-Formular-Attribute name, disabled und value (mit Content- und IDL-Attributen) mit dazugehörigem Verhalten bei Resets und deaktivierten Fieldset-Vorfahren
  4. Implementierung der zusätzlichen Attribute required, min, und max (mit Content- und IDL-Attributen, wobei min und max sich sogar die Mühe machen, ihre States als BigInt zu repräsentieren)
  5. Durchschleifen von Value- und Validity-Updates des gewrappten Inputs an den Wrapper, Meldung der Validity-Errors mit dem gewrappten Input als anchor

Die Dependency-Kosten belaufen sich allein auf die <4k von Ornament (theoretisch optional, würde aber die Komplexität des Rests explodieren lassen), die Rendering-Engine uhtml (theoretisch könnte auch jeder andere Render-Mechanismus verwendet werden) und die 250 Zeilen Form-Decorator.

Das ist ein brauchbarer Zwischenstand, aber gibt es noch viel mehr zu erforschen: CFI könnten mehr als nur ein Form-Element wrappen, was ein komplexeres Value-Composing notwendig machen würde. Um diverse Lifecycle-Events wie formStateRestoreCallback habe ich mich noch gar nicht gekümmert und das leidige Thema der TypeScript-Kompatibilität steht auch noch im Raum. Dazu (und zu bestimmt noch vielen anderen Features und Randaspekten) dann mehr in folgenden Artikeln.

Ornament - Eine Web-Component-Microlibrary

Veröffentlicht am 15. November 2023

Die Krux mit der Komplexität moderner Webanwendungen ist, dass die Vergangenheit vorbei ist und wir das Rad der Zeit nicht zurückdrehen können. Es ist nicht sinnvoll, einfach React, Vue, Angular und Co. komplett links liegenzulassen und wieder Webapps mit jQuery bauen, denn obwohl jQuery selbst noch genau wie früher funktioniert, funktioniert die Welt nicht mehr wie früher. Es arbeiten größere Teams an größeren Projekten mit größerer Komplexität und vor allem ist jQuery auf eine ganz bestimmte Weise überflüssig geworden. In jQuerys Selbstbeschreibung heißt es:

“[jQuery] makes things like HTML document traversal and manipulation, event handling, animation, and Ajax much simpler with an easy-to-use API that works across a multitude of browsers.”

Das Problem ist: Die easy-to-use API löst kein akutes Problem mehr! Entwickler:innen schweben heute entweder mit Frontend-Frameworks auf All-Inclusive-Abstraktionsebenen, oder sie schreiben ganz einfach Vanilla JS. DOM-Traversal, Fetch-API sowie CSS- und JavaScript-Animationen gibt es nativ in jedem Browser und auch das Problem der Cross-Browser-Kompatibilität ist längst nicht mehr in dem Maße existent, wie es in den frühen 2000ern der Fall war. Die Plattform bietet mittlerweile selbst alle Features, die früher von jQuery, Mootools und ähnlichen Lückenfüller-Libraries geschlossen werden mussten.

Das Web-Component-Problem

Das trifft jedenfalls zu, wenn es um die normale DOM-Programmierung angeht. Im Reich der Web Components sind die nativen APIs sowohl konzeptionell knifflig als auch unvollständig. Web Components mit Vanilla JS zu entwickeln ist fast genauso lästig, wie anno 2004 JS ohne jQuery zu schreiben! Eine kleine Auswahl an Ärgernissen gefällig?

  • Der Element-Upgrade-Prozess ist ab Werk fehleranfällig und die daraus resultierenden Probleme sind schwierig zu diagnostizieren.
  • Lifecycle-Callbacks wie connectedCallback() und adoptedCallback() sorgen dafür, dass Funktionalität über viele Klassenmethoden verstreut wird. Aufrufe von Update-Methoden müssen ggf. in viele verschiedene Callbacks eingebaut werden, was unübersichtlich ist und die Wartung erschwert.
  • Nur der attributeChangedCallback() hat das genau gegenteilige Problem: Das Handling aller Attribute ist in ihm zusammengefasst, was ihn lang und kompliziert werden lässt. Andererseits feuert er nur für Attribute, die in den observableAttributes vorgemerkt wurden, deren Update leicht vergessen werden kann.
  • Attribut-Handhabung ist ganz allgemein entsetzlich aufwendig. Ein gutes Attribut auf einem HTML-Element hat eine JavaScript-API und ein HTML-Attribut, und beide Parts müssen sauber synchronisiert sein. Dazu müssen Entwickler:innen einen privaten State und ein Getter-Setter-Paar und Attribut-Handling via attributeChangedCallback()/observedAttributes ausprogrammieren, was schon in simplen Fällen in Spaghetticode ausartet.
  • Jenseits der Komplexität fehlt es beim Attribut-Handling an eingebauten Primitives. Obwohl in der HTML-Plattform konzeptionell Dinge wie „String-Attribut“ und „Boolean-Attribut“ vorhanden sind (in Form von u. a. id und disabled), können wir bei Web Component auf diese in den Browser-Interna schon vorhandene Logik nicht zurückgreifen. Stattdessen dürfen wir uns selbst überlegen, wie genau wir Attribut-Strings in andere Datentypen parsen – für jedes Attribut von vorn.

All diese Punkte stellt keine Kritik an der grundsätzlichen Philosophie von Web Components und ihrer APIs dar. Es ist in Ordnung, dass Webentwickler:innen ihre eigenen HTML-Elemente mit einer JavaScript-Klasse definieren können. Imperative Programmierung und OOP (in geringer Dosierung) sind vertretbare Techniken der Software-Entwicklung. Die aktuellen APIs sind nur sehr, SEHR umständlich und sehr, SEHR lückenhaft – so umständlich und lückenhaft, dass sie der Entwicklung der eigentlichen Komponenten im Wege stehen. Selbst die simpelste Komponente resultiert, wenn mit Bordmitteln geschrieben, in komplett inakzeptablem Spaghetticode:

class CalculatorWidget extends HTMLElement {  
  // Private Zustände "a" und "b" mit Initialisierung aus den HTML-Attributen
  #a = Number(this.getAttribute("a"));
  #b = Number(this.getAttribute("b"));
  
  // Öffentliche Get-API für "a"
  get a() {
    return this.#a;
  }
  
  // Öffentliche Set-API für "a". Typchecks, Attribut-Update und Render-Aufruf nicht vergessen
  set a(value) {
    if (typeof value !== "number") {
      throw new TypeError("Must be number");
    }
    if (value !== this.#a) {
      this.#a = value;
      this.setAttribute("a", this.#a);
      this.#render();
    }
  }

  // Öffentliche Get-API für "b". Genau wie für "a", nur mit anderen Namen.
  get b() {
    return this.#b;
  }
  
  // Öffentliche Set-API für "b". Genau wie "a", nur mit anderen Namen, aber den
  // genau gleichen Typchecks, Attribut-Updates und Render-Aufrufen.
  set b(value) {
    if (typeof value !== "number") {
      throw new TypeError("Must be number");
    }
    if (value !== this.#b) {
      this.#b = value;
      this.setAttribute("b", this.#b);
      this.#render();
    }
  }
  
  // Verarbeitung von Attribut-Updates für "a" und "b"
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "a") {
      this.#a = Number(newValue);
    }
    if (name === "b") {
      this.#b = Number(newValue);
    }
  }
  
  // Nicht vergessen!
  get observedAttributes() {
    return ["a", "b"];
  }
  
  // Update, wenn das Element eingehängt wird
  connectedCallback() {
    this.#render();
  }
  
  // Anzeige des Ergebnisses, muss bei jeder Änderung von a oder b aufgerufen werden
  #render() {
    this.innerHTML = this.#a + this.#b;
  }
}

window.customElements.define("calculator-widget", CalculatorWidget);

Das ist deutlich zu viel Code für eine Komponente, die zwei Attribute als Zahlen interpretiert und addiert! Es fehlt an nichts Fundamentalen, denn die Plattform kann im Prinzip alles, was wir benötigen. Nur die Reibungsverluste der in den nativen APIs verwendeten Sprachmittel sind in einem Maße aus dem Ruder gelaufen, dass die Entwicklung von Vanilla Web Components nahezu unmöglich wird.

Ein jQuery für Web Components

Genau diesem Problem widmet sich Ornament, eine kleine Library für bessere Web-Component-APIs. Ornament ist kein Framework, sondern besteht nur aus ein paar Funktionen, die die genannten Problemzonen (und ausschließlich diese) von Web Components verbessern. Die 60 Zeilen Attribut-Albtraum von zuvor verwandeln sich mit Ornament in ca. 10 SLOC:

import { define, attr, number, reactive } from "@sirpepe/ornament";

// Regisiert die Klasse als Custom Element
@define("calculator-element")
class CalculatorElement extends HTMLElement {
  // Das komplette Attribut-Handling für "a" in einer Zeile
  @attr(number()) accessor a = 0;

  // Das komplette Attribut-Handling für "b" in einer Zeile
  @attr(number()) accessor b = 0;

  // Ruft die Methode "#render" auf, wenn sich "a" oder "b" ändern, plus einmal
  // bei der Initialisierung
  @reactive()
  #render() {
    this.innerHTML = this.a + this.b;
  }
}

Der Schlüssel zur Kürze ist die neue Decorators-Syntax (Dr. Axel erklärt), mit der sich Plugins ganz einfach an Klassenelemente herandeklarieren lassen. Decorators stehen kurz vor der Standardisierung und sind bereits heute mit Babel und TypeScript einsetzbar.

Unter der Haube verdrahtet Ornament lediglich, wie jQuery damals, ein paar APIs neu und existiert problemlos neben handgeschriebenem Vanilla-JavaScript. Wer mag, kann Ornament mit selbstgestrickten attributeChangedCallback() kombinieren! Mit einem treeshake-freundlichen zulässigen Höchstgewicht von unter 4k und allgemein minimalem Umfang sowie einfacher Migrations-Strategie ist die Library kein besonders großes Dependency-Risiko. Der z.Z.noch für den Decorators-Support nötige Build-Schritt wird überflüssig werden, sobald die Browser Decorators nativ unterstützen.

Kein weiteres Frontend-Framework!

Ornament ist kein Frontend-Framework und macht deswegen keinerlei Vorgaben in Hinblick auf App-Architektur, Template-Syntax, State-Management oder die vielen anderen Dinge, die von Frontend-Frameworks üblicherweise mitgeliefert werden. Das ist auch gar nicht das Ziel von Ornament. Wie jQuery soll Ornament ein zielgerichtetes Upgrade für die Developer Experience sein, das problemlos neben Vanilla-JavaScript und anderen Libraries existiert. Auf diese Weise können alle, die Bedarf an App-Architektur, Template-Syntax und State-Management haben, sich ihren eigenen Stack zusammenbasteln.

Wer kein Interesse an einem eigenen Stack hat, sondern einfach nur bunte Boxen in den Browser zu rendern gedenkt (und keine Probleme damit hat, sich an Dependencies zu ketten), ist bei einem der klassischen Frontend-Frameworks oder einem Web-Component-Framework wie Lit bestens aufgehoben. Wer aber auf Vanilla Web Components aufbauend etwas Eigenes, neues erschaffen möchte, kann sich mit Ornament das Leben erheblich vereinfachen … zumindest so lange, bis die Browser ihre diversen Probleme in den Griff bekommen und Ornament überflüssig machen. Denn anders als „richtige“ Frameworks wird Ornament, wenn sich die Webstandards rund um Web Components wie bisher weiterentwickeln, bald genauso überflüssig werden wie jQuery. Die Browser werden hoffentlich in Zukunft bessere APIs entwickeln und bessere Features für Attribut-Handling anbieten, als es im Moment der Fall ist.

Ich habe Ornament gebaut, weil mit den aktuellen APIs das Experimentieren an Web Components fast unmöglich ist. Jeder Versuchsaufbau besteht zu 90 % aus Boilerplate und Lifecycle-Callback-Verdrahtung, was so viel mentale Kapazität bindet, dass das eigentliche Experiment in den Hintergrund tritt. Wenn ich aber nun Attribute und Updates einfach per @-Deklaration an einzeilige Klasenkonstrukte herangebasteln kann, wird sehr viel Overhead eingespart und ich kann mich auf die eigentliche Aufgabe konzentrieren. Ein „richtiges“ Web-Component-Framework hilft hier auch nicht, denn Frameworks bringen immer ihre eigenen Abstraktionen und Konzepte mit, die nicht Gegenstand meiner Experimente sind ‐ ich möchte gern wissen, wie Web Components funktionieren, und nicht, was z.B. Lit macht. Deshalb macht Ornament nichts weiter, als ein wenig API-Streamlining zu betreiben, so dass ich entweder einfach meinen Versuch schreiben oder auf Ornament als Basis ein kleines Ad-hoc-Miniframework zusammenstricken kann. Es ist ganz wie früher mit jQuery: Write less, do more! Bis Ornament hoffentlich, früher als später, nicht mehr gebraucht wird.