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.

Webtech-Erklärbär-Termine Q4 2023

Veröffentlicht am 5. September 2023

Die Pest hat mehr oder minder den Schwanz eingezogen und der Webtech-Erklärbär traut sich wieder aus seiner Höhle heraus. Ein ganz klein bisschen Angst macht mir dieser Terminplan, dem auch noch alle Inhouse-Termine fehlen, schon. Aber umso besser für euch: es gibt wieder diverse Möglichkeiten, sich von mir diversen Webtech-Kram auf Konferenzen überall im Land erklärbären zu lassen! Besonders empfehlen möchte ich die (finale Edition des Webworker-Stammtisch Ruhr, wo ich in Essen ab 19:00 Uhr ein bisschen HTML-Spezifikationsexegese betreiben werde. Aber der Rest kann sich auch sehen lassen:

Termine unpassend, Orte alle zu weit weg und Programme nicht genehm? Ich komme auch gerne mit einem maßgeschneiderten Workshop in eurer Firma vorbei – mich kann man ganz einfach mieten! Selbst bei einer Terminliste wie der für dieses Quartal mache ich dafür immer gerne Zeit.

Im Kaninchenbau der Array-Erkennung

Veröffentlicht am 1. August 2023

Auf Twitter (die Älteren unter uns erinnern sich sicher noch) fragte Nikolaus: „Woran, wenn nicht am Prototypen, erkennt Array.isArray() Arrays“ und bekam von mir eine viel zu kurze Antwort. Es folgt in diesem Artikel die Langfassung!

Heutzutage ist Array-Erkennung ganz einfach: die Spezifikationen legen direkt fest, dass Array.isArray() Arrays erkennt! Genau genommen verweist die Definition von Array.isArray() auf die spezifikationsinterne Funktion IsArray(), die mehr oder minder sagt: wenn ich auf ein Array angewendet werde, gebe ich true aus, ansonsten false. Arrays sind in ECMAScript sehr klar von normalen Objekten abgegrenzte Spezial-Objekte (sogenannte „Array Exotic Objects“) und daher eigentlich ohne großen Aufwand zu identifizieren. Das war allerdings nicht immer so einfach.

Was ist eigentlich ein Array?

Arrays sind Allerwelts-Bausteine von fast jedem JavaScript-Programm... was genau macht sie exotisch? Zunächst mal nicht viel: Arrays sind zu 99% von normalen JavaScript-Objekten nicht zu unterscheiden. Wir können beliebige Felder definieren, Funktionen wie Object.keys() mit ihnen verwenden und Operationen wie delete funktionieren auch:

let arr = [];
arr.someField = 23;
console.log(arr.someField); // > 23
arr[0] = 42; // Auch nur ein Objekt-Feld
console.log(Object.hasOwn(arr, "someField")); // > true
console.log(Object.hasOwn(arr, 0)); // > true
delete arr.someField;
console.log(Object.hasOwn(arr, "someField")); // > false

Das einzig offensichtlich Spezielle an Arrays sind eine eigene Literal-Syntax ([] statt {}) und ein Prototyp, der Methoden wie push() und splice() bereitstellt. Wir kommen dem Funktionsumfang von Arrays sehr nahe, wenn wir einfach ein neues Objekt mit Array.prototype als Prototyp anlegen:

let fakeArray = Object.create(Array.prototype);
fakeArray.push(23);
console.log(fakeArray[0]); // > 23
console.log(fakeArray.length); // > 1

Unser Fake-Array hat Inhalt (numerische Objekt-Keys) und Methoden wie push() definieren nicht nur neue Felder, sondern erhöhen auch die length um die Anzahl der neuen Elemente. Ein echtes Array macht aber noch mehr:

let fakeArray = Object.create(Array.prototype);
let realArray = [];

fakeArray.push(23);
realArray.push(23);

console.log(fakeArray[0], realArray[0]); // > 23, 23
console.log(fakeArray.length, realArray.length); // > 1, 1

fakeArray[1] = 42;
realArray[1] = 42;

console.log(fakeArray[1], realArray[1]); // > 42, 42
console.log(fakeArray.length, realArray.length); // > 1, 2

realArray.length = 0;
fakeArray.length = 0;

console.log(fakeArray.length, realArray.length); // > 0, 0
console.log(fakeArray[0], realArray[0]); // > 23, undefined

Beim Fake-Array ändert sich die length bei der Benutzung von Methoden, nicht aber bei direktem setzen von Indizes (z.B. fakeArray[1] = 42). Umgekehrt führt ein setzen der length auf 0 beim echten Array zum Löschen des Inhalts, beim Fake-Array hingegen ändert sich nichts am Inhalt. Wie kann das sein? Mit Sicherheit ist doch length ein Getter/Setter-Paar, das Array-Inhalt zählt oder verändert, richtig?

let realArray = [];

console.log(Object.getOwnPropertyDescriptor(realArray, "length"));
// > { value: 0, ... } - KEIN Getter/Setter-Paar

realArray[5] = true;

console.log(Object.getOwnPropertyDescriptor(realArray, "length"));
// > { value: 6, ... } - Magisches Update für "length"!

Oh. Anscheinend ist length doch eine ganz normale Daten-Property auf Arrays und kein Getter/Setter-Paar auf dem Prototypen. Aber wie funktioniert length denn dann?

Exotic Objects

In der ECMAScript-Spezifikation existiert das Konzept des „Exotic Object“, das bestimmte Sorten von Objekt (z.B. Arrays) von „Ordinary Objects“ abgrenzt. Exotic Objects können in einer beliebigen Reihe von Weisen vom Verhalten der normalen Ordinary Objects und auf diese Weise „magische“ Features wie length auf Arrays umsetzen. Als Ordinary gelten jene Objekte, die eine bestimmte Liste von Algorithmen implementieren (plus ein paar Extras für Funktionen) und „Exotic Objects“ weichen von diesen Standard-Bausteinen ab.

Im Falle von Arrays ist der Algorithmus für das Setzen von Properties abweichend definiert:

  1. Das Setzen der length verändert den Array-Inhalt
  2. Das Setzen von numerischen Feldern (d.h. von Array-Indizies) verändert die length
  3. Alles andere verhält sich wie bei normalen Objekten

Auf einem Array ein Feld wie z.B. arr.x = "Hello" zu definieren hat also den gleichen Effekt, als würden wir das auf einem normalen Objekt tun. Setzen wir jedoch arr[7] = 23, erhält length auf magische Weise ein automatisches Update und setzen wir die length auf einen neuen Wert, verändert sich der Array-Inhalte ebenso automagisch. Allein das Vorhandensein dieser einen Ausnahme für den Property-Set-Algorithmus erhebt Arrays in den exklusiven Club der Exotic Objects!

Die ECMAScript-Spezifikation untergliedert den Club der Exotic Objects anhand der diversen Non-Standard-Verhaltensweisen seiner Mitglieder noch weiter. Auf diese Weise kann die Spezifikation zwischen z.B. Arrays und Strings (die beide spezielle, aber unterschiedliche Operationen mit length und Indizes implementieren) auseinanderhalten. Allerdings passiert diese Unterscheidung allein auf der Ebene der Spezifikationen. Das Ziel der Specs ist, über präzise definierte Algorithmen ein bestimmtes beobachtbares Verhalten der Programmiersprache zu garantieren, doch die Algorithmen selbst sind nicht direkt aus JavaScript heraus beobachtbar. Wie mussten sehr genau hinsehen, um das besondere Verhalten von length überhaupt zu erkennen, und dieses Erkennen allein verrät uns nur, dass irgendwas besonderes los ist - was genau unter der Haube mit Array passiert, erklärt allein die Spezifikations-Lektüre.

Nun wissen wir, dass Arrays tatsächlich etwas besonderes sind: eine Subspezies einer besonderen Spezies von Objekt. Wie können wie diese Spezies jetzt in unserem normalen JS-Code von normalen Objekten unterscheiden?

Die Grenzen von Duck Typing und instanceof

Normalerweise ist Duck Typing das Mittel der Wahl, um in JavaScript den Typ von Objekten (näherungsweise) festzustellen, doch das funktioniert bei Arrays nicht besonders gut. Gerade aufgrund ihrer magischen length-Eigenschaft (anstelle eines Getter-Setter-Paars) sind Arrays von herkömmlichen Objekten kaum zu unterscheiden: ein überzeugendes Fake-Array mit numerischen Keys, Array.prototype und einer length-Eigenschaft ist, wie wir gesehen haben, schnell gebaut.

Eine denkbare Alternative zu Duck Typing ist instanceof, aber auch das hat seine Grenzen: wenn wir von Hacks, zu denen wir in Kürze kommen, erst mal absehen, liefert instanceof nur das richtige Ergebnis, wenn seine beiden Operanden aus dem gleichen Browsing Context (d.h. Fenster bzw. Frame) stammen:

// Array und Array-Constructor aus gleichem Frame
console.log([] instanceof Array); // > true
console.log(someFrame.contentWindow.arr instanceof someFrame.contentWindow.Array); // > true

  
// Array und Array-Constructor aus unterschiedlichen Frames
console.log([] instanceof someFrame.contentWindow.Array); // > false
console.log(someFrame.contentWindow.arr instanceof Array); // > false

Das ist ganz streng genommen nicht verwunderlich - Array und iframe.contentWindow.Array sind nun mal zwei unterschiedliche Objekte, und nur eins von beiden ist die Constructorfunktion von einem Array aus einem gegebenen Browsing Context. Hinzu kommt, dass wir mit @@hasInstance den instanceof-Operator ohnehin zu jedem beliebigen Ergebnis kommen lassen können:

class Yep {
  // Bestimmt das Ergebnis von "x instanceof Yep"
  static [Symbol.hasInstance]() {
    return true;
  }
}

console.log([] instanceof Yep); // > true
console.log({ foo: 42 } instanceof Yep); // > true

Und ja, streng genommen können wir den Array-Constructor so patchen, dass instanceof über Frame-Grenzen hinweg funktioniert:

// Normalerweise ist @@hasInstance auf Arrays nicht definiert...
Object.defineProperty(Array, Symbol.hasInstance, {
  value: (x) => Array.isArray(x)
});

Allerdings müssten wir innerhalb dieses Patches Array.isArray() benutzen, was uns auf der Suche nach einem Weg jenseits von Array.isArray() nicht wirklich weiterbringt. Für sich genommen ist und bleibt instanceof zur Array-Erkennung unbrauchbar und wir brauchen einen anderen, definitiven Weg, Arrays - die, egal aus welchem Frame stammend, nun mal Array Exotic Objects sind - zu identifizieren!

Der [[Class]]-Hack

Nachdem sich instanceof als nutzlos erwiesen hat, ist klar, wonach wir suchen: Wir brauchen einen Identifikationsmechanismus, der sich auf aus JS heraus zugängliche Aspekte stützt, die Array Exotic Objects eigen sind - unabhängig vom Browsing Context oder irgendwelchen Symbols auf irgendwelchen Klassen. Aus dieser Erkenntnis entstand in der grauen JavaScript-Vorzeit der folgende Hack:

let isArray = (x) => Object.prototype.toString.call(x) === "[object Array]"; // WTF?
console.log(isArray(42)); // false
console.log(isArray([])); // true

Wie funktioniert das? Im Prinzip per Informations-Leck! Normalerweise hat jede JavaScript-Objekt-Klasse seine eigene Implementierung von toString():

console.log({}.toString());
// > "[object Object]"
// Quelle: Object.prototype.toString()

console.log(function test(x) { return x * x; }.toString());
// > "function test(x) {return x * x;}"
// Quelle: Function.prototype.toString()

console.log([1, 2, 3].toString());
// > "1,2,3"
// Quelle: Array.prototype.toString()

Alle JavaScript-Objektklassen erben von Object.prototype und im Zuge dessen überschreiben sie die Basis-Implementierung von Object.prototype.toString() mit ihren eigenen Stringifizierungs-Algorithmen. Im Falle von Arrays stringifiziert dieser Algorithmus den Array-Inhalt und fügt ihn mit Kommata zusammen. Mittels Object.prototype.toString.call(someArray) umgehen wir aber diesen Array-eigenen Algorithmus und verwenden den Standard-Stringifizierungs-Algorithmus Object.prototype.toString() für unser Array. Und dieser Standard-Stringifizierungs-Algorithmus gibt nicht, wie viele glauben, einfach immer "[object Object]" aus!

Vor ECMAScript 2015 enthielten alle JavaScript-Objekte einen internen String-Wert namens [[Class]]. Die Doppeleckklammer-Notation ist die ECMAScript-Standard-Schreibweise für nicht-öffentliche Felder in Objekten. Ein so beschriebenes Feld ist ein reiner Spezifikationsmechanismus (vergleichbar mit den Non-Standard-Operationen von Exotic Objects) und sollte für Nutzer von JavaScript selbst nicht direkt beobachtbar sein. Soweit die Theorie.

In ES2015 und älter wurde [[Class]] allerdings in Object.prototype.toString zur Stringifizierung von Objekten verwendet! Einfach den Wert von [[Class]] in den String "[object XYZ]" an der Stelle von XYZ einsetzen und fertig! Bei ({}).toString() kam also nur deshalb"[object Object]" heraus, weil [[Class]] in Standard-Objekten eben "Object" war. Für Arrays, deren [[Class]] den Wert "Array" war, müsste also [object Array] herauskommen, doch da Arrays ihre eigene, [[Class]] ignorierende toString()-Implementierung mitbringen, passierte das im Normalfall nicht. Der einzige Weg, den [[Class]]-Wert eines Objekts mit eigener toString()-Implementierung sichtbar zu machen, besteht darin, das toString() von Object.prototype mittels call()-Methode auf die fraglichen Objekte anzuwenden.

Das Endergebnis war ein Hack, der eine löchrige Abstraktion in den ECMAScript-Spezifikationen ausnutzte. Die öffentliche Methode Object.prototype.toString erlaubte den Einblick in einen nichtöffentlichen Aspekt von der Spezifikationsmechaniken, womit wir Objekte genau wie die ES-Specs unterscheiden konnte. Das funktionierte mit Arrays und diversen anderen Standard-Objekt-Sorten recht zuverlässig, doch eine saubere Lösung zur Array-Erkennung sieht natürlich anders aus.

Der Weg zu Array.isArray()

Der Webentwickler-Community einen Mechanismus zur zweifelsfreien Identifikation von Arrays zu geben, war eine recht unkontroverse Idee. Anfangs (bis ES2015) stützte sich Array.isArray() noch auf [[Class]], doch später wurde das Regelwerk vereinfacht: true für Array Exotic Objects, andernfalls false. Das ändert im Endeffekt nicht viel, denn [[Class]] war genau so ein internes Spezifikationsdetail, wie es die Kategorie Array Exotic Object ist, doch am Ende ist es doch der etwas direktere Weg.

In heutigem ECMAScript existiert [[Class]] nicht mehr und Objekte (eingebaute wie auch in JS definierte) können ihre Stringifizierung per @@toStringTag selbst bestimmen. Alles, was von [[Class]] bleibt, sind ein paar zusätzliche Schritte in der heutigen Definition von Object.prototype.toString(), um Abwärtskompatibilität herzustellen. Array.isArray() erkennt seinesgleichen heutzutage ganz einfach per Definitionem und ist daher das am besten unhinterfragte Mittel der Wahl zur Array-Identifizierung. Klar, mit @@hasInstance aus dem Array-Constructor könnte JavaScript heutzutage Arrays auch über Frame-Grenzen per istanceof erkennbar machen, doch das lässt das Gebot der Abwärtskompatibilität natürlich nicht zu. Das wäre viel zu einfach.