1unitedpower: Button zum Hinzufügen von Klassen

Beitrag lesen

Meine Antwort konzentriert sich auf Software-Engineering Best-Practices.

Vorweg ein paar Worte zu den bisherigen Lösungsvorschlägen: Alle halten sich an das KISS-Prinzip: Keep it simple, stupid! Im Flur unseres Lehrstuhls prangert ein mahnendes Zitat von Brian W. Kernighan, das uns daran erinnern soll, Dinge nicht zu kompliziert zu gestalten:

Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.

Wenn wir das KISS-Prinzip als Maßstab anlegen, dann ist alles Weitere, was ich nun vorzubringen habe, grober Unfug. Etwas milder formuliert, könnte man auch sagen, dass das folgende nur für hinreichend komplexe Applikationen einen Mehrwert hat, nicht aber für dein simples Problem. Software-Projekte sind in den meisten Fällen etwas lebendiges, die oft mit einem überschaubaren Feature-Set starten und über die Zeit an Masse und Komplexität zulegen. Gerade bei Projekten, die sehr klein starten und dann hochfrequentiert neue Features integrieren, lässt sich beobachten, dass sie im Laufe ihres Lebens immer wartungsintensiver werden. Projekte, die von Beginn an als umfangreich eingestuft werden, haben dieses Problem auch, werden aber häufiger auch von Beginn an mit einer Architektur konstruiert, die es erlaubt den Wartungsbedarf über längere Entwicklungsperioden nicht proportional ansteigen zu lassen.

In den 2000ern war das Model-View-Controller-Pattern eine beliebte Architektur, um Web-Anwendungen zu konstruieren und ist es in Teilen auch heute noch. Speziell im JavaScript-Ökosystem haben sich aber andere Architekturen durchgesetzt, mein Eindruck ist, dass Komponenten-basierte Systeme mit unidirektionalem Datenfluss heute die dominierende Blaupause für Web-Apps und Mobile-Apps sind. Bevor ich das genauer erläutere, muss ich aber nochmal auf das Problem eingehen, schließlich bringt es nichts, die Lösung vor dem Problem zu diskutieren. Das mache ich jetzt nochmal am Beispiel der bisher genannten Lösungen, damit will ich aber nicht sagen, dass sie schlecht wären. Im Gegenteil, im Sinne des KISS-Prinzips sind das die besseren Lösungen für dein konkretes Problem. Stellen wir uns deshalb vor, dass diese Lösungen nur ein Teil einer viel umfangreicheren Web-App sind, die aus unzähligen Ad-Hoc Lösungen zusammengeschustert wurde.

Alle Lösungen haben gemeinsam, dass sie kein explizites Zustandsmodell besitzen: Der Anwendungszustand ist implizit im DOM gespeichert und wird dort direkt manipuliert. Der Zustand ist hier das aktuell aktive Layout. In welchem Zustand sich die App befindet, wird implizit durch das Vorhandensein der jeweiligen Klasse auf dem html bzw. body-Element determiniert. Gunnar setzt außerdem das aria-pressed-Attribut auf den jeweiligen Buttons. Er stellt in seinem Code sicher, dass die Zustände der Buttons und das aktuell ausgewählte Layout immer im Einklang miteinander stehen. Es wäre unsinnig, wenn das Layout "foo" aktiviert ist, aber der dazugehörige foo-Button nicht aktiv ist. Aber was wäre, wenn nun eine dritte Komponente ebenfalls die foo-Klasse vergeben oder entfernen könnte, die nichts von dem foo-Button weiß? In dem Fall würden der foo-Button und das Layout nicht mehr synchronisiert sein, sondern könnten widersprüchliche Informationen zeigen. Das ist das größte Problem der GUI-Programmierung: Alle GUI-Komponenten müssen synchronisiert sein, nicht aufeinander abgestimmte Komponenten zeigen widersprüchliche Informationen an.

Unidirektionaler Datenfluss

Das Problem kann man mit unidirektionalem Datenfluss lösen: Es verlangt vom Programmierer den Anwendungszustand explizit zu modellieren. Der Anwedungszustand kann von keiner GUI-Komponente direkt geändert werden, sondern nur indirekt über eine Schnittstelle. Die Schnittstelle sorgt dann dafür, dass alle GUI-Komponenten, die über die Änderung des Anwendungszustands informiert werden, sodass diese sich aktualisieren können.

Komponten-baiserte Architektur

Allen Lösungen ist außerdem gemeinsam, dass sie direkt auf das DOM zugreifen. Das DOM wirkt im Web manchmal wie ein Fremdkörper, während HTML und CSS rein deklarative Sprachen sind, ist das DOM eine rein imperative Schnittstelle. Eine Änderung am DOM sieht völlig anders aus, als der HTML-Code den man initial schreibt. Das Problem löst man klassich mit Templating-Engines, die erlauben es HTML mit Platzhaltern zu schreiben. Frühe Templating-Lösungen waren in sofern dumm, als dass sie keine Ahnung von HTML hatten, sie waren rein text-basiert. Moderne Templating-Engines, die HTML verstehen, sind als Virtual DOM bekannt und werden nicht mehr Templating-Engine genannt. Die früheste und bis heute eine der beliebtesten Implementierungen ist React.

Genug Gebrabbel, zeig mir Code!

Ich hab mir Gunnars und Rofls Lösungen als Vorlagen genommen und sie auf React mit unidirektionalem Datenfluss portiert. Gunnars Pendant, Rolfs Pendant.

Die Hauptdatei in Gunnars Pendant sieht so aus:

import React, { useState } from 'react'
import { render } from 'react-dom'
import './style.css'

function App () {
  const [foo, setFoo] = useState(false) // 6
  const [bar, setBar] = useState(true)  // 7
  return (
    <div className={'app' + (foo ? ' foo' : '') + (bar ? ' bar' : '')}>
      <button aria-pressed={foo} onClick={() => setFoo(!foo)}>foo</button>
      <button aria-pressed={bar} onClick={() => setBar(!bar)}>bar</button>
    </div>
  )
}
render(<App />, document.getElementById('root')) // 16

Die drei ersten Zeilen importieren schlicht Library-Code von React und die CSS-Datei.

Die Zeilen 6 und 7 modellieren den explizit den Anwendungszustand. Wir haben zwei boolsche Variablen foo und bar, die kodieren, ob die jeweiligen Layouts aktiv sind oder nicht. Ferner haben wir zwei Setter setFoo und setBar mit denen wir indirekt den Anwendungszustand verändern können. Der Initial-Zustand für das Layout "foo" ist false, für "bar" true. Darunter folgt das Virtual DOM, also sozusagen das Template. Das sieht aus wie HTML, ist aber eine Syntax-Erweiterung von JavaScript. Die geschweiften Klammern sind Markierungen für Platzhalter. Ein Platzhalter ist ein beliebiger JavaScript-Ausdruck. Zum Beispiel wird die Zeile

<div className={'app' + (foo ? ' foo' : '') + (bar ? ' bar' : '')}>

von React zu folgedem HTML umgeformt:

<div class="app bar">

Das ist technisch nicht ganz korrekt: Unter der Haube berechnet React eine minimale Anzahl von DOM-Operationen, die ausgeführt werden müssen, um diesen Zustand zu erreichen

Bei einem Klick auf den foo-Button wird der Setter setFoo aufgerufen und zwar mit dem negierten aktuellen Zustand als Paramater. Daraufhin wird auch direkt der foo-Button aktualisiert, sodass das aria-pressed-Attribut immer synchron mit dem Anwendungszustand ist. Der bar-Button macht das gleiche für das "bar"-Layout.

Die Zeile 16 initialisiert schließlich die App.

React Best Practices

Wie jede Technologie kommt React natürlich mit Vor- und Nachteilen daher.

Progressive Enhancement

Out of the box ist der oben gezeigte Code nicht im Geiste von Progressive Enhancement. Ein Nutzer ohne JavaScript sieht in dem obigen Beispiel erstmal gar nichts. Das HTML-Grundgerüst muss vom Server ausgeliefert werden. Dazu gibt es die Möglichkeit React-Komponenten serverseitig zu rendern. Clientseitig kann man dann den Aufruf von render durch hydrate ersetzen. Das hat übrigens die nette Nebenwirkung, dass man server- und clientseitig ein und dieselben "Templates" verwenden kann. Spart also enorme Entwicklungszeit.

Download-Volumen von React

Der obige Code funktioniert nicht ohne Teile des React-Frameworks zu laden. React selber hat nicht gerade den schmalsten Fußabdruck und da man in den seltensten Fällen die ganze Library braucht, mutet man dem Nutzer ziemlich viel Overhead zu. Das ist für sich genommen schon ärgerlich, der Effekt wird noch verschlimmert, weil das Runterladen natürlich auch Zeit braucht. Inzwischen gibt es deshalb eine Reihe an Alternativen zu React, die sich alle damit brüsten schmaler zu sein, Vue.js zum Beispiel. Auf der anderen Seite gibt es aber inzwischen auch sehr viele Entwickler-Tools, um nur die Teile aus React auszuliefern, die auch tatsächlich benötigt werden. "Tree-Shaking" und "Code-Splitting" sind zwei gute Stichworte für weitere Recherchen.

Overhead von React

Neben dem Download-Volumen hat React natürlich auch einen Overhead zur Laufzeit auf das JavaScript-Programm. Das laufende Beispiel ist in diesem Punkt mit Sicherheit den Lösungen von Rolf, Jürgen und Gunnar unterlegen. Aber die Prämisse war ja, dass das Projekt eine "hinreichende" Komplexität birgt. Für hinreichend komplex gibt es natürlich keine scharfe Grenze, das muss immer am jeweiligen Projekt entschieden werden. Jedenfalls optimiert React unter der Haube sehr viele DOM-Operationen weg, die oft einen Performance-Engpass bilden. Außerdem spart React-Entwicklung sehr viel Entwicklungsaufwand, der dann bspw. in Performance-Optimierung reinvestiert werden kann.