Just add Code

Hotwire Stimulus für Progressive Enhancement mit Web Components (Teil 1)

Will man in einer Webapplikation auf dem Browser Logik ausführen, so bieten sich mittlerweile Web Components an um mit sinnvollem Scoping Code und Markup zu senden. In den üblichen Beispielen führt das leider dazu, dass man im Browser ohne JavaScript nichts sieht, weil der gesamte Inhalt der Web Component in JavaScript generiert wird. Ich zeige gleich zwei Bibliotheken, die einen anderen Ansatz verfolgen und es einfach machen, im Sinne von Progressive Enhancement Web Components dazu zu nutzen, bestehendes Markup mit mehr Funktionalität zu versehen. Dieser Teil behandelt Hotwire Stimulus und in Teil 2 werden wir einen Blick auf GitHubs Catalyst werfen.

Äpfel & Birnen

Web Components sind eine einfache Möglichkeit, im Browser Funktionalität in isolierten Kontexten hinzuzufügen. Custom Elements erlauben es uns unser eigenes Markup-Element zu erzeugen: <my-element>. Dieses Element bildet den isolierten Kontext, denn wir können einfach mit seinem Inhalt interagieren und auf Ereignisse, die darin passieren, reagieren. Solange der Browser das Element nicht kennt, ignoriert er es einfach – somit ist es für uns ohne Konsequenzen, unser Markup einfach schon mal hinzuschreiben. Im Folgenden werde ich nur noch von Custom Elements sprechen, weil wir weder Templating noch Shadow DOM benutzen werden.

Schaut man sich nun aber gängige Beispiele für Custom Elements an, werden diese meist so verwendet: <my-element></my-element>. Der eigentliche Inhalt des Elements wird erst erzeugt und gerendert, wenn das neue Element im Browser registriert worden ist – also nur, wenn der notwendige JavaScript Code korrekt läuft. Bekannte Frameworks wie Stencil und LitElement propagieren dies auch als den normalen Weg.

Nichts hält uns aber davon ab, Custom Elements zu verwenden, um Progressive Enhancement zu betreiben. Wenn wir unser Custom Element als Wrapper für bestehendes, sinnvolles Markup nutzen, lässt sich so Mehrwert hinzufügen:

<my-element>
  <p>Your API Keys</p>
  <input type="text" name="api-key" value="A8DAF06123B7"/>
</my-element>

Mit diesem Setup könnten wir – wenn der Browser den Code für unser Custom Element ausführt – zum Beispiel eine Möglichkeit hinzufügen, den Text in die Zwischenablage zu kopieren, so dass er einfach an einer anderen Stelle weiterverwendet werden kann.

Unser Beispiel

Wir nehmen dieses Beispiel, um exemplarisch zu zeigen, wie Stimulus (in diesem Post) und Catalyst (in Teil 2) bei der Entwicklung von Custom Elements helfen und dabei gleichzeitig Progressive Enhancement fördern – denn sie machen es einfach, mit bestehendem Markup zu interagieren, anstatt erst alles zur Laufzeit zu erzeugen.

Wir starten für unsere Experimente mit einer einfachen Struktur, in der wir vorsehen, den Inhalt eines Textfelds entweder automatisch in die Zwischenablage zu kopieren, oder den Inhalt der Zwischenablage in das Textfeld einzufügen. Das nötige JavaScript API ist vergleichsweise neu und funktioniert noch nicht in allen Browsern gleich gut. Wir nutzen dies als Chance, auch diese Unterschiede noch abzufangen und nur anzubieten, was auch funktioniert:

<body>
  <main>
    <h1>Progressive Custom Element Demo</h1>
    <section class="demo">
      <input type="text" name="input" />
      <button class="hidden">Copy</button>
      <button class="hidden">Paste</button>
    </section>
  </main>
</body>
Unser HTML Grundgerüst

Die beiden Buttons sind dabei initial ausgeblendet, weil wir sie nur anbieten wollen, wenn sie auch etwas sinnvolles tun können:

.hidden {
    display: none;
}

Das komplette Beispiel steht als öffentliches GitHub-Repository zur Verfügung und kann damit einfach lokal nachvollzogen werden.

Was sind Progressive Enhancement und Custom Elements?

Stimulus

Stimulus habe ich ja schon im Artikel zu Hotwire in Teilen vorgestellt. Wie dort erwähnt, setzt Stimulus vollständig auf bestehendes Standard-Markup und hat technisch nichts mit Custom Elements zu tun. Wir werden also in diesem Teil der Vorstellung am Ende keine Web Component erstellen. Stimulus stellt aber die relevanten Bausteine zur Verfügung, um die gleiche Funktionalität zu erreichen.

Der Controller

Dreh- und Angelpunkt in Stimulus ist ein Controller – eine normale ES6 JavaScript Klasse, die von der durch Stimulus definierten Klasse Controller ableitet:

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  connect() {
    console.log('EnhancedInputController connected');
  }
}
Der Stimulus-Controller

Sobald wir den Controller erstellt (und bei Stimulus registriert haben), können wir Ihn an ein Element im Markup binden, indem wir dem entsprechenden Element ein data-controller Attribut geben, in dem der Name des Controllers steht. Damit erzeugt Stimulus eine Instanz der Klasse und verbindet die beiden.

<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input">
        <input type="text" name="input" />
        <button class="hidden">Copy</button>
        <button class="hidden">Paste</button>
      </div>
    </section>
  </main>
</body>
Binding des Controllers an den DOM

Da wir auch die Lifecycle-Methode connect() implementiert haben, die Stimulus aufruft, wenn es die Controller-Instanz an das DOM-Element gebunden hat, sollten wir nun die Meldung EnhancedInputController connected in der Browser-Konsole sehen.

Binding von vorhandenem Markup

Wie erwähnt ist Stimulus darauf ausgelegt, bestehendes Markup zu referenzieren. Das Konzept dazu nennt Stimulus „Targets“. Targets werden als eine statische Liste von Element-Namen im Controller definiert

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];

  connect() {
    console.log('EnhancedInputController connected');
  }
}
Der Stimulus-Controller mit deklarierten Targets

Die gleichen Namen lassen sich nun nutzen, um im HTML Elemente mit data Attributen auszustatten, die nach dem Schema data-{controller-name}-target benannt sind und deren Wert einer der Strings aus dem targets Attribut im Controller sind:

<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste">Paste</button>
      </div>
    </section>
  </main>
</body>
Annotierte Target Elemente

Anders als gezeigt kann man den gleichen Namen auch mehrfach verwenden, wenn man anstelle eines einzelnen Elements eine Reihe von Ihnen adressieren will.

Stimulus nimmt die Werte aus dem static targets und erzeugt jeweils Properties dazu, so dass man via this.{name}Target (im Beispiel also this.copyTarget) auf das Element zugreifen kann, respektive mit this.has{Name}Target prüfen kann, ob ein entsprechendes Target ausgezeichnet ist. Ein Element im DOM ist dabei auch nicht darauf beschränkt, zu einem einzelnen Controller zu gehören, sondern kann mit diversen data-{controller-name}-target Attributen ausgezeichnet sein.

Mit diesem Werkzeug können wir nun den Controller so erweitern, dass er die Buttons für die wir Support bieten können, aktiviert:

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }
}
Aktivieren der Buttons

Wir prüfen neben navigator.clipboard auch noch, ob die beiden Methoden writeText und readText definiert sind, weil dies wie erwähnt noch nicht einheitlich in allen Browsern ist. Bei mir gibt Firefox 85.0.2 z.B. nur Support für writeText her, aber nicht für readText, während Chromium beides unterstützt.

Reagieren auf Events

In diesem Zustand bieten wir natürlich noch keinen Mehrwert, weil die beiden Buttons nun zwar dynamisch eingeblendet werden, sie aber noch nichts weiter tun.

Als erstes definieren wir also zwei Methoden, die Copy und Paste mit dem Clipboard API implementieren. Da das API asynchron ist, definieren wir die Handler als async und können dann mit await linear das Handling hinschreiben:

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }

  async copy() {
        console.log("copying from the input field");
        await navigator.clipboard.writeText(this.inputTarget.value);
        console.log("✅ done. Go try pasting somewhere.");
  }

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Controller mit Handler-Methoden

Um diese Methoden nun an Events im DOM zu binden nutzen wir Stimulus „Actions“. Hierbei wird das Element, an dessen Event man interessiert ist, wieder mit einem data Attribut erweitert. Diesmal mit data-action.

Als Attributwert verwendet man einen String in der Form {event}->{controller-name}#{method-name}, mit dem man definiert,

  1. Welches Event des Elements ({event}) wir
  2. an welche Methode (#{method-name})
  3. welches Controllers ({controller-name}) binden will (->)

Will man auf mehrere Events reagieren, so lässt sich in data-action eine mit Leerzeichen getrennte Liste definieren: data-action="{event-1}->{controller-name-1}#{method-name-1} {event-2}->{controller-name-2}#{method-name-2}"

<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
  </main>
</body>
Das Markup – erweitert um Actions

Neben dieser grundlegenden Syntax unterstützt Stimulus noch weitere Features wie Shorthand, bei dem man für ‚offensichtliche‘ Bindings den Namen des Events weglassen kann. Oder weitere Features wie once, die bei der Registrierung von Event Listenern im DOM API normalerweise zur Verfügung stehen.

Konfiguration über Attribute

Wenn wir unsere „Enhanced Input“ Komponente nun flexibel einsetzen wollen, sollten wir kontrollieren können, ob Copy oder Paste verfügbar ist, anstatt uns hier nur auf den Browser zu verlassen. Dafür wollen wir natürlich auch nicht immer den Code anpassen, sondern definieren es idealerweise deklarativ direkt im HTML.

Stimulus bietet hierfür das Konzept der „Values“. Diese starten Ihr Dasein wieder als eine Definition im Controller. Unter dem Namen values wird eine statische Objekt-Definition erzeugt, deren Keys die Namen der Attribute sind und deren Value Ihren Datentyp definiert.

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];
  static values = { copy: Boolean, paste: Boolean };

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }

  async copy() {
        console.log("copying from the input field");
        await navigator.clipboard.writeText(this.inputTarget.value);
        console.log("✅ done. Go try pasting somewhere.");
  }

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Controller mit definierten Values

Dem allgemeinen Pattern folgend erzeugt Stimulus für die so deklarierten Values wieder Properties nach dem Schema this.{name}Value und this.has{Name}Value – für uns also this.copyValue und this.pasteValue, die man einfach auslesen oder zuweisen kann.

Unser Beispiel nutzt nur Boolean als Typ, weil wir nur Schalter brauchen. Stimulus Values unterstützen aber Array, Boolean, Number, Object und String als Datentypen. Stimulus übernimmt dabei die Konvertierung, so dass man auf die generierten Properties zugreifen kann, als ob sie den deklarierten Datentyp haben.

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];
  static values = { copy: Boolean, paste: Boolean };

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText && this.copyValue === true) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText && this.pasteValue === true) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }

  async copy() {
        console.log("copying from the input field");
        await navigator.clipboard.writeText(this.inputTarget.value);
        console.log("✅ done. Go try pasting somewhere.");
  }

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Values werden im Code verwendet

Als zusätzliche Funktion kann man auch noch Methoden nach dem Namensschema {name}ValueChanged definieren, die immer dann von Stimulus aufgerufen werden, wenn der Wert des Values ändert.

Auf der HTML-Seite werden die Values wieder über data Attribute auf dem Controller definiert, diesmal folgt das Attribut dem Naming-Schema data-{controller-name}-{valueName}-value.

<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-copy-value="true"
           data-enhanced-input-paste-value="true">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
    <h1>Copy disabled</h1>        
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-copy-value="false"
           data-enhanced-input-paste-value="true">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
    <h1>Paste disabled</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-copy-value="true"
           data-enhanced-input-paste-value="false">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
  </main>
</body>
Unterschiedliche Werte für die Values

Leider kann man über das static values keine Defaults definieren. Will man also kein Attribut definieren, dann muss der Default, den Stimulus pro Datentyp vorgibt, passen. Was er im Beispiel hier nicht tut, da der Default für Boolean false ist, wir aber eigentlich gern per Default beide Buttons aktiviert haben wollen.

weitere Goodies

Als weiteren Schritt, die eigene Komponente flexibel zu gestalten, bietet Stimulus das Konzept von „CSS Classes“. Die Idee dahinter ist durch CSS gesteuertes Verhalten der Komponente von den eigentlichen CSS-Klassennamen zu entkoppeln.

Zum Beispiel könnte ich in meiner Komponente visualisieren wollen, ob der Inhalt okay ist, oder noch Validierungsfehler hat. Am einfachsten gebe ich meiner Komponente dafür eine CSS-Klasse, die die entsprechende Darstellung auslöst. In unserem Beispiel nehmen wir das initiale Verstecken der beiden Buttons als die Dynamik, die wir via CSS gestalten.

Anstatt nun den Namen der CSS-Klasse, die diese Veränderung hervorruft, im JavaScript hart zu kodieren, kann man in seinem Stimulus Controller eine weitere statische Definition hinzufügen:

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];
  static values = { copy: Boolean, paste: Boolean };
  static classes = ["hidden"];

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText && this.copyValue === true) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText && this.pasteValue === true) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }

  async copy() {
        console.log("copying from the input field");
        await navigator.clipboard.writeText(this.inputTarget.value);
        console.log("✅ done. Go try pasting somewhere.");
  }

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Controller mit Definition für CSS Classes

Damit erzeugt Stimulus wiederum Properties für uns, deren Wert wir über this.{name}Class auslesen und als logischen Wert im Code benutzen können.

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];
  static values = { copy: Boolean, paste: Boolean };
  static classes = ["hidden"];

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText && this.copyValue === true) {
            this.copyTarget.classList.toggle(this.hiddenClass);
        }
        if (navigator.clipboard.readText && this.pasteValue === true) {
            this.pasteTarget.classList.toggle(this.hiddenClass);
        }
    }
  }

  async copy() {
        console.log("copying from the input field");
        await navigator.clipboard.writeText(this.inputTarget.value);
        console.log("✅ done. Go try pasting somewhere.");
  }

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Zugriff auf die Properties der CSS Classes

Auf der HTML-Seite können wir nun wieder deklarativ ein data Attribut an unser Controller-Element hinzufügen, in dem wir angeben, wie der konkrete Name der CSS Klasse für den jeweiligen Verwendungszweck in unserem konkreten Fall ist. Das Attribut folgt dabei dem Namensschema data-{controller-name}-{css-class-name}-class und definiert in seinem Wert die tatsächlich zu nutzenden CSS-Klasse.

<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-hidden-class="hidden" 
           data-enhanced-input-copy-value="true"
           data-enhanced-input-paste-value="true">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
    <h1>Copy disabled</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-hidden-class="hidden" 
           data-enhanced-input-copy-value="false"
           data-enhanced-input-paste-value="true">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
    <h1>Paste disabled</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-hidden-class="hidden" 
           data-enhanced-input-copy-value="true"
           data-enhanced-input-paste-value="false">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
  </main>
</body>
Mit definiertem Wert für die 'hidden' Klasse

Wenn man alles zusammensteckt

Mit all diesen Dingen zusammen haben wir nun unser Beispiel in einer funktionierenden Form.

Das fertige Beispiel
Das fertige Beispiel

Wie man sieht, ist auch in Browsern, in denen sowohl Copy wie auch Paste möglich sind, noch die eine oder andere Hürde zu nehmen. Aus Sicherheitsgründen (in der Zwischenablage könnte ja auch gerade das Passwort aus dem Passwortmanager sein) muss noch die Erlaubnis erteilt werden, auch aus dem Clipboard zu lesen. Generell ließe sich diese Komponente aber nun flexibel verwenden, ohne dass der Code angepasst werden muss. Und sollte JavaScript nicht funktionieren, wäre zumindest immer noch ein <input> Feld vorhanden.

Fazit und Ausblick

Ich habe es schon im ersten Artikel gesagt: die Tools aus dem Hotwire Bundle fühlen sich generell rund und solide an. Die gebotenen Funktionen sind umfassend und bieten Parität mit dem, was das DOM-API auch zur Verfügung stellt, wenn man es braucht. Im Normalfall kann man aber gut hinter der Stimulus Abstraktion leben und die tiefere Integration der Library überlassen. Und dann kommt man tatsächlich mit „normalem“ HTML und data Attributen aus – weiteres Wissen ist nicht notwendig.

Es braucht vermutlich eine Weile, bis man die Syntax der unterschiedlichen statischen Attribute für die magischen Bindings innerhalb des Controllers verinnerlicht hat. Die unterschiedlichen Formen (String-Arrays und Objektdefinition) helfen dabei nur mäßig – immerhin ist das Naming innerhalb der erzeugten Properties aber schlüssig. Wenn man diese Namen verinnerlicht hat, ergibt sich vermutlich auch das Naming der data Attribute im HTML von alleine – so schön ich dieses Feature finde schaue ich doch bisher noch jedesmal nach. Und – zumindest für meinen Geschmack – sind die Namen der Attribute, die sich ergeben, wenn man seine Controller nicht nur copy oder clipboard nennt sehr verbose. Natürlich muss man sie nur einmal hinschreiben, aber ich finde es gleich unübersichtlich.

Am Ende reibe ich mich aber am Meisten daran, dass Stimulus einen weiteren Layer baut. Natürlich ist es super, wenn ich nichts über Custom Elements wissen muss, aber warum sollte ich eigentlich nicht? Schließlich sind diese das eigentliche Web-Fundament und damit vermutlich die stabilere Basis. Und dann tatsächlich sprechende Element-Namen im HTML zu haben anstelle von <div data-controller="foo"> klingt für mich auch eher wie ein weiterer Vorteil.

Das soll kein schlechtes Licht auf Stimulus werfen, im Gegenteil, die Library ist wie gesagt solide, die Dokumentation ist prima, und bei Fragen kann man entweder in den Code schauen oder direkt in der User-Community fragen, die Basecamp auch noch mit anbietet (und in der die Entwickler aktiv sind). Aber Ihr könnt auch genauso gut die Zeit darein investieren, zu lernen, wie richtige Web Components funktionieren.

Im nächsten Teil werden wir uns dann mit Catalyst eine weitere Library anschauen die den oben skizzierten Ansatz unterstützt. In weiten Teilen sind die Features vergleichbar, aber doch unterschiedlich genug um interessant zu bleiben. Bis dahin!

Der Autor dankt seinen Kollegen Robert Glaser und Daniel Westheide für die Kommentare zu einer früheren Version des Artikels. Das Titelbild ist von Claudio Schwarz auf Unsplash.

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie-Vereinbarung zustimmen. Mehr lesen