Bei der Active Group arbeiten wir an mehreren Projekten mit Web-Frontends. Dies ist inzwischen auch für „normale“ Applikationen eine echte Alternative zu den oft umständlichen und eingeschränkten GUI-Toolkits von Java, .NET & Co, da fast alle mit einem „Web-Widget“ geliefert werden, in dem JavaScript/HTML5-Anwendungen laufen können. HTML5 und die reichlich vorhandenen Frameworks für die Web-Programmierung bieten größeren Gestaltungsspielraum als die traditionellen GUI-Toolkits und sind außerdem leichter zu portieren.

Trotzdem macht die DOM-Programmierung mit Javascript (und auch mit vielen Frameworks, die das eigentlich vereinfachen sollen) oft nicht so richtig Freude. Geändert hat sich das für uns mit dem Open-Source-Release von Facebooks Framework React, bei dem Ideen aus der funktionalen Programmierung deutlich zu erkennen sind. Um die Entwicklung noch weiter zu vereinfachen, setzen wir für die React-Programmierung ClojureScript ein, und zwar mit einem eigenen Wrapper für React namens Reacl, der ebenfalls als Open Source verfügbar ist. Um Reacl geht es in diesem Posting, das etwas länger ausgefallen ist.

Das React/Reacl-Modell

In einem vorherigen Posting haben wir bereits in ClojureScript und React eingeführt, aber es lohnt sich, das React und Reacl zugrundeliegende Modell noch einmal Revue passieren zu lassen. Wer traditionelle Javascript-MVC-Frameworks kennt, muss erstmal etwas umdenken - hier ist zu sehen, dass bei vielen Javascript-Entwicklern, die React das erste mal sahen, Skepsis und teils offene Feindseligkeit vorherrscht. Das täuscht hoffentlich nicht darüber hinweg, das React großartig ist.

In normalen MVC-Frameworks sind sowohl das Modell als auch der View mutierbar:

Wenn also ein Benutzer durch eine Interaktion eine Änderung des Modells veranlasst, dann muss der Controller die entsprechende Veränderung im View veranlassen. Das bringt zwei Probleme mit sich:

  • Da Änderungen im Modell direkt Änderungen im View triggern, entstehen in der Anwendung komplexe Callback-Netzwerke, bei denen jede Änderung des Modells explizit an alle Views kommuniziert werden muss. Das ist schwer zu verstehen, schwer korrekt hinzubekommen, schwer zu debuggen und schwer zu ändern.
  • Da Änderungen im View ihrerseits Änderungen im Modell triggern können, entstehen oft Callback-Zykeln, die schwer zu brechen sind.

React vermeidet diese Probleme mit folgendem Bild:

Der View entsteht also durch die Anwendung einer Funktion auf das gesamte Modell. Die Anwendung muss also nicht mehr selbst sicherstellen, dass die Änderung im View der Änderung im Modell entspricht: Das macht React hinter den Kulissen. Außerdem wird der View nur neu generiert, wenn eine Runde Änderungen am Modell abgeschlossen sind. So können keine Zykeln entstehen.

Eine ausführlichere Beschreibung dieses Modells und der Vorteile in der Entwicklung gibt es auch auf Video. (Am besten gleich über das Marketing-Sprech am Anfang auf Minute 7 vorspulen.)

Noch funktionaler mit ClojureScript und Reacl

Während React trotz funktionaler Ideen noch viele imperative Aspekte durchscheinen lässt, haben wir uns mit Reacl zum Ziel gesetzt, das oben skizzierte Modell konsequent umzusetzen.

Dazu gehören zwei zentrale Ideen, die in Clojure-Programmen aufgrund des Fokus auf rein funktionaler Programmierung und Message-Passing öfter anzutreffen sind:

  • Der Zustand der gesamten Applikation sitzt in einem einzigen Objekt, das ausschließlich rein funktional manipuliert wird. Ändert sich der Zustand der Applikation, wird ein neues Objekt substituiert, ohne das alte Objekt zu mutieren.
  • Zustandsänderungen werden durch das Schicken von expliziten Nachrichten veranlasst.

Wir benutzen Reacl zwar schon in Kundenprojekten, trotzdem haben wir mit der Entwicklung erst vor kurzem angefangen, es sind also noch Änderungen zu erwarten. Dieses Posting bezieht sich auf Version 0.4.0.

Reacl am konkreten Beispiel

Dieser Abschnitt führt in Reacl anhand einer winzigen Todo-Applikation ein: Neue Todos können in eine Liste eingetragen und dann abhakt werden. Live zu sehen ist die Anwendung hier:

Hier ist die Namespace-Deklaration, die für eine Reacl-Anwendung typisch ist:

(ns examples.todo.core
(:require [reacl.core :as reacl :include-macros true]
[reacl.dom :as dom :include-macros true]
[reacl.lens :as lens]))

Das Beispiel landet mit dieser Deklaration in einem Namespace namens examples.todo.core. Außerdem importiert es Funktionen und Makros aus dem Namespace reacl.core mit Präfix reacl/ (ClojureScript-Makros werden in Clojure gesondert programmiert, darum muss man sie mit :include-macros auch extra einbinden), aus reacl.dom mit Präfix dom/ und reacl.lens mit Präfix lens/.

Der Namespace reacl.core definiert die zentralen, oben beschriebenen Konzepte und reacl.dom Funktionen für die DOM-Manipulation. Der Namespace reacl.lens ist eine kleine Library für Linsen, welche es erleichtern, den Applikationszustand zu manipulieren. Eine Linse ist eine Art Zeiger auf einen Teil einer größeren Struktur, der es erlaubt, auf diesen Teil zuzugreifen und diesen auszutauschen. (Linsen sind in Haskell ein echter Hit. Darüber werden wir auch noch ein Posting schreiben.) Sie ersparen uns, mit „Ids“ o.ä. hantieren zu müssen, um bestimmte Teile des Applikationszustands zu identifizieren.

Einzelne Todos

Die Todo-Applikation verwaltet den Applikationszustand - also die Liste von Todos - als Liste von Objekten des Typs Todo:

(defrecord Todo [text done?])

Eine Reacl-Applikation definiert Klassen von Komponenten, die in das DOM einer Web-Anwendung eingehängt werden können. Entsprechend gibt es eine Klasse to-do-item für einzelne Todos, und jedes Todo ist eine Instanz der Klasse - eine Komponente. Hier ist der Anfang der Klassendefinition für to-do-item:

(reacl/defclass to-do-item
this todos [lens]
...)

In der Klassendefinition können drei lokale Variablen verwendet werden: Die Bedeutung von this und todos ist in Reacl vordefiniert, lens ist hingegen ein Parameter der Klasse:

  • this ist die Komponente.
  • todos ist der Applikationszustand, also die Liste aller Todos.
  • lens ist ein Zeiger innerhalb der Todo-Liste auf dieses Todo.

Jede Reacl-Klasse muss eine Render-Methode definieren, die aus dem Applikationszustand und den Parametern den View generiert:

(reacl/defclass to-do-item
this todos [lens]
render
(let [todo (lens/yank todos lens)]
(dom/letdom
[checkbox (dom/input
{:type "checkbox"
:value (:done? todo)
:onChange #(reacl/send-message! this
(.-checked (dom/dom-node this checkbox)))})]
(dom/div checkbox
(:text todo))))
...)

Der Ausdruck nach render muss ein virtuelles DOM-Objekt liefern. Dazu benutzt er lens/yank, um aus der Todo-Liste „dieses“ Todo herauszuholen. Der dom/div-Ausdruck macht ein DOM-Objekt (ein div-Element) aus einer Checkbox (zum Abhaken des Todos) und dem Text des Todos. Die Checkbox wird mit dem dom/input-Ausdruck erzeugt, der ein entsprechendes input-Element liefert. Die Map mit den Schlüsseln :type, :value und :onChange steht für die HTML-Attribute type, value und onChange. (Die Doppelpunkte kennzeichnen sogenannte Keywords in ClojureScript, als effiziente Schlüssel in die Map fungieren.)

Wichtig am input-Element ist die Callback-Funktion am onChange-Attribut: Diese schickt eine Nachricht an die Komponente (also an this): true, wenn der Haken gesetzt ist, sonst false. Dieser boolesche Wert muss aus dem „echten“ DOM extrahiert werden, den dom/dom-node liefert - dort ist er der Wert des checked-Felds. (Der Punkt in .-checked steht für den Zugriff auf ein Feld, das - besagt, dass ein Feld ausgelesen wird und nicht eine Methode aufgerufen.) Damit dom/dom-node weiß, für welchen virtuellen DOM-Knoten sie den echten DOM-Knoten liefern soll (nämlich das input-Element), muss das input-DOM einen Namen bekommen - das passiert mit dom/letdom, das ähnlich wie let funktioniert, aber speziell für diesen Zweck gemacht ist.

Damit kann die Komponente schon einmal ein Todo darstellen. Es fehlt noch der interaktive Aspekt. Dazu kommt zur defclass-Form noch eine handle-message-Klausel dazu, die den Wert als Argument bekommt, der mit reacl/send-message! verschickt wurde:

(reacl/defclass to-do-item
this todos [lens]
render ...
handle-message
(fn [checked?]
(reacl/return :app-state
(lens/shove todos
(lens/in lens :done?)
checked?))))

In diesem Fall führt das Abhaken der Checkbox dazu, dass der Applikationszustand durch einen neuen ersetzt werden muss: Der Wert des done?-Felds des aktuellen Todos soll ersetzt werden. Dies macht der lens/shove-Ausdruck oben, der eine neue Liste von Todos liefert. (Mehr zu Linsen - wie gesagt - in einem späteren Posting.) Der Aufruf von (reacl/return :app-state ...) signalisiert Reacl, dass der Applikationszustand ausgetauscht werden soll.

Fertig ist die to-do-item-Klasse! Die Klasse to-do-item kann jetzt als Funktion benutzt werden, die zwei Parameter hat: Die Komponente, in die das Todo eingebaut wird sowie die Linse lens.

Die Todo-Liste

Die gesamte Todo-Applikation besteht aus einer Liste von to-do-items sowie einem Textfeld für neue Todos. Die Todo-Applikations-Komponente kennt also zwei Arten der Benutzer-Interaktion: Text wird eingetippt und schließlich der Add-Knopf (oder Return) gedrückt. Um beide Aktionen zu repräsentieren, benutzen wir zwei Record-Definitionen:

(defrecord New-text [text])
(defrecord Submit [])

Der bisher eingetippte Text ist „irgendwie Zustand“, aber kein Applikationszustand: Er geht erst in den Applikationszustand ein, wenn Add/Return gedruckt wird. Davor ist er reiner lokaler „GUI-Zustand“, der lokal zur Komponente gehört.
Diese Sorte Zustand unterscheidet Reacl vom Applikationszustand, und er kann bei der Klassendeklaration als zusätzlicher Parameter angemeldet werden:

(reacl/defclass to-do-app
this todos local-state []
initial-state ""
...)

Es reicht übrigens nicht, den Text erst bei Bedarf aus dem DOM-Knoten auszulesen, da der Message-Handler keinen Zugriff auf das DOM hat. Dies entkoppelt außerdem den GUI-View von der Reaktion auf Benutzereingaben, was die Softwarearchitektur verbessert.

Die initial-state-Klausel legt den Anfangszustand bei der Erzeugung der Komponente fest - noch kein Text da. Der render-Ausdruck kann local-state als Wert des Textfelds in das DOM einbauen:

(reacl/defclass to-do-app
this todos local-state []

initial-state ""

render
(dom/div
(dom/h3 "TODO")
(dom/div (map-indexed (fn [i todo]
(dom/keyed (str i) (to-do-item this (lens/at-index i))))
todos))
(dom/form
{:onSubmit (fn [e _]
(.preventDefault e)
(reacl/send-message! this (Submit.)))}
(dom/input {:onChange (fn [e]
(reacl/send-message! this
(New-text. (.. e -target -value))))
:value local-state})
(dom/button
(str "Add #" (+ (count todos) 1)))))
...)

Zu beachten ist hier, dass es zwei Callbacks gibt, die jeweils eine unterschiedliche Nachricht mit reacl/send-message! verschicken. (Die dom/keyed Funktion nummeriert die Todos durch, damit React sie intern bei Änderungen zuordnen kann.)

Schließlich fehlt noch die handle-message-Klausel:

(reacl/defclass to-do-app
this todos local-state []
...
handle-message
(fn [msg]
(cond
(instance? New-text msg)
(reacl/return :local-state (:text msg))

(instance? Submit msg)
(reacl/return :local-state ""
:app-state (concat todos [(Todo. local-state false)])))))

Diese unterscheidet zwischen den zwei Nachrichten-Typen und transformiert den Zustand entsprechend. Die :local-state-Klausel von reacl/return sorgt dafür, dass der lokale Zustand ersetzt wird.

Fazit

Das Reacl-Modell unterscheidet sich deutlich von anderen MVC-Frameworks für Javascript: Programmierer müssen sich nicht darum kümmern, die DOM bei Modell-Änderungen gerade passend zu ändern. Stattdessen generiert das Programm den View einfach neu und dieser ist damit immer konsistent. Das vereinfacht die Programmierung radikal. React kümmert sich darum, das DOM effizient dem Browser zu vermitteln. Dabei ist React überraschend performant - React-Programme sind oft schneller als traditionelle MVC-Programme.

Reacl-Klassen zentralisieren die Transformation von Zustand in den handle-message-Klauseln der Klassen. Dies macht es einfach, die möglichen Zustandsänderungen im Überblick zu behalten.

Das gesamte Beispiel ist im Verzeichnis examples/todo im Reacl-Projekt zu finden. Dort finden sich noch weitere Beispiele - hoffentlich viel Spaß dabei!