This blog post is also available in English

Webseiten auf dem Server zu rendern und dann mittels „Progressive Enhancement“ dafür zu sorgen, dass sie auch ohne Full-Reloads funktionieren, wie viele User es erwarten: heute gefühlt ein Nischenthema. Hotwire ist eine neue Sammlung an Tools, die es vereinfacht, mit dieser Strategie zu guten Ergebnissen zu kommen. Es gibt uns für „klassische“ Webapplikationen eine Reihe an Best Practises an die Hand, die ein umfangreiches SPA-Framework überflüssig machen.

„Wir haben ein Backend mit REST-basierten JSON-Schnittstellen und oben drauf dann halt eine Angular-Applikation“

Für viele von uns ist diese Beschreibung einer beliebigen Webapplikation in den letzten Jahren zum Allgemeinplatz geworden. Die Gründe für diese Trennung sind meistens eher „das macht man heute so“ oder „das ist, womit unser Team Erfahrung hat“. Selten macht eine Anforderung wirklich den Einsatz eines der gängigen SPA-Frameworks notwendig. Wir haben an anderen Stellen schon ausführlicher besprochen, was genau die Nachteile sind, die diese „moderne“ Aufteilung mit sich bringt.

Ende letzten Jahres hat das Team um Basecamp das Hotwire Toolkit vorgestellt. Basierend auf Ihren Erfahrungen beim Bau von Basecamp und Hey haben sie eine sehr überschaubare Menge von Javascript-Modulen als Open Source veröffentlicht, die das Verhalten einer SPA herstellen, ohne auf einen soliden SSR-Unterbau zu verzichten.

Die Code-Beispiele, die im folgenden Artikel vorstelle, sind alle in unserem GitHub-Repository zu finden. Dort zeigen wir eine lauffähige Einbindung in eine plain-vanilla Spring Boot Applikation.

Batteries included

Hotwire steht synonym für den gesamten Ansatz: sending HTML Over the Wire, also die Verwendung der nativen Formate des Webs, um die Applikation für Nutzende möglichst schnell und reaktiv und für Developer möglichst eingängig und konsistent zu machen.

Um dieses Zielbild zu erreichen setzt Hotwire auf drei Bausteine:

Wichtig zu wissen ist, dass sich die drei Bausteine wunderbar kombinieren lassen, aber nicht gegenseitig voraussetzen.

Im Folgenden wollen wir sie noch im Detail betrachten.

Turbo

Wie erwähnt übernimmt Turbo eine ganze Reihe von Funktionen, die aufeinander aufbauen. Und obwohl unser Beispiel-Repository die Integration in Spring zeigt, ist die meiste Funktionalität komplett Framework-unabhängig, weil sie nur durch entsprechende Auszeichnung im HTML erreicht wird.

SPA-Style Navigation mit Turbo Drive

Am einfachsten umzusetzen ist die nahtlose Navigation, wie man sie aus SPAs kennt: für die Nutzenden soll kein Reload sichtbar sein und der Übergang zwischen Seiten nahtlos bleiben.

import * as turbo from "@hotwired/turbo"

console.log('Hotwire Demo App JS enabled');
Integration von Turbo

ist alles, was man dafür tun muss – durch den Import wird Turbo geladen und startet selbst eine Session (alternativ kann man Turbo von einem CDN wie unpkg oder skypack einbinden).

Turbo übernimmt dann alle Link-Klicks und Form-Submissions, die innerhalb des gleichen Origins bleiben (also auf dieselbe Protokoll/Hostname/Port-Kombination zeigen). Diese werden im Hintergrund als XHR ausgeführt, die Browser-History wird angepasst (damit Back und Forward korrekt funktionieren). Wenn die Antwort eintrifft, wird der Seiteninhalt, den der Browser im Moment anzeigt, angepasst. Elemente aus dem <head> werden hierbei zusammengeführt, die im <body> durch die neu eingetroffenen ersetzt. Falls möglich, zeigt Turbo Drive schon mal eine Version der Seite aus dem Cache an, die dann mit dem aktuellen Ergebnis aktualisiert wird (stale-while-revalidate), um so für den Nutzer noch schneller ein Ergebnis bieten zu können.

Das Verhalten von Turbo Drive lässt sich über weitere data-Attribute an Elementen in der Seite noch feingranularer kontrollieren. Damit stellt man sicher, dass die Funktionalität genau so zum Einsatz kommt, wie man es beabsichtigt – normalerweise ist der Default aber schon sehr gut. Das Handbuch erklärt die einzelnen Optionen gut im Detail.

Unterteilung einer Seite in Blöcke mit Turbo Frames

Obwohl sich mit Turbo Drive das Updaten einer Seite in den Hintergrund verlegt, will man gelegentlich noch besser kontrollieren können, auf welche Teile einer Seite sich eine Interaktion bezieht.

Als Beispiel können wir uns eine Wiki-Seite denken, in der die einzelnen Abschnitte für sich editierbar sind.

<h3>Section</h3>
<turbo-frame id="section_1">
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit</p>
  <form action="/sections/1/edit" method="post">
    <button type="submit">Edit this section</button>
  </form>
</turbo-frame>
Eine beispielhafte Wiki-Seite

Der Abschnitt, den wir in der Seite isolieren wollen, ist in ein <turbo-frame> Element gewickelt, das ihn einzeln adressierbar macht. Turbo sorgt damit automatisch dafür, dass sich Interaktionen innerhalb dieses „Frames“ wirklich nur auf diesen beziehen. Nach dem Empfang einer Antwort wird nur der Teil der Seite innerhalb des <turbo-frame> aktualisiert (wie in einer SPA, nur ohne zusätzliche Change Detection im virtual DOM).

Wenn nun die Antwort auf POST /sections/1/edit ebenfalls einen <turbo-frame> mit der gleichen id enthält, dann wird nur dieser aus der Response ausgeschnitten und ersetzt den <turbo-frame> der Ursprungsseite.

<body>
  <h1>Editing Section</h1>

  <turbo-frame id="section_1">
    <form action="/sections/1">
      <textarea name="content">Lorem ipsum dolor sit amet consectetur adipisicing elit</textarea>
      <button type="submit">Save</button>
    </form>
  </turbo-frame>
</body>
Die Response mit der neuen Ansicht

In unserem Beispiel würden wir so transparent den Abschnitt unserer Wiki-Seite durch das Bearbeitungsformular ersetzen.

Ob die Reponse dabei nur das <turbo-frame>-Fragment enthält, das den Request ausgelöst hat, oder eine vollständige eigene Seite, ist dabei egal. Wenn man allerdings immer die komplette Seite sendet, ergibt sich von ganz allein, dass das Update auch ohne JavaScript weiterhin funktioniert.

An dieser Stelle möchten wir Dir gerne einen Tweet anzeigen. Um ihn zu sehen, musst Du dem Laden von Fremdinhalten von twitter.com zustimmen.

Lazy Loading mit Turbo Frames

Eines der Dinge, die man mit Turbo Frames „einfach so“ mit dazu bekommt, ist das Lazy Loading von Seitenbestandteilen. So kann man beim Laden einer Seite einzelne Abschnitte nur als leere Fragmente mit Platzhaltern ausliefern und sich den (ggfs. langsamen) Fetch von Daten sparen und noch schneller ein Ergebnis an die Nutzerin liefern:

<article>
<h2>My Blog Post</h2>
<p>Lorem ipsum dolor sit amet.</p>
<h3>✨ these comments are loaded automagically ✨</h3>
<turbo-frame id="comments" src="/message/1/comments">
  <img src="/images/spin.gif" alt="Waiting icon">
</turbo-frame>
</article>
Ein automatisch nachgeladener Turbo-Frame

Das src-Attribut des <turbo-frame> teilt Turbo mit, dass es automatisch einen Request auslösen und von wo der Content geholt werden soll. Die Response wird analog behandelt wie bei einem Request, den der Nutzer auslöst: der entsprechende <turbo-frame> wird ausgeschnitten und ersetzt das statische Markup.

Hotwire beim Lazy Loading von Seiteninhalten
Hotwire beim Lazy Loading von Seiteninhalten

Andere Seitenbereiche aktualisieren

<h3>Comments</h3>
<turbo-frame id="comments">
  <ul>
    <li>Comment 1</li>
    <li>Comment 2</li>
    <li>Comment 3</li>
  </ul>
</turbo-frame>

<h3>✨ Remove comments ✨</h3>
<p>The following form removes comments <em>from the list above</em>.</p>
<p>Submitting it should only trigger a reload of the frame above, not the whole site</p>
<form action="/messages/comments/remove" method="post" data-turbo-frame="comments">
  <button type="submit">Remove comment</button>
</form>
Adressieren anderer Seitenabschnitte

Das Code-Beispiel oben tut noch etwas anderes: es löst einen HTTP-Request aus (der wie gewohnt im Hintergrund stattfindet), wendet das Resultat aber auf einen anderen Bereich der Seite (nämlich den <turbo-frame> mit der id comments) an. Gesteuert wird dies durch das data-turbo-frame-Attribut, das Turbo mitteilt, welcher Frame adressiert werden soll.

Dynamische Updates mit Turbo-Streams

Turbo Streams sind sicherlich das Thema, das die meiste Aufregung nach sich gezogen hat. Schließlich ist es direkt mit ‚WebSockets Live-Updates für eine Webseite‘ vorgestellt worden. Tatsächlich lassen sich Websockets und andere Methoden anbinden, doch dazu später mehr. Zuerst wollen wir uns anschauen, was Turbo Streams eigentlich tut.

Turbo Streams erlaubt es in einer Response mehrere Aktionen für eine Webseite auf einmal zu schicken. Dafür definiert es ein eigenes Format für die Response:

<turbo-stream action="append|prepend|replace|update|remove" target="ID">
    <template>
        HTML-Content
    </template>
</turbo-stream>
Grundsätzliches Format einer Turbo-Stream Aktion

Jede Aktion wird von einem <turbo-stream>-Element umschlossen, das in action die Aktion angibt, die ausgeführt werden soll. Das target-Attribut trägt die ID des adressierten Elements. Dabei muss dies kein Turbo-Frame oder sonst etwas sein, sondern ein beliebiges HTML-Element mit entsprechendem id-Attribut (analog, wie man es in document.querySelector() verwenden würde).

Die möglichen Aktionen sind auf die oben angegebenen fünf (append,prepend,replace,update und remove) beschränkt. Schaut man sich die Implementierung an, wird klar: das sind diejenigen, die sich einfach auf die Operationen auf einem Node im DOM API abbilden lassen. Wieder vermeidet Turbo also etwas selbst zu erfinden, zu Gunsten der Webplattform. (Und ehrlicherweise reichen diese Aktionen auch für sehr viele Fälle aus.)

Den eigentlichen Content, den man einfügen will, steckt man in ein <template>-Element, damit es einfach im DOM verwaltet werden kann. Bei der Verarbeitung wird dieses Element in den DOM eingefügt, der Content verwendet und dann das <template>-Element wieder entfernt.

Von diesen <turbo-stream>-Elementen steckt man so viele, wie man will, in seine Response, gibt dem ganzen den Content-Type text/vnd.turbo-stream.html und schon ist die Funktionalität fertig. Turbo prüft für „normale“ Responses (z. B. bei Form-Submits) den Content-Type und appliziert die Turbo-Streams Updates anstelle der normalen Merging-Logik, so lange der Content-Type der Response eben text/vnd.turbo-stream.html lautet.

Alternativ lassen sich Event-Streams zum Übermitteln dieser Updates verwenden. Das hat den Vorteil, dass es nicht unbedingt Nutzer-Interaktion braucht, um Updates zu bekommen. Ausserdem sind Event-Streams eine offene Verbindung zwischen Browser und Server, so dass fortlaufende Updates (wie z. B. für einen Chat oder einen Live-Ticker) möglich.

Da man einen solchen Stream explizit über das Javascript-API bei Turbo registriert, muss dann auch der Content-Type nicht mehr gesetzt werden. Turbo verlässt sich darauf, dass die einzelnen Events als Turbo-Stream Aktionen interpretierbar sind (ist dies nicht der Fall, liest man auf der Konsole davon).

Als Event-Streams funktionieren sowohl Server-sent Events, wie auch Websockets, die jeweils mit ihrer konkreten Klasse im Browser instanziiert werden:

import { connectStreamSource } from "@hotwired/turbo";

this.eventSource = new EventSource("http://localhost:8080/turbo-sse");
connectStreamSource(this.eventSource);
Einbindung eines Server-sent Event Streams
import { connectStreamSource } from "@hotwired/turbo";

this.eventSource = new WebSocket("ws://localhost:8080/stream-updates");
connectStreamSource(this.eventSource);
Einbindung eines Websockets

Auf der Java-Seite ist das Handling dieser Verbindungen ganz normal, da es sich um allgemeine SSE-, respektive Websocket-Verbindungen handelt. Lediglich das Format, in dem die Response geliefert wird, muss den Turbo-Frames-Aktionen entsprechen.

@Controller
public class TurboStreamSSEController {

    private SseEmitter emitter;

    @GetMapping(path="/turbo-sse", produces=MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter handle() {
        this.emitter = new SseEmitter();
        this.emitter.onCompletion(() ->  this.emitter = null);
        return emitter;
    }
Der SSE Controller
@Component
public class TurboStreamWebsocketHandler extends TextWebSocketHandler {

    private WebSocketSession session;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        super.afterConnectionEstablished(session);
        this.session = session;
    }

    private void sendUpdate(String text) throws IOException {
        if (this.session != null) {
            WebSocketMessage<String> message = new TextMessage(text);
            this.session.sendMessage(message);
        }
    }
Der Websocket Controller

Stimulus

Stimulus verfolgt die Idee Elemente einer bestehenden Webseite in Controller zu verwandeln. Diese können auf Events, die sich in Ihrem Scope ereignen, reagieren und somit beliebig komplexe Interaktionen auf einer HTML-Seite ermöglichen. Ausserdem hat ein Controller Referenzen auf alle Elemente die er umfasst und kann diese kontrollieren. Somit lässt sich das bestehende Markup nachträglich anpassen, z.B. indem man einem Textfeld eine Möglichkeit hinzufügt den enthaltenen Text ins Clipboard zu kopieren (inklusive den dafür notwendigen Buttons).

Gleichzeitig bindet man sich nicht daran ein Custom Element zu schreiben, sondern verwendet normales Markup. Diese Vorgehensweise legt einem Progressive Enhancement nahe, weil man die Basis seiner Elemente schon im normalen HTML-Code haben muss, bevor man sie nun mit seinem Controller erweitert.

import { Controller } from "stimulus";
import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo";

export default class ConnectWebsocketController extends Controller {
    static targets = ["toggle"]
    static values = { connected: Boolean }

    eventSource = undefined;

    toggleStream() {
        if (this.connectedValue) {
            disconnectStreamSource(this.eventSource);
            this.eventSource.close();
            this.eventSource = undefined;
            this.connectedValue = false;
            this.toggleTarget.innerText = 'Attach WS Stream';
        }
        else {
            this.eventSource = new WebSocket("ws://localhost:8080/stream-updates");
            connectStreamSource(this.eventSource);
            this.connectedValue = true;
            this.toggleTarget.innerText = 'Detach WS Stream';
        }
    }
}
Ein Beispiel Controller in Stimulus

Die Implementierung erfolgt in einer ES6 JavaScript-Klasse, die die Basis-Klasse Controller, die Stimulus mitbringt, erweitert. Hier definiert man ganz normal die Funktionen, die man zum Erfüllen der Funktionalität braucht.

Das Ableiten von Controller bringt vor allem Mehrwert durch die beiden statischen Felder values und targets. Für diese erzeugt Stimulus automatisch Bindings an data-Attribute im HTML, so dass diese im Code ohne eigenen Boilerplate-Code direkt adressiert werden können. Die Dokumentation geht im Detail auf die Verwendung und die unterstützten Datenformate ein.

Weiterhin bekommt man (analog zu Custom Elements) die Lifecycle-Methoden initialize, connect und deconnect mit denen man notwendigen Setup und Teardown betreiben kann.

Wenn man nicht auf webpack als Bundler setzt, muss man nun noch mit einem minimalen Stück Code den Controller mit einem logischen Namen verknüpfen (analog der Registrierung eines Custom Elements):

import { Application } from "stimulus";

import ConnectSSEController from "./ConnectSSEController.js";
import ConnectWebsocketController from "./ConnectWebsocketController.js";

const application = Application.start();
application.register('connect-websocket', ConnectWebsocketController);
application.register('connect-sse', ConnectSSEController);
console.log("Stimulus controllers registered");
Registrierung von Controllern in Stimulus

Auf der Webseite vervollständigt man nun das Binding: mittels data-controller-Attribut gibt man an, welcher Controller an das Element gebunden werden soll (dabei werden die eben registrierten Namen verwendet):

<div data-controller="connect-websocket" data-connect-websocket-connected-value="false">
  <button data-action="click->connect-websocket#toggleStream" data-connect-websocket-target="toggle">Attach WS Stream</button>
</div>
Binding des Controllers im HTML

Die verschiedenen data-connect-websocket-Attribute folgen der Stimulus Naming-Convention und erlauben so das oben beschriebene Binding an die static Fields des Controllers, das Stimulus automatisch macht.

Zu guter Letzt bindet data-action="click->connect-websocket#toggleStream" einen Click-Handler an den <button> und sorgt dafür, dass dieser die toggle-Methode unserer ConnectWebsocketController-Klasse aufruft.

Einbettung in Spring

Wie eingangs erwähnt haben wir eine einfache Einbettung all der oben gezeigten Funktionen in Spring Boot/Spring Web MVC auf GitHub publiziert. Ehrlicherweise muss man aber sagen, dass die meiste Implementierung nicht wirklich Spring-spezifisch ist – schließlich geht es vor allem darum, die richtigen Attribute oder zusätzlichen Elemente in den eigenen HTML-Templates unterzubringen. So finden sich die meisten Hotwire-spezifischen Dinge also in unseren Templates und würden sich einfach in eine andere Templating Library (wir haben Thymeleaf verwendet) oder auch ein anderes Java-Framework (wie quarkus) übernehmen lassen.

Der größte Klimmzug war die Content-Negotiation und dafür zu sorgen, dass man innerhalb des Spring @Controllers wie gewohnt den Namen eines Templates zurückgeben kann, wenn man aus einer normalen Methode ein Turbo Streams Update zurückgeben will.

Der ViewResolver soll dann aber eben auch nur dann einen View nehmen, der mit dem richtigen Content-Type gerendert wird. Der Haken dabei ist, dass die Turbo Library immer Accept: text/vnd.turbo-stream.html sendet, wir den Turbo-Streams Content-Type aber eben nur für spezifische Responses aktivieren wollen.

Unsere Lösung ist im Moment ein ViewResolver, der seine View-Namen fix konfiguriert bekommt.

public ThymeleafViewResolver turboStreamViewResolver(SpringTemplateEngine engine) {
    ThymeleafViewResolver resolver = new ThymeleafViewResolver();
    resolver.setContentType("text/vnd.turbo-stream.html");
    resolver.setOrder(2);
    resolver.setTemplateEngine(engine);
    resolver.setViewNames(new String[] {"comments-stream"});
    return resolver;
}
Der Turbo Streams-spezifische View Resolver

Wessen Geschmack bei Spring eher in Richtung Webflux geht, dem sei hier das Repository von Josh empfohlen, der eine passende Implementierung zeigt und diese auch sehr lesenswert beschreibt.

Fazit

Hotwire ist ein wenig so, wie die Einführung der SSDs. Es passiert nichts grundsätzlich Neues, aber plötzlich ist das Althergebrachte so einfach und schnell und offensichtlich, dass die Frage aufkommt, warum wir je nach anderen Antworten gesucht haben.

Templating ist an einer Stelle zentralisiert, es gibt keinen ellenlangen Kampf mit webpack oder einer Modulkonfiguration, kein 30 Sekunden langes Warten darauf, dass ein Bundle neu gebaut ist und die 10MB wieder in den Browser geladen wurden.

Die Funktionalität tut ohne Konfiguration normalerweise genau das, was man erwartet und lässt genügend Stellschrauben um sich flexibel anpassen zu lassen. Und das ohne in die Tiefen eines verschachtelten JS APIs herabsteigen zu müssen, sondern deklarativ im Markup. Man merkt die ganze Zeit, dass hier Köpfe am Werk waren, die die Entwicklung von Webapplikationen kennen und um deren Haken und Ösen wissen.

Insbesondere Turbo ist eine runde (und für das, was es bringt, leichtgewichtige) Bibliothek, die einem schrittweise neue Features ermöglicht, die alle ein SPA-Feeling ermöglichen, ohne dass man eine SPA bauen muss.

Turbo Streams ist schon als Response auf normale Form-Submits ein grossartiges Feature. In Verbindung mit Event-Streams fühlt es sich dann auf jeden Fall ein wenig nach Magie an, wenn man sich „einfach so“ Updates in die Seite streamen kann, ohne noch großartig Code dafür schreiben zu müssen.

Stimulus leistet gute Arbeit dabei, Custom Elements zu simulieren, ohne dass man sich direkt an diese bindet. Mit den Target- und Value-Bindings bietet es außerdem gute Wege an, Konfiguration deklarativ in HTML zu hinterlegen, anstatt auf zusätzliche Skripte zu bauen. Man merkt, dass der Fokus sehr darauf liegt, HTML-first zu sein und somit den Progressive-Enhancement-Gedanken zu stärken.

Persönlich finde ich Stimulus eine gute Ergänzung, würde für die gleiche Funktionalität aber eher direkt auf Custom Elements setzen. Ganz einfach weil wir dann direkt auf der Webplattform und nicht auf einer Definition von Basecamp aufsetzen. Libraries, die einem Boilerplate-Code abnehmen, gibt es auch für Custom Elements genug, aber das soll ein Thema für einen anderen Post sein.

In jedem Falle ist Hotwire (welche Teile davon man auch immer nutzen will) einen intensiven Blick wert und ich kann nur allen, die gerade mit etwas Neuem beginnen, raten, es einmal auszuprobieren und sich erst am Ende vom Tag zu fragen, ob es wirklich noch Dinge gibt, die man aus den großen JavaScript-Frameworks vermisst.