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.