TIL-Roundup, Februar 2024: Formulare, Compression Streams, Adopted Stylesheets

Veröffentlicht am 2. April 2024

Auch im letzten Monat haben lautes Nachdenken und lebhafter Austausch auf Mastodon dazu geführt, dass ich einiges über HTML, CSS und JavaScript/DOM erfahren habe, das mir vorher nicht klar war. Und da Mastodon noch immer nicht die intergalaktische Total-Dominanz ausübt, die ihm eigentlich zusteht, kehre ich die gesammelten Erkenntnisse an dieser Stelle nochmals zusammen. Das behalte ich ab nun auch bei, bis ihr alle mir dort folgt.

Formulare in Formularen? Jain! (und mit Browserbugs)

Ich habe schon vor langer Zeit mal einen Workflow für Form-Value-Handling in Formular-Web-Components ausgebrütet, der darauf basierte, im Shadow DOM der Komponente ein inneres <form>-Element zu haben. Dieses Element lässt sich zu FormData serialisieren, was dann wiederum bequem in Submit-Values, value-Attribute und alle sonstigen für die Komponente relevanten Aspekte transformiert werden kann. Im Firefox funktionierte das auch hervorragend, aber bei einigen Komponenten streikte Chrome. Warum? Weil mein Form-Handling-Workflow ungültiges HTML verwendet (wenn man denn HTML verwendet).

Die HTML-Standards verbieten verschachtelte Form-Elemente und (was ich nicht auf dem Schirm hatte) Formulare in Shadow Roots in Formularen gelten als verschachtelte Form-Elemente! Allerdings besteht diese Regel auch nur für HTML, nicht für das DOM. Der folgende Code resultiert in nur einem <form>-Element, da der HTML-Parser das innere Element verwirft:

<form>
  <form></form>
</form>

<!-- Ergebnis: ein <form> im DOM -->

Wenn wir aber gar keinen HTML-Parser involvieren, sondern per JS direkt das DOM manipulieren, erhalten wir verschachtelte Formulare:

let outer = document.createElement("form");
let inner = document.createElement("form");
outer.append(inner);
document.body.append(outer);

// Ergebnis: zwei <form> im DOM

Das ist auch nicht so besonders bizarr: HTML ist nur eine Serialisierung des DOM und hat daher die Freiheit, sich bestimmter DOM-Konstrukte zu verweigern, genau wie JSON mit zahlreichen Aspekten von JavaScript nichts anfangen kann.

Mein Komponenten-Fail in Chrome kam dadurch zustande, dass ich bei den Problem-Komponenten innerHTML für das Shadow-DOM-Setup verwendet habe (und andere DOM-Tools bei den unproblematischen Komponenten). innerHTML verwendet natürlich seinerseits den HTML-Parser, der allerdings offenbar in meinem Haupt-Browser Firefox den Verschachtelungs-Überblick verliert, sobald Shadow DOM involviert ist. Bedeutet: im Firefox funktioniert etwas, das laut HTML-Standard nicht funktionieren dürfte. Endlich kann ich mal einen Bug melden, der nicht einfach nur Gebettel um eine Implementierung von Feature X ist!

Meinen Web-Component-Ansatz mit inneren Formularen werde ich beibehalten, obwohl er sich nicht in HTML serialisieren lässt. Solange die inneren Formulare im Shadow DOM bleiben, stören sie nicht, und solange das Shadow DOM ohne einen (bugfreien) HTML-Parser aufgesetzt wird, sollten sie auch funktionieren. Und ich denke nicht, dass deklaratives Shadow DOM ein sinnvolles Einsatzgebiet für Custom Formular-Inputs sein wird, weswegen ich mir erlaube, die Regeln von HTML an dieser Stelle zu ignorieren.

Kompatibilitätsprobleme von CompressionStreams (und deren Zubehör)

Nachdem ich im Januar CompressionStreams über den grünen Klee gelobt hatte, fielen mir im Folgemonat einige Kompatibilitätsprobleme auf. Seit dem LTS-Release von Node 20 herrscht in Hinblick auf die Kompressionsalgorithmen durch die Bank die gleiche Unterstützung, aber Chrome und Chrome-Derivate implementieren nicht @@asyncIterator auf ReadableStream, sodass für diese Browser folgender Polyfill benötigt wird:

ReadableStream.prototype[Symbol.asyncIterator] ??= async function* () {
  const reader = this.getReader();
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        return;
      }
      yield value;
    }
  } finally {
    reader.releaseLock();
  }
};

TypeScript-kompatible Versionen der compress()- und decompress()-Funktionen aus dem Artikel, mit dem o.g. Polyfill und besserem Error Handling und URL-sicherem Base64 gibt es in meiner Toolsammlung.

Die Reihenfolge von Adopted Stylesheets ist egal

Mit new CSSStyleSheet() erstellte Stylesheet-Objekte können der adoptedStyleSheets-Property eines Shadow Root (oder eines Document) zugewiesen werden, um dem entsprechenden Objekt ein bisschen Style überzuhelfen. adoptedStyleSheets kommt als Array daher und trotzdem ist – für mich überraschend – der Array-Index eines gegebenen Stylesheets für die CSS-Anwendung irrelevant. Es zählt allein die Reihenfolge des Hinzufügens:

function createSheet(css) {
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(css);
  return sheet;
}

const host = document.querySelector(".host");
const shadow = host.attachShadow({ mode: "open" });
shadow.innerHTML = "<span>Text</span>"
shadow.adoptedStyleSheets[1] = createSheet("span { color: red }");
shadow.adoptedStyleSheets[0] = createSheet("span { color: green }");
// Ergebnis: grün

Ich hätte mich nicht gewundert, wenn der Text rot geworden wäre, da im Array color: red die letzte Regel ist. Aber da sie zuerst hinzugefügt wurde, gewinnt Grün.

Safari bleibt der neue IE6: kein d in CSS

Ich musste entsetzt zur Kenntnis nehmen, dass Safari d in CSS nicht unterstützt – als einziger relevanter Browser diesseits der Andromeda-Galaxie. Eigentlich ist d ein Attribut des SVG-Elements <path>, das den zu zeichnenden Pfad beinhaltet. Wie so ziemlich jedes SVG-Attribut (fill, stroke etc.) kann auch d als CSS-Eigenschaft ausgedrückt werden und d wird in dieser Rolle mit einem path()-Wert gefüttert, genau wie u. a. clip-path. Einziges Problem: Safari mag d in CSS nicht.

Das ist ziemlich verheerend, da damit ein CSS-Manöver verhindert wird, für das ich mich ansonsten ziemlich gefeiert hätte: per Custom Properties konfigurierbare Inline-SVGs! Jetzt muss ich mich damit begnügen, ein endliches Set von vordefinierten d-Werten über Bande per Custom Property Toggles bereitzustellen. Ein ziemlich enttäuschendes Downgrade.

Weitere Erkenntnisse und Fundstücke

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.