Die erste Fassung der Code.Movie-Webseite, damals noch mit dem Playground als SAAS, hatte ich vor ca. 2 Jahren gebaut und dabei alles zum Einsatz gebracht, was moderne Frontend-Entwicklung zu bieten hatte: Next.js und React sowie diverse Packages für React. Dazu zählten unter anderem Redux und React DnD, die freilich ihrerseits Ergänzungs-Packages nach sich zogen (u. a. Redux-Saga und react-dnd-html5-backend), plus natürlich allerlei weiteres Zubehör wie Linter-Plugins für Next und React. Das Endergebnis war eine moderne Webapp nach allen Regeln der Kunst – SPA, SSR, jedes Feature war dabei. Wenn ich heute die package.json von damals aus dem Archiv hole, finde ich die Liste der Dependencies auch gar nicht mal so besonders lang. Die Packages konzentrierten sich auf das Wesentliche und statt für jede Kleinigkeit NPM heranzuziehen, habe ich viel mit eigenem Code gelöst.

Das Projekt litt trotz einer eher überschaubaren intrinsischen Komplexität und einem moderat dimensionierten Dependency Tree daran, dass ich permanent damit beschäftigt war, vorhandenen Code wieder und wieder neu zu schreiben, ohne viel sichtbaren Fortschritt am Projekt zu erzielen. Das könnte allein mein höchstpersönliches Versagen gewesen sein, aber es drängt sich der Eindruck auf, dass nicht nur ich dieses Problem kenne.

Code als Dauer-Baustelle: eine Ursachensuche

Am Dauer-Rewrite waren die Dependencies nicht unbeteiligt; von einem Tag auf den anderen wollte beispielsweise Next.js alle Pages statt in Folder A lieber in Folder B sehen und dort, wo die Dependencies wirklich gestapelt waren, gab es auch bei kleinen Updates immer wieder Kompatibilitätsprobleme. Das größere Problem saß aber in Kiel vor dem Bildschirm, denn mich hatte das Frontend-FOMO weit mehr im Griff, als mir damals klar war.

Neue Versionen von Software bedeuten in aller Regel Fortschritt. Klar, manchmal bricht eine API und manchmal wird etwas verschlimmbessert, aber in aller Regel geht es vorwärts – und wer möchte nicht gern vorn mit dabei sein? Das Problem ist nur, dass Fortschritt, wenn er auf bestehenden Code trifft, nicht ohne Arbeitseinsatz eingebaut werden kann … manchmal, ohne wahrnehmbare Verbesserungen zu erzielen. In meinem Fall passierte das mehrfach:

  • Dependency-Updates mit API-Änderungen (wie das angesprochene Next.js-Update) bringen Umbauten des betroffenen Codes mit sich. Diese können in sich eine Verbesserung darstellen, aber genauso gut einfach eine reine Umformulierung des Bestehenden ohne spürbaren Gewinn bedeuten. Dass am Ende von den eventuellen Verbesserungen etwas Wahrnehmbares bei den Nutzer:innen des Projekts ankommt, ist sehr unwahrscheinlich und war beim Next.js-Update am Ende auch nicht der Fall.
  • Eines Tages ließ ich mich überzeugen, das Redux-Toolkit einzubauen und im Zuge dessen das gesamte bisherige State-Management der App zu ersetzen. Viel Arbeit, keine Änderung an UI oder UX. Der neue Code war im Vergleich zu meinem vorher verwendeten DIY-Redux-Toolkit deutlich weniger idiosynkratisch, aber außerhalb des Codes gab es keine wahrnehmbare Änderung. Da mit dem Code nur Menschen in Kontakt kamen, die auch schon vorher wussten, wie das System funktioniert, war dieser Umbau nicht wirklich hilfreich.
  • Ich muss vermutlich nicht erklären, was mit dem frisch umgebauten State Management passiert ist, als ein Jahr später Signals um die Ecke kamen. Verbesserungen an UI, UX und Feature-Set: keine.

Jetzt könnte man mir vorwerfen, dass ich hätte kommen sehen sollen, dass keiner dieser Umbauten mir weiterhelfen würde. Aber ich würde nicht sagen, dass mir nichts davon geholfen hat: das Next.js-Update mitzunehmen war nötig, um auch in Folge Updates mitgehen zu können und das mehrfache Neuschreiben hat das State Management ganz am Ende in einen hervorragenden Zustand gebracht! Letzteres lag vermutlich weniger an Redux-Toolkit oder Signals als eher daran, dass ich beim dritten Rewrite ein viel besseres Bild davon hatte, was ich zu erreichen versuchte … aber der Fortschritt an sich ist nicht wegzudiskutieren. Selbst wenn es keine wirklichen Verbesserungen gegeben hätte, so wäre es doch keine gute Idee gewesen, die Updates zu ignorieren, denn das frische Update von heute ist der etablierte Standard für die nächsten Updates der Dependencies von morgen.

Wir müssen aber auch festhalten, dass alle genannten Updates und Verbesserungen sich nur dort manifestiert haben, wo sie eine untergeordnete Rolle spielen: im Programmcode. Code ist nicht unwichtig, aber wenn wir Frontend-Web-Apps bauen, ist das Ziel ein anklickbares Produkt, in dem eine dritte Iteration von State Management nur mittelbar eine Rolle spielt. Das dazu nötige JavaScript, CSS und HTML kann sich dabei innerhalb eines relativ breiten Qualitätskorridors bewegen, ehe es zu einem Problem wird.

Nun bin ich, wie alle Hörer:innen von Working Draft wissen, weit entfernt davon, unkritisch jedem Hype hinterherzulaufen. Trotzdem war es mir, als ich mich in das React-Universum begeben hatte, nicht möglich, sich dem permanenten Neuschreiben und dem Dauer-Update von bereits Funktionierendem zu entziehen. Es gab jederzeit genug Neuerungen zum Einbau und genug Gründe (v.a. Kompatibilität aktueller und zukünftiger Dependencies untereinander) für all die Neuerungen. Am Ende hat mich das permanente Neuschreiben ausreichend genervt, um das betroffene Projekt eines Tages einfach abzubrechen.

Wo kommt das ständige Neuschreiben her?

Web-Frontends könnten eigentlich, wenn einmal gebaut, fast auf ewig unverändert weiter funktionieren. Browser sind gnadenlos abwärtskompatibel und wenn wir wollten, könnten wir auch heute noch Seiten aus <frameset> und document.write() zusammenbauen. Warum schreiben wir dann unsere Frontends (ganz oder in Teilen) ständig neu?

Neben den zweifellos nötigen Updates (neue Features im betroffenen Frontend oder globale Neuheiten, auf die regiert werden muss – z. B. Sicherheits-Features, Responsive Design oder Dark Mode) kommt meines Erachtens viel Rewrite daher, dass die meisten Web-Apps keine Berührungspunkte mehr mit der eigentlich so stabilen Web-Plattform haben. Das ist kein Zufall, denn der Sinn und Zweck von Tools wie React ist, die Plattform wegzuabstrahieren. Die Webstandards definieren allerlei unkomfortable APIs und statt eines konsistenten Big-Picture-Designs herrscht ein wildes Durcheinander von Paradigmen und Konventionen. Angesichts dessen ist eine komplette Neukonzeption natürlich sehr attraktiv. Chaos und OOP? Einfach React installieren und es herrschen ein klares Konzept und funktionale Programmierung!

Solche Abstraktionsschichten (und alle darauf aufbauenden Schichten, wie etwa clientseitige Router und UI-Toolkits) sind aber nicht, wie ein Webbrowser, der unbedingten Abwärtskompatibilität verpflichtet, wodurch sich ein Teil des permanenten Neuschreibens erklärt. Die Frontend-Frameworks dieser Welt haben aber auch ein ganzes Ökosystem um sich herum angesammelt, das nicht nur Software, sondern auch Ideen und Praktiken generiert – weit schneller, als die dem Ökosystem zugrundeliegenden Frameworks API-Änderungen produzieren. Abertausende Nerds finden jeden Tag neue Patterns und neue Best Practices und publizieren dazu Texte, Code und Videos ohne Unterlass. Wie es Software-Entwickler:innen angesichts dessen gelingen soll, nicht FOMO anheimzufallen (und andererseits nicht zum Fortschritts-Totalverweigerer zu werden), weiß ich nicht. Und mir ist es am Ende auch nicht besonders gut gelungen.

Zum Dauer-Rewrite im Frontend kommt meines Erachtens in drei Schritten:

  1. Moderne Frameworks versuchen der Web-Plattform ein neues Paradigma überzustülpen, um ihre Nutzer:innen vor den Bugs und Inkonsistenzen der nativen APIs zu bewahren und die Implementierung von relevanten Patterns möglichst einfach zu machen.
  2. Auf der frisch erschaffenen Plattform erwächst ein Ökosystem, das iterativ immer neue Tools und Patterns generiert, die die neu entstandene Plattform möglichst gut zu nutzen versucht. Es wird ausprobiert, verfeinert, verworfen, verbessert.
  3. Entwickler:innen sehen, dass stets und ständig neue Tools und Patterns als aktuell gelten und verwenden Packages, die früher oder später diese neuen Tools und Patterns voraussetzen werden und versuchen, stets ihren gesamten Stack aktuell zu halten, statt (mögliche) technische Schulden anzuhäufen.

Keine der beteiligten Parteien macht bei irgendeinem dieser Schritte etwas wirklich falsch! Die Entwickler:innen des Frameworks wollen eine objektiv bessere Plattform bauen, die Entwickler:innen der Tools und Patterns versuchen, aus der neuen Plattform alles herauszuholen und die Entwickler:innen der Web-Apps tun verständlicherweise alles, um mit der Gesamtentwicklung Schritt zu halten. Das bedeutet aber auch, dass sich alle Beteiligten in einem Zustand des permanenten Experimentierens befinden und eine experimentelle Plattform ist keine stabile Plattform. Sie mag in mancherlei Hinsicht besser sein, aber diese Verbesserungen müssen jeden Tag neu erarbeitet und ausgebaut werden.

Es sei denn, man verwendet statt der besseren, aber weniger stabileren Plattform eine stabile Plattform und schluckt ein paar DX-Kröten.

Meine eigene, kleine Hütte im Wald

In der oberen Kreidezeit schreib’ ich einst, dass man nicht kein JavaScript-Framework verwenden kann und diese Aussage würde ich weiter so vertreten. Ein Framework besteht aus Entscheidungen, die durch Software in Form gegossen werden – und wenn man programmiert, trifft man Entscheidungen und schreibt Software. Früher oder später hat man ein Framework in der Hand, entweder ein im Vorfeld installiertes, ein am Anfang designtes oder ein im Laufe der Entwicklung passiertes. Um irgendeine Form von Struktur kommen wir nicht herum.

Wenn aber Entscheidungen und Software eigentlich genau das Metier von uns Softwareentwickler:innen sind, sollten wir eigentlich alle in der Lage sein, ein eigenes Frontend-Framework zu bauen. Diese Idee ist in einer Welt, in der Web Components existieren, gar nicht mal so absurd (denn immerhin müssen wir zumindest kein ganz neues Komponentenmodell erfinden) und eröffnet einige neue Möglichkeiten:

  1. Wir können das Framework so gestalten, wie wir wollen und die Tradeoffs so wählen, wie wir möchten. Insbesondere können wir uns entscheiden, das Framework vergleichsweise wenig machen zu lassen und uns stattdessen mit den Macken der Web-Plattform arrangieren … und uns auf diese Weise Stabilität erkaufen. Falls uns gewisse Aspekte gar nicht interessieren (z. B. TypeScript oder Server-Side-Rendering), können wir diese komplett ignorieren und an sie kein einziges Byte und keinen einzigen Design-Kompromiss verschwenden.
  2. Updates und Verbesserungen finden statt, wenn wir wollen, statt in Abhängigkeit von irgendwelchen Third-Party-Autoren von Frameworks oder Dependencies. Welche Teile des Frameworks wir selbst schreiben und welche Teile aus Drittpaketen stammen, entscheiden wir ebenfalls selbst.
  3. Wir entkoppeln uns vom in anderen Framework-Ökosystemen grassierenden FOMO. Einerseits fehlt uns damit eine breite Auswahl an fertigen Plugins, die wir etwa im React-Universum vorfinden, und wir werden vieles mit mehr Aufwand als npm install lösen müssen. Andererseits werden wir uns auf diese Weise auch keinen permanent wackelnden Turm aus Dependencies aufbauen können, was sich langfristig auszahlen könnte.

Es versteht sich von selbst, dass es nicht für jedes Projekt und jedes Team möglich ist, ein eigenes Framework zu bauen, aber ich bin überzeugt, dass es für viele Teams und Projekte machbar und sinnvoll ist, sich ein pareto-optimales DIY-Framework zu leisten. Ein solches könnte die wildesten Tradeoffs machen, die sich keins der etablierten Frameworks je erlauben würde:

  1. TypeScript komplett ignorieren und stattdessen originelle APIs bereitstellen, die TS nie verstehen würde? Kein Problem!
  2. Spielt Performance eine untergeordnete Rolle? Warum dann nicht Dinge wie Templates oder JSX im Frontend kompilieren und sich den Compile-Schritt ersparen?
  3. Neueste Web-Component-Standards mit Oldschool-Templating wie z.B. Handlebars.js kombinieren? Warum nicht!
  4. Heutzutage unpopuläre Architekturen wie MVC oder Two-Way-Databinding aus der Gruft holen, weil sie gut auf das zu lösende Problem passen? Nur zu!

Das eigene kleine Framework muss nicht komplex oder vielseitig sein: hier ist zum Beispiel der Code des Frameworks hinter Code.Movie, alle 180 Zeilen inkl. Kommentaren und Whitespace. Das Ganze setzt sich wie folgt zuammen:

  • Dependency 1: Ornament für Web-Component-APIs,
  • Dependency 2: uhtml für Templating und DOM-Diffing,
  • Eine Basisklasse namens BaseElement, die HTMLElement erweitert, initialisiert das Shadow DOM und führt ein UI-Update aus, wenn sich auf der Klasse entsprechend dekorierte Accessors ändern,
  • Ein Decorator @listen() implementiert Event Delegation von Klicks und andere Events im Shadow DOM,
  • Der Ornament-Decorator @define() ist so erweitert, dass er Stylesheets laden und dem Shadow DOM bereitstellen kann

Das ganze „Framework“ besteht also aus einer Klasse, die zwei Dependencies zusammenknotet, drei Konventionen etabliert (UI lebt in Shadow DOM, lokaler State lebt in Accessors und das Templating ist in der Klassenmethode render() definiert) und den zwei Hilfsfunktionen bzw. Hilfs-Decorators @listen() und @define(). Die so entstehenden Web Components sehen wie folgt aus:

import {
  BaseElement,
  define,
  attr,
  literal,
  string,
} from "../../framework/index.js";

@define("cm-page-layout", {
  sheets: [new URL("./styles.css", import.meta.url)],
})
export class CMPageLayout extends BaseElement {
  @attr(literal({ values: ["page", "docs"], transform: string() }))
  accessor mode = "page";

  render() {
    return this.html`<div class=${this.mode}>
  <div class="sidebar"><slot name="sidebar"></slot></div>
  <div class="main"><slot></slot></div>
</div>`;
  }
}

Das ist nicht so kompakt wie eine React-Komponente und (mangels TypeScript) nicht so typsicher wie es das Angular-Äquivalent wäre, aber auf jeden Fall ein relativ simples und einfach zu verstehendes Konstrukt. Die Syntax für das Einbinden von Stylesheets ist etwas umständlich und dass im Shadow DOM verwendete HTML-Tags manuell importiert werden müssen, nervt ein wenig. Es gibt kein SSR, keinen Router und nur einen sehr basalen Build-Schritt mit Parcel, da das gesamte Projekt tendenziell eher eine MPA denn eine SPA ist. Komponenten enthalten diverse Ad-hoc-Lösungen für Probleme, die das Framework nicht berücksichtigt und es existiert ein nur mäßig gelungenes State-Management-System, das nochmals um die 290 Zeilen wiegt (dazu später mehr).

Diesen diversen Nachteilen steht gegenüber, dass das verwendete Framework nur 180 Zeilen und nur zwei austauschbare Mini-Dependencies zu Felde führt, auf Web Components und damit stabilen Webstandards basiert und einfach nur ein unspektakuläres Werkzeug ist … und daher mir bisher null Frontend-FOMO eingebracht hat! Seitdem das Framework vor ein paar Monaten ein Feature-Equilibrium gefunden hat, habe ich es nicht mehr angerührt. Ich kann damit Komponenten definieren, die einen Rahmen für UIs darstellen, aber auch flexibel genug sind, um Ad-hoc-Problemlösung zu ermöglichen. Ich will etwas machen, für das es keine Lösung aus der Dose gibt? Einfach den entsprechenden Code in die betroffene Klasse schreiben und fertig!

Vor- und Nachteile des DIY-Frameworks

Als ich mich dazu aufmachte, Code.Movie mit einem DIY-Framework neu zu schreiben, sahen meine Erwartungen wie folgt aus:

  • Weniger npm update durch weniger Dependencies und dadurch weniger permanentes Neuschreiben von vorhandener Funktionalität,
  • vergleichsweise wenig Entwicklungsaufwand am Framework, denn Web Components können ja so schwer nicht sein,
  • relativ viel Rad-Neu-Erfinden, denn es fehlt der vorher bestehende Zugriff auf das React-Ökosystem
  • etwas schlechtere Performance durch das Bevorzugen von einfachen Lösungen gegenüber besonders anspruchsvoll-optimierten Lösungen.

Diese Erwartungen wurden nur teilweise erfüllt. So hat sich etwa die Performance (v.a. Rendering und die reine JavaScript-Performance bei Interaktionen wie etwas Drag & Drop) im Vergleich zum alten React-Projekt deutlich verbessert. Ich führe das darauf zurück, dass der neue Code im Vergleich einfach extrem stark reduziert ist. Kein Code ist so schnell wie kein Code, egal wie fleißig und trickreich die Performanceoptimierung auch ist. Die Ladeperformance ist im Vergleich zum Next.js-Projekt mit all seinem SSR und all seinen Optimierern auch ungefähr gleich geblieben. Weniger ist einfach mehr.

Überraschenderweise würde ich auch behaupten, dass ich nicht häufiger das Rad neu erfinden musste als mit der React-App. Viele Web-APIs wie Drag & Drop, für die man in einem React-Projekt eigene Kompatibilitätsmodule benötigt, konnte ich einfach direkt benutzen! Einen Adapter für CodeMirror musste ich tatsächlich selbst schreiben, aber das war dank origineller Anforderungen auch im React-Projekt der Fall.

Der Entwicklungsaufwand am Framework war etwas höher als erwartet, was primär daran lag, dass ich offenbar meinte, unbedingt einen besonders ausgebufften State Manager entwickeln zu müssen. Dieser hat eine API auf Basis von Decorators und verwendet unter der Haube proxybasiertes Change Tracking …

import { BaseElement, define, listen } from "../../../framework/index.js";
import { PlaygroundState, languages } from "../../../data/index.js";

@define("cm-project-panel", {
  sheets: [new URL("index.css", import.meta.url)],
})
export class CMProjectPanel extends BaseElement {

  // Trackt eine State-Variable im accessor #open
  @PlaygroundState.use("ui.projectPanel.open")
  accessor #open;

  // Event Listener für toggles auf <details> im Shadow DOM
  @listen("toggle", "details")
  #handleToggle(evt) {
    // Neuer state? Einfach accessor updaten!
    // Löst Re-Render der betroffenen Komponenten aus usw.
    this.#open = evt.target.open;
  }
  
  // ... Render-Methode und viel mehr
}

… was eine ganz brauchbare Developer Experience bietet, aber auch ziemlich aufwendig war (ich ungebildeter Informatik-in-der-Realschule-Abwähler musste eine eigene Trie-Implementierung entwickeln!) und 290 LOC wiegt. Eine simplere Lösung um den Preis einer etwas umständlicheren API oder der Rückgriff auf einen de-facto-Standard wie Signals hätte mir in der Gesamtschau viel Zeit erspart.

Wenig überraschend gibt es im Projekt jetzt weniger Dependency-Updates, aber auch nicht null, denn ein Buildsystem inkl. Dev-Server gibt es weiterhin. Das Ende des permanenten Neuschreibens hat sich vielmehr dadurch eingestellt, dass ich nicht mehr Teil des Next.js/React-Ökosytems bin und mich daher in viel geringerem Maße von FOMO betroffen fühle. Ich koche mein eigenes Süppchen und radikale Umwälzungen wie der React-Compiler und React Server Components betreffen mich nicht mehr. Dieser Effekt ist ausgesprochen befreiend und ist meines Erachtens der eigentliche Grund, warum ich bei Code.Movie dieser Tage mehr an Features statt an Rewrites arbeiten kann.

Fazit: baut eure eigenen Frameworks!

Ich bin überzeugt, dass es für viele Projekte von Vorteil wäre, wenn sie sich aus dem Dauer-Rewrite-Zyklus der modernen Frontend-Entwicklung befreien könnten – auch für Projekte, die größer sind, als meine 1-Personen-MPA. Klar, React und Co bieten viel, aber sie kosten auch viel: viele Kilobytes, viel zu lernen und viel Aufwand bei entweder dem andauernden Neuscheiben vorhandener Features oder dem Umgang mit Frontend-FOMO. Viele Gründe, die normalerweise gegen DIY-Frameworks zu Felde geführt werden, greifen heutzutage im Frontend nicht mehr:

  • Ein minimales Framework hat nur wenige Zeilen Code, enthält nur die wichtigsten Features und ist damit keine signifikante Wartungs-Belastung.
  • Der Bus-Faktor spielt bei ausreichend minimalistischer Konzeption keine Rolle, denn zwei Dependencies mit unter 200 Zeilen Glue Code am Funktionieren zu halten, dürfte für die meisten Teams im Bereich des Machbaren liegen.
  • Wenn das Framework auf Webstandards setzt (speziell Web Components) ist es kein Problem, Entwickler:innen hierfür anzuheuern, denn HTML kennt jeder und die Kombination aus einer Basisklasse und einer Handvoll Decorators und Hilf-Funktionen lässt sich jedem und jeder schnell erklären.

Diese Einwände lassen sich eher noch besser an die Adresse der etablierten Frontend-Frameworks richten. Ist die Wartungs-Belastung durch ein sich permanent updatendes Framework nebst Ökosystem rechtfertigen? Bedeutet ein großer Bus-Faktor und der Support durch eine große Firma nicht auch, dass es viele Personen (freidrehende CEOs, wegbeförderte Entwickler:innen) gibt, die das Projekt mit individuellen Entscheidungen in Schwierigkeiten bringen könnten? Sollte so etwas Zentrales wie das Framework des Projekts nicht unbedingt der eigenen Kontrolle unterliegen? Kann man es sich leisten, nicht auf die zu jeder anderen Technologie kompatiblen Web Components zu setzen?

Der Programmcode hinter der Code.Movie-Webseite hat sich nach dem Abwandern von React definitiv vom Ideal der modernen Frontend-Entwicklung entfernt. Es gibt ein wenig mehr Boilerplate, weniger Typsicherheit und die vielen Klassen und HTML-Strings gewinnen keinen Coolness-Preis. heutzutage für zentral befundene Features wie SSR fehlen komplett. Es handelt sich ohne Zweifel um eine auf die vorliegenden Anforderungen stark zugeschnittene 80/20-Lösung. Aber aus einer Reihe von Gründen – mit dem fehlenden Frontend-FOMO als dem meines Erachtens gewichtigsten Grund – fasse ich den einmal geschriebenen Code nur noch an, wenn ich daran etwas ändern möchte, das sichtbare Features betrifft. Endergebnis: deutlich mehr Produktivität und Entspanntheit. Und alles lädt und kompiliert so viel schneller! Man kann direkt mit den Web-APIs reden! Ich kann diesen Schritt jenen, die ihn sich leisten können, nur empfehlen.