Komponierbare Komponenten
Dieser Artikel stellt ein Modell für wirklich komponierbare Webkomponenten vor, aufbauend auf der beliebten Bibliothek React. Komponierbarkeit ist ein Schlüssel zu guter Testbarkeit und maximal wiederverwendbarem Code in der funktionalen Programmierung. Grundkenntnisse in JavaScript und React werden vorausgesetzt.
Funktionskomposition
Unter Komposition versteht man in der funktionalen Programmierung ganz allgemein aus zwei Dingen einer Art, ein Ding derselben Art zu machen. Dabei kann es je nach Art dieser Dinge verschiedenste Möglichkeiten der Komposition geben.
Ein klassisches Beispiel sind Funktionen. Wenn man sich auf einstellige Funktionen beschränkt, kann man diese z. B. leicht hintereinander oder auch nebeneinander1 ausführen:
var f = function (x) { return g(h(x)); } // hintereinander
var f = function (x) { return [g(x), h(x)]; } // nebeneinander
Hier wird aus zwei einstelligen Funktionen g
und h
eine neue
einstellige Funktion f
gebaut. Im „hintereinander“ Fall wird das
Ergebnis der ersten als Argument für die zweite Funktion benutzt. Im
„nebeneinander“ Fall wird ein Tupel aus den jeweiligen Ergebnissen
von g
und h
zurückgegeben.
Die Einschränkung auf einstellige Funktionen stellt dabei keine grundsätzliche Einschränkung der Ausdrucksstärke dar, da man jede Funktion mit mehreren Argumenten immer in eine Funktion mit nur einem Argument umwandeln kann, indem man z. B. alle Argumente in ein Tupel packt.
Ein wichtiges Merkmal der funktionalen Programmiersprachen ist, dass
Funktionen „first class values“ sind, d. h. sie sind genau wie Zahlen
oder Strings Werte der Programmiersprache. Und das wiederum
bedeutet, dass man sogenannte Higher-Order-Funktionen schreiben
kann. Das sind Funktionen die andere Funktionen als Argument erhalten,
oder neue Funktionen erstellen und zurückgeben können. Damit kann man
auch Funktionen schreiben, die eine bestimmte Art der
Funktionskomposition implementieren. Die Hintereinanderausführung wird
dabei oft comp
(kurz für „compose“), die Nebeneinanderausführung oft
juxt
(kurz für „juxtapose“) genannt:
function comp(g, h) {
return function(x) { g(h(x)) }
}
var f = comp(g, h) // hintereinander
function juxt(g, h) {
return function(x) { [g(x), h(x)] }
}
var f = juxt(g, h) // nebeneinander
Die Ergebnisse der Kompositionen (hier jeweils f
) sind dann die
gleichen Funktionen wie oben, aber eben viel kürzer definiert,
mithilfe der Kombinatoren comp
bzw. juxt
.
Webkomponenten
Soviel zur Funktionskomposition, die recht einfach ist, wenn man sich auf einstellige Funktionen beschränkt. In diesem Artikel soll es ja aber um die Komposition von Webkomponenten gehen. Als Webkomponente wollen wir hier einen Teil einer Webanwendung oder Webseite verstehen, in die ein User über eine längere Zeit Eingaben tätigen kann, oder in der sich ändernde Informationen angezeigt werden.2
Das kann im Allgemeinen sehr komplex sein: Die eine Komponente hat zwei Eingaben und drei Werte, die durch sie verändert werden sollen, die nächste zwei Eingaben und eine „Ausgabe“. Dies führt in der Praxis zu sehr spezifischen Komponenten mit sehr viel langweiligem „Boilerplate-Code“ und geringer Wiederverwendbarkeit. Analog zu Funktionen können wir diese Komplexität aber reduzieren, um eine bessere Komponierbarkeit zu ermöglichen.
Dazu definieren wir Komponenten als etwas, das auf einem einzelnen Wert eines bestimmten Typs operiert. Die Komponente sollte dabei einen solchen Wert anzeigen können und, als Reaktion auf eine Aktion des Users, eine Änderung dieses Werts veranlassen können. Dies entspricht in etwa den sogenannten controlled components in React. Das ist, genauso wie bei den Funktionen, keine Einschränkung der Allgemeinheit von Komponenten, da aus mehreren Werten immer ein einzelner gemacht werden kann, und bei einer Änderung des Werts auch nur ein Teil aktualisiert werden kann. Außerdem kann eine Webkomponente auch statisch sein, d. h. den aktuellen Wert ignorieren, und dem User einfach keine Änderung ermöglichen.
Eine einfache Komponente zur Modifikation eines Strings durch den User kann in diesem Modell dann z. B. so aussehen:
function textinput(value, onChange) {
return <input type='text'
value={value}
onChange={function(ev) { onChange(ev.target.value) }}/>;
}
Zu beachten ist, dass die Funktion textinput
selbst die Komponente
darstellt, nicht deren Rückgabe! Jede Funktion, die einen value
und
einen onChange
-Callback als Argument hat, ist eine Komponente. Die
Rückgabe dieser Funktion muss dann ein fertiges „React-Element“ sein;
in diesem Fall ein INPUT-Element.
Die Festlegung, dass jede Komponente so aussehen muss, ermöglicht uns die Implementierung allgemein verwendbarer Kombinatoren und damit die einfache Komponierbarkeit.
Komponierbarkeit
Welche Arten von Kompositionen von Komponenten sind in diesem Modell nun denkbar? Mit HTML-Elementen können zum Beispiel zwei Komponenten „nebeneinander“ gestellt werden. Hier mit einem DIV-Element:
function cdiv(c1, c2) {
return function (value, onChange) {
return <div>{c1(value, onChange)}{c2(value, onChange)}</div>;
}
}
Die Funktion cdiv
ist dabei, analog zu den Funktions-Kombinatoren
comp
und juxt
von oben, ein Kombinator für Komponenten, die eine
neue Komponente auf Basis der beiden Komponenten c1
und c2
zurückgibt.
In diesem Fall findet keine Änderung an den eingehenden oder
ausgehenden Werten der Komponenten c1
und c2
statt. Die erzeugte
Komponente gibt den Wert, den sie bekommt direkt „nach unten“ an c1
und c2
weiter, und jede Änderung, egal von welcher Komponente, wird
direkt „nach oben“ durchgereicht.
Das ist natürlich selten ausreichend. Man will zum Beispiel eine
Komponente erzeugen, die, anstatt auf einem String, auf einem
bestimmten Feld eines Objekts arbeitet, das einen String enthält. Wir
können dazu eine Funktion focus
schreiben, die uns, ausgehend vom
Namen des Feldes field
und einer bestehenden Komponente c
, so eine
modifizierte Komponente zurückgibt:
function focus(field, c) {
function (value, onChange) = {
var c_value = goog.object.get(value, field)
var c_onChange = function(v) {
onChange(goog.object.set(value, field, v))
}
c(c_value, c_onChange)
}
}
Die Funktion focus
kann ebenfalls als Kombinator für Komponenten
bezeichnet werden: Sie nimmt eine Komponente als Argument und gibt
eine neue Komponente zurück.
Um aus der textinput
-Komponente also nun eine Komponente zu
erzeugen, die das Feld name
eines Objekts editierbar macht, können
wir jetzt einfach folgendes schreiben:
focus('name', textinput)
Oder um zwei Input-Felder nebeneinander zu haben, von dem eins den Nachnamen und eins den Vornamen anzeigt und für den User änderbar macht:
cdiv(focus('firstname', textinput),
focus('lastname', textinput))
Zu beachten ist dabei, dass diese Webkomponenten auch referenziell
transparent sind. D. h. obwohl die Komponente textinput
hier zweimal
verwendet wird, erhält man natürlich zwei separate Eingabefelder in
der Webanwendung: die Komponenten haben keine Identität.
Eine Komponente muss übrigens, wie oben erwähnt, die obligatorische Eingabe oder den Callback gar nicht benutzen. Um zum Beispiel einfach nur einen statischen Text anzuzeigen, bietet sich folgende Definition an:
function text(str) {
function (value, onChange) {
return <>{str}</>
}
}
Damit kann man neben die Input-Felder ganz leicht noch einen Text stellen:
cdiv(cdiv(text("Vorname:"), focus('firstname', textinput)),
cdiv(text("Nachname:"), focus('lastname', textinput)))
Oder auch noch weiter abstrahieren:
function labelled_textinput(label) {
return cdiv(text(label), textinput)
}
cdiv(focus('fistname', labelled_textinput("Vorname:"))
focus('lastname', labelled_textinput("Nachname:")))
Man beachte, dass die Verschachtelung hier eine andere ist als
vorher. Die Komponenten, die von der Funktion text
zurückgegeben
werden, bekamen vorher ein Objekt als value
übergeben, aber jetzt
einen String. Dadurch dass diese den Wert aber gar nicht benutzen oder
modifizieren, kann man sie quasi beliebig mit anderen Komponenten
kombinieren.
Fazit
Wir haben jetzt also ein Modell für Komponenten gefunden, das uns wirklich komponierbare Komponenten gibt, inklusive der Definition von Funktionen, die Komponenten als Argumente erwarten oder neue Komponenten zurückgeben können. Dies reduziert Boilerplate-Code und schafft mächtige Abtraktionsmöglichkeiten.
Reacl-c
Die hier vorgestellten Implementierungen stellt nur die Grundidee für komponierbare Komponenten in vereinfachter Form dar. Um produktiv einsetzbar zu sein, braucht es ein etwas verfeinertes Modell und noch eine ganze Reihe von Erweiterungen und Details in der Schnittstelle zu React, die den Umfang dieses Artikels sprengen würden. Wir, die Active Group GmbH, haben dafür aber eine Bibliothek namens reacl-c implementiert, die diese Idee in ClojureScript vollständig realisiert. Sie ist eine Weiterentwicklung von Reacl, und bereits in vielen Projekten für unsere Kunden produktiv im Einsatz.
Fußnoten:
-
Mit „nebeneinander“ ist hier nicht die gleichzeitige oder parallele Ausführung von Funktionen gemeint. ↩
-
Ein weiterer Aspekt sind Signale oder Ereignisse, die von einer Webkomponente ausgehen können, wie zum Beispiel das Klicken auf einen Button. Diesen Aspekt wollen wir in diesem Artikel aber nicht weiter betrachten. ↩