This blog post is also available in English
Previously on ‘just add Code’
Der erste Teil der Vorstellung hat in einem einfachen Beispiel gezeigt wie wir Progressive Enhancement mit Stimulus umsetzen. In diesem Teil werden wir das gleiche Beispiel mit Hilfe von Catalyst durchspielen und sehen, wo die Unterschiede und Gemeinsamkeiten liegen.
Zur Erinnerung noch einmal unser Beispiel: wir nehmen ein einfaches <input>
Feld und geben diesem – wenn der Browser unseren Code lädt – die Möglichkeit, den Inhalt des Textfelds entweder automatisch in die Zwischenablage zu kopieren, oder den Inhalt der Zwischenablage in das Textfeld einzufügen. Da das nötige JavaScript API noch vergleichsweise neu ist und noch nicht in allen Browsern gleich gut funktioniert nutzen wir dies als Chance, Progressive Enhancement noch weiter zu ziehen und nur an Funktion anzubieten, was auch wirklich funktioniert.
Die beiden Buttons sind dabei initial ausgeblendet, weil wir sie nur anbieten wollen, wenn sie auch etwas sinnvolles tun können:
Das komplette Beispiel steht als öffentliches GitHub-Repository zur Verfügung und kann damit einfach lokal nachvollzogen werden.
Catalyst
Catalyst ist eine auf Typescript basierende Library, die einem bei der Erstellung von Custom Elements hilft. Im Gegensatz zu Stimulus ist das Ergebnis von Catalyst Code also immer ein neues Custom Element – allerdings ohne, dass man selbst die Definition oder Registrierung beim Browser macht. Man kann sich also voll auf die Funktionalität konzentrieren.
Catalyst setzt recht weitgehend auf Typescript Decorators, um die Menge an Boilerplate klein zu halten. Da diese in Typescript selbst noch ein experimentelles Feature sind, muss man sie explizit in seiner tsconfig.json aktivieren, wenn man das Projekt aufsetzt. Sollte das aus irgendeinem Grund nicht möglich sein, zeigt die Catalyst-Dokumentation auch immer, was hinter den Kulissen passiert, so dass man die notwendige Funktionalität nachbauen kann – eben einfach mit mehr Code.
Die Element Klasse
In Catalyst ist die zentrale Implementierung somit eine Ableitung des generischen HTMLElement
, wie es in der DOM API definiert ist. Dies erweitert man noch mit dem von Catalyst zur Verfügung gestellten @controller
Decorator um die notwendige Registrierung im Browser zu triggern:
Den Namen für unser neues Custom Element leitet Catalyst für uns aus dem Namen der Klasse ab. Ein allfällig vorhandenes Element
wird entfernt und das CamelCasing wird in kebab-casing umgewandelt. So haben wir selbst Einfluss auf das Naming. Im Beispiel wird aus EnhancedInputElement
der Element-Name <enhanced-input>
.
Da wir connectedCallback()
– den Standard-Callback des Custom Element API, den der Browser aufruft wenn Custom Elements in den DOM gehängt werden – implementiert haben, sollten wir nun die Meldung EnhancedInputElement connected
in der Browser-Konsole sehen.
Binding von vorhandenem Markup
Auch in Catalyst heisst das Konzept, mit dem man Child-Elemente innerhalb seines Custom Elements an Properties bindet “Targets”. Mit Catalyst definieren wir die notwendigen Properties aber selbst und können so auch die Typen aus dem DOM API nutzen. Um das Binding zu erzeugen annotiert man diese Properties mit @target
oder @targets
. Catalyst übernimmt dann das Suchen innerhalb des Scopes des Custom Elements und die Definition der notwendigen Getter (die Dokumentation zeigt recht schön, wie Targets ohne Decorators funktionieren).
Im HTML vervollständigt man dieses Binding, indem man den entsprechenden Elementen ein data-target
Attribut hinzufügt, dass in der Form {controller-name}.{targetName}
angibt, an welche Property das Element gebunden werden soll.
Neben dem hier gezeigten @target
gibt es auch die Annotation @targets
, die anstelle eines einzelnen Elements ein Array an Elementen bindet (also intern querySelectorAll
anstelle von querySelector
abbildet). Demzufolge kann der gleiche Name innerhalb von mehreren data-target
Attributen verwendet werden.
Außerdem bezieht sich Catalyst mit Targets immer implizit auf den Scope ‘innerhalb des Elements’. Damit lassen sich Custom Elements schachteln und Elemente können zu mehreren Controllern gehören, indem mehr als eine Deklaration als Wert von data-target
angegeben wird.
Nun, da die Elemente effektiv mit unseren Properties verbunden sind, können wir diese nutzen, um aus unserem Controller heraus die Buttons basierend auf den Fähigkeiten des Browsers zu aktivieren.
Reagieren auf Events
Zum Behandeln von Events definieren wir als erstes unsere Handler als eigenständige Methoden des Controllers. Dabei stehen uns alle Features von Typescript offen. Wir nutzen zum Beispiel async
um einfach und ohne Promises mit dem Clipboard API umgehen zu können.
Hier zeigt sich einer der Unterschiede von Catalyst zu Stimulus. Da alle Properties und Methoden in der gleichen Klasse sind und somit im gleichen Namensraum existieren muss ich selbst für Eindeutigkeit und sprechende Namen sorgen, also die Referenzen auf die <button>
Elemente z.B. mit xxxButton
benennen.
Das Konzept um Events aus dem DOM an Methoden des Controllers zu binden heisst in Catalyst ebenfalls “Actions”. Wie vorher für die Targets gibt es nur ein Attribut, mit dem man alle Elemente erweitert von denen man Events behandeln will: data-action
.
In data-action
schreibt man in der Form {event}:{controller}#{method}
- Welches Event (
{event}
) des Elements man an - welche Methode (
#{method}
) - welches Controllers (
{controller}
) binden (:
) will
Wiederum sind mehrere Events definierbar, indem man sie durch Leerzeichen trennt data-action="{event-1}"{controller-1}#{method-1} {event-2}:{controller-2}#{method-2}"
. Analog zu Targets kann der Controller dabei irgendein Controller sein, der im DOM-Tree oberhalb des annotierten Elements steht, das Schachteln von Controllern ist also wieder unterstützt.
Konfiguration über Attribute
Wir wollen unsere “Enhanced Input” Komponente wieder flexibler gestalten, indem wir es erlauben, deklarativ über Attribute zu definieren, ob Copy oder Paste enabled sein sollen (wenn der Browser dies auch unterstützt).
Catalyst nennt das Konzept für das notwendige Binding “Attrs”. Mit dem dabei definierten @attr
Decorator kann man wieder einfach Properties in seinem Controller annotieren und Catalyst übernimmt das notwendige Binding auf ein HTML-Attribut. Die Attribute folgen dem Namens-Schema data-{attr-name}
.
Als Datentypen unterstützt @attr
bisher nur string
, boolean
und number
, garantiert dafür aber, dass diese immer einen Wert haben (jeder Datentyp hat also auch einen Default) und sie nie null
oder undefined
sind. Ausserdem kann man in der Typescript Definition einfach selber einen Default als Teil der Deklaration setzen.
Der Support für boolean
Attribute ist im Detail ein wenig tricky. Denn Catalyst macht nicht etwa eine Konvertierung der Strings “true” und “false” aus einem Attribut in ein boolean, sondern es verwendet hasAttribute
zu prüfen, ob das Attribut existiert. Sobald das entsprechende Attribut auf dem Element definiert ist, ist der Wert der Property im Controller immer true
– unabhängig vom Inhalt. Erst wenn man das Attribut entfernt ist der Wert der Property false
– also analog, wie z.B. required
auf Form-Elementen funktioniert.
In unserem Beispiel bedeutet dies, das wir unsere enabled
Attribute immer dann hinschreiben müssen, wenn wir die Funktion aktivieren wollen.
Möchte man über Änderungen von Attributen benachrichtigt werden gibt es keinen speziellen Mechanismus, sondern man muss attributeChangedCallback()
implementieren, so wie es vom Custom Elements API definiert wird. Damit man korrekt benachrichtigt wird muss man dann auch den observedAttributes
Getter implementieren und die Attribute angeben, für die man sich interessiert. Ausserdem gilt es zu beachten, dass bei diesem Callback der alte und der neue Wert des Attributs nicht unbedingt unterschiedlich sind.
weitere Goodies
Catalyst bleibt auch mit weiteren Features sehr nah an dem, was die Webplattform bietet und versucht dessen Nutzung einfacher zu machen. So unterstützt Catalyst mit Templates eine Möglichkeit innerhalb seines Custom Elements Elemente zu definieren, die erst angezeigt werden, wenn auch das JavaScript geladen und Interaktivität gegeben ist. Die entsprechenden Inhalte werden in einem <template data-shadowroot>
Element definiert und dann automatisch in den ShadowDOM des Custom Elements eingehängt. Unser Beispiel sähe dann so aus:
Der komplette interaktive Teil würde also im Normalfall nicht gerendert, sondern erst wenn das Custom Element geladen wurde. Wird JavaScript nicht ausgeführt produzieren wir also ein leeres Element!
Die Catalyst-Macher dokumentieren auch (absolut zu Recht), dass man mit diesem Feature sparsam umgehen sollte, weil es natürlich den Gedanken von Progressive Enhancement kaputt machen kann. Sie sehen es als Möglichkeit komplexeres HTML erst einzubinden, wenn man wirklich weiss, dass das dazugehörige JavaScript auch funktioniert.
Mit der aktuellen Implementierung ist es leider nicht möglich Inhalte innerhalb und ausserhalb eines <template>
Elements zu definieren um diese zu kombinieren. Der Inhalt des <template>
Elements ersetzt immer den gesamten Inhalt unseres Custom Element. Um immer noch progressive Enhancement zu erlauben müsste unser Beispiel also so aussehen:
Der positive Seiteneffekt von diesem Feature ist, dass alle Funktionen, die ich oben erwähnt habe auch transparent auf einem Element mit ShadowDOM funktionieren. Catalyst übernimmt die notwendige Traversierung, so dass es aus Nutzersicht egal ist, wo der eigene Content zu Hause ist.
Fazit Catalyst
Catalyst fühlt sich in der Verwendung schlank und effizient an. Ich kann einfach meine Custom Elements definieren und wichtige Elemente und Attribute binden, ohne dass es für mich viel Code zu schreiben gilt – im Gegenteil, die wichtigen Dinge verstecken sich hinter einfachen Decorators. Auch die einheitlichen und kurzen Namen der data
Attribute sind schön (und das Handling von boolean
Attributen stört nicht allzusehr).
Gleichzeitig ist Catalyst die ganze Zeit nah am Standard. Ich muss nicht ein neues, eigenständiges API begreifen, sondern kann mein Wissen über Custom Elements 1:1 weiterverwenden und einfach an der einen oder anderen Stelle auf meine eigene Implementierung verzichten.
Dass die ganze Library auf Typescript basiert hilft insofern weiter, als dass man somit auch den Rest des DOM APIs typisiert nutzt und damit Editor Support für Methoden und Properties bekommt.
Wenn man offene Fragen hat, wird es etwas schwieriger Antworten zu finden. Denn bei GitHub selbst existieren eine ganze Reihe anderer Repositories unter dem gleichen Namen. Bei Stack Overflow ist der Tag bereits für ein Perl Webframework belegt. Und mit da die erste Version erst am 12. März 2020 schienen ist, gibt es auch sonst nicht viele Erklärungen im Internet. Auf der anderen Seite ist der eigentliche Quellcode mit nur 9 Source-Dateien auch mehr als überschaubar. Direkt im Source nachzusehen ist also nicht die schlechteste Lösung.
Vergleich Catalyst und Stimulus
Wie wir gesehen haben, sind die beiden Libraries sehr ähnlich – sie folgen den gleichen Ideen und haben weitgehend sogar die gleichen Namen für die unterstützten Konzepte (Catalyst hat sich hier nach eigener Aussage weitgehend von Stimulus inspirieren lassen).
Lediglich der Ansatz ist leicht unterschiedlich. Catalyst setzt auf eine Sprache mit Typ-Support und versucht wirklich nur vom bestehenden Web Components Standard ein wenig zu abstrahieren und Boilerplate Code einzusparen. Zur Laufzeit im Browser ist das Ergebnis von einem handgeschriebenen Custom Element nicht zu unterscheiden. Als Entwickler muss man allerdings selber sicherstellen, dass der Browser alle nötigen Features unterstützt, oder selber die nötigen Polyfills inkludieren.
Stimulus dagegen senkt die Eintrittsbarriere weiter und bietet seine ganze Funktionalität in plain vanilla JavaScript. Außerdem verlässt es sich nicht darauf, dass der Browser Custom Elements oder Mutation Observer unterstützt, sondern bringt diese Funktionen selber mit. Es setzt also auch auf der Client-Seite möglichst wenig voraus
Feature | Catalyst | Stimulus |
---|---|---|
Sprache | TypeScript | JavaScript |
Grösse im Browser | 9kB |
80kB |
Features | Element-Binding, Action-Binding, Attribut-Binding, Shadow-DOM Templates | Element-Binding, Action-Binding, Attribut-Binding, logische CSS-Klassennamen |
API | Analog dem W3C Custom Element API | Durch Stimulus definiert |
Support | Github Issues, Stack Overflow | dedizierte Hotwire Community, GitHub Issues |
Beide Libraries sind hervorragend geeignet, um einen dabei zu unterstützen interaktive Elemente in Form von (oder zumindest analog zu) Custom Elements zu implementieren und dabei Progressive Enhancement als Konzept nicht aus den Augen zu verlieren. Wie man aus der Zusammenfassung sicherlich schon herauslesen kann, finde ich dabei Catalyst die schönere Lösung, weil sie sich näher an dem bewegt, was der Browser nativ implementiert und damit direkter Wissen schafft, dass sich langfristig und anderweitig verwenden lässt. Und die deutlich kleinere Implementierung ist es obendrein.
Ich danke meinem Kollegen Robert Glaser für sein Feedback zu einer älteren Version dieses Texts. Das Titelbild ist von Yancy Min auf Unsplash.