TIL-Roundup Januar 2024: TextEncoder, @import, type-Attribute

Veröffentlicht am 13. Februar 2024

Ich nutze meine Mastodon-Präsenz vor allem, um dumme Fragen zu stellen und laut nachzudenken. Fragenstellen und Nachdenken führt zu Erkenntnisgewinn (in meinem Fall meist in Sachen Browserbugs und Webstandards) und dieser Artikel fasst meine Erkenntnis-Highlights aus dem Januar in etwas organsierterer Form zusammen. Top-Fundstück des Monats waren definitiv die bereits in einem eigenen Artikel verarbeiteten CompressionStreams, aber der Rest kann sich auch sehen lassen!

TextEncoder und TextDecoder

Mir war neu, dass alle Browser unter der Sonne TextEncoder und TextDecoder unterstützen. Der Decoder schluckt Bytes und produziert Strings, der Encoder macht das Gegenteil:

const utf8bytes = new TextEncoder().encode("????");
// > Uint8Array(4) [ 240, 159, 164, 161 ]
const string = new TextDecoder().decode(utf8bytes);  
// > "????"

Der Decoder kann natürlich auch mit mehr als UTF-8 umgehen und lief mir beim Austüfteln von CompressionStreams über den Weg.

Kein @import im CSSStyleSheet()-Constructor (und CSS-Modulen)

Ein Ansatz für CSS in Shadow DOM besteht darin, mit new CSSStyleSheet() Stylesheets aus heißer Luft zu erzeugen und diese einem ShadowRoot (oder Document) zuzuweisen:

const host = document.querySelector("div");
const root = host.attachShadow({ mode: "open" });
root.innerHTML = `Hello`;
const sheet = new CSSStyleSheet();
sheet.replaceSync(":host { color: red }"); // async-Alternative: replace()
root.adoptedStyleSheets.push(sheet);

Die in diesem Stylesheet enthaltenen Regeln gelten dann ausschließlich in den ShadowRoots oder Dokumenten, deren adoptedStyleSheets sie zugewiesen wurden. Zwar handelt es sich hierbei um grundsätzlich zweifelhafte JS-Hexerei auf CSS-Territorium, aber es mangelt nicht an Vorteilen:

  • Web Components können ihre eigenen kleinen CSS-Dateien haben (anstelle von Strings in JavaScript)
  • Wer sich etwas Mühe gibt, Memory Leaks zu umgehen, kann ein CSSStyleSheet-Objekt über mehrere Komponenten-Instanzen teilen
  • Unter Zuhilfenahme von Build-Tools kann CSS zur Compile-Zeit ins JavaScript gebundlet werden, falls das gewünscht ist

Allerdings musste ich feststellen, dass @import in mit new CSSStyleSheet() und CSS-Modulen genutzten Stylesheets nicht funktioniert.. Das Problem ist, dass jedes @import traditionell ein Stylesheet von einer URL lädt und für mehrere Requests auf die gleiche Adresse komplett unterschiedliches CSS geliefert bekommen kann. Für ECMAScript-Module hingegen baut der Browser einmal einen Modul-Graph auf und lädt keine URL zweimal – zwei unterschiedliche Antworten innerhalb eines Ladezyklus sind also ausgeschlossen. CSS-Module wollen Syntax wie import css from './foo.css' ermöglichen, doch hier kollidiert die Funktionsweise von ECMAScript-Modulen mit der von @import. Wir erwarten von import-Statements deterministische Ergebnisse und von @import-Regeln das genaue Gegenteil. Die erwartbare Konsequenz: kein @import in CSS-Modulen und auch kein @import in mit new CSSStyleSheet() erzeugten Stylesheets, in denen sich ein vergleichbarer Widerspruch manifestiert.

type auf <textarea> und <select> (und mehr)

Beim Zusammenstecken einiger Debug-Strings fiel mir auf, dass auf <textarea> und <select> das IDL-Attribut type existiert:

const textarea = document.createElement("textarea");
console.log(textarea.type); // > "textarea"

const select = document.createElement("select");
console.log(select.type); // > "select-one"

const multiSelect = document.createElement("select");
multiSelect.multiple = true;
console.log(multiSelect.type); // > "select-multiple"

Anders als bei <input>, wo das Content-Attribut type den Input-Typ bestimmt, ist das IDL-Attribut type bei <textarea> und <select> read-only. Die Idee dahinter scheint zu sein, dass alle Formular-Elemente eine einheitliche API zum Ermitteln ihres Typs haben sollen, denn auch <output> und <fieldset> haben dieses Feature. Unter den verbliebenen form-associated elements haben <input>, <button> und <object> ohnehin type-Attribute und die einzigen Ausreißer sind <img> (warum ist das überhaupt form-associated?) und eventuelle form-associated custom elements. Was lernen wir daraus?

  1. Formular-Elements können wir allein anhand ihres type auseinanderhalten.
  2. Wenn wir form-assoicated custom elements bauen, sollten sich diese auch die Mühe machen, einen type-Getter zu implementieren, denn sonst funktioniert Punkt 1 nicht mehr.

Punkt 2 ist schon erfüllt, wenn wir einfach nur den folgenden Codeschnipsel in unsere Form-Element-Basisklassen einbauen:

export class FormBaseElement extends HTMLElement {
  // Boilerplate...
  
  get type() {
    return this.tagName.toLowerCase();
  }
  
  // ... mehr Boilerplate...
}

Damit funktioniert type praktisch wie bei <textarea> und erfüllt damit in 99% aller Fälle schon locker seinen Zweck!

Weitere Erkenntnisse und Fundstücke

Folgt mir auf Mastodon, wenn ihr dem nächsten Erkenntnis-Paket live beim Entstehen zusehen wollt!

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.