ManuScripts: WJERT – FP mit Elm – Teil 6 – Elm rennt
Eine Blog-Serie zum Vergleich von Elm, ReactJS und AngularJS anhand eines praktischen Beispiels.Hier geht es direkt zu [Teil 1, 2, 3, 4, 5]
Herrlich frische Bergluft bläst unseren Wandervögeln entgegen auf der Ämpächli Alp, wo Angular sich vor allem über den festen Boden unter seinen Füssen freut, sowie die Aussicht auf eine Erfrischung im Restaurant bei der Bergstation. Elm ist aber überhaupt nicht in der Stimmung, bereits eine Rast einzulegen. Sie einigen sich auf einen Treffpunkt: Hängstboden. Elm marschiert wehenden Schrittes los, mit gewölbter Brust, den Kopf der Sonne entgegengestreckt.
Zur Erinnerung: Das Endziel ist ein Anagram-Generator für die deutsche Scrabble-Community.
Etappenziel: Interaktive Benutzeroberfläche
Vorbereitung (create-elm-app)
In den letzten Monaten ist die Elm-Community sehr weit gewandert und nebst zahlreichen Editor-Plugins für die gängigen Opensource-Editoren sind andere wertvolle Tools erschienen, wie z.B. create-elm-app, welches sich webpack bedient, und damit eine „state-of-the-art“ Toolchain für die Entwicklung unter elm bereitstellt.
Zur Erinnerung: Elm-Programme werden mit dem Compiler elm-make nach javascript kompiliert und das wiederum wird in eine Html-Seite eingebunden, entweder in ein Ziel-div oder fullscreen oder sogar headless (Keine Anzeige).
Der mitgelieferte elm-reactor (development web-server) reicht für die Entwicklung eigentlich aus, aber mit webpack gibt es jetzt schon solche goodies wie „hot reload“ und css stylesheets können so auf die altbekannte Weise eingebunden werden, was für den Anfang auch vortheilhaft ist.
Dadurch bleibt mehr Zeit für die Sache, die Spass macht: die Programmierung mit Elm. Wer später seinen eigenen Build-Prozess einführen möchte, kann die Abhängigkeit problemlos durch das Tool entfernen lassen.
Die Installation erfolgt auf der Kommandozeile: sudo npm install -g create-elm-app
Wir fangen also nochmals ganz von vorne an mit unserer Applikation und kopieren die Models aus den vergangenen Blog-Posts bei Bedarf in unser Projekt. Zuerst aber mal eine neue elm-app erstellen:
create-elm-app /path/to/my/project/elm-anagrams Creating elm-anagrams project... Packages configured successfully! Project is successfully created in `/path/to/my/project/elm-anagrams`. cd /path/to/my/project/elm-anagrams elm-app start
Nach kurzer Zeit erscheint:
Compiled successfully! The app is running at: http://localhost:3000/ To create production build, run: elm-app build
und zusätzlich wird der Standard-Browser unter obiger Adresse geöffnet, wo nochmals eine zufriedenstellende Erfolgsmeldung angezeigt wird.
Editor: Atom, Brackets, Emacs, IntelliJ, Sublime Text, Light Table, Vim, VS Code
Hier ist die Feature-Matrix
Persönlich nutze ich im Moment Atom mit folgenden Plugins:
- language-elm
- elm-format
- linter-elm-make
- elmjutsu
Ich wechsle demnach (in einer neuen Konsole) ins elm-anagrams-Verzeichnis und starte atom .
Projekt-Struktur
create-elm-app legt die Ordner src und tests an. Was durch elm-make kompiliert wird, landet im Verzeichnis elm-stuff. Das letzte Verzeichnis, dist, wird vom Build-Tool erzeugt. Ausserdem wird gleich auch noch die korrekte .gitgnore-Datei erzeugt, so dass wir nur noch git init && git add . && git commit -m „initial“ aufrufen müssen, um unsere Resultate unter Versionskontrolle zu stellen.
Damit wir sofort loslegen können, öffnen wir die Datei src/App.elm, wo wir den folgenden Inhalt vorfinden:
module App exposing (..) import Html exposing (text, div) subscriptions model = Sub.none update msg model = ( model, Cmd.none ) init = ( (), Cmd.none ) view model = div [] [ text "Your Elm App is working!" ]
Ich würde die Reihenfolge init, subscriptions, update, view bevorzugen (und rearrangiere entsprechend), aber dies bleiben die vier Bausteine, die durch die TEA (the elm architecture) vorgeschlagen werden und welche die gesamte Applikation ausmachen. Subscriptions verwenden wir vorerst nicht.
Wir erinnern uns daran, dass elm mit einem immutable
(unveränderbaren) Model operiert. Das Modell wird immer an die betreffenden Funktionen übergeben und daraus wird (in den meisten Fällen) ein neues Modell resultieren.
Die folgende Grafik veranschaulicht dies und beschreibt die Funktionsweise von elm.
Update-Loop
Here is a simplified model of how an Elm 0.17 program operates.
The update function blocks waiting for the next incoming Msg. When received it creates a new model value and commands. The view and subscriptions functions then run against the new model value, producing new Html Msg and Sub Msg values, respectively. Then update waits for the next Msg. Note that subscriptions is called after each update.
Happy Elming!
So, hier noch der Link zum Syntax, und dann legen wir los:
Das Ziel
Unser heutiges Ziel ist es, eine einfache Eingabe-Maske zu erstellen, welche ein Textfeld enthält, welches bei Eingabe eines Zeichens den Bereich darunter entsprechend aktualisiert. Zu einem späteren Zeitpunkt befassen wir uns mit dem HTTP-Aufruf ans Backend.
init und
Model
Model the problem!
Zuerst soll man sein Problem modellieren. Welche Daten muss unser Modell enthalten, damit wir unser Ziel erreichen können?
Dazu erstellen wir im File App.elm zwei neue Typen:
- einen mit dem treffenden Namen Model (es ist eine elm-Konvention, das Hauptmodell Model zu nennen) und
- Anagram, welcher später ausgebaut wird.
Eine erste Version könnte folgendermassen aussehen:
type alias Anagram = String -- bloss ein neuer Name für String, lesbarer, lässt einfachen Ausbau zu type alias Model = { input: String -- der momentane String im Textfeld , anagrams: List Anagram -- die Liste der aktuellen Resultate }
Von diesem Typ wollen wir nun eine Initialversion erstellen in der Funktion init. Diese sieht neu, mit Signatur, folgendermassen aus:
init: (Model, Cmd Msg) init = ( { input = "", anagrams = [] }, Cmd.none )
Wir ersetzen das bisher leere Model () vom Typ Unit durch einen record
({ input = „“, anagrams = [] }), der mit dem deklarierten Typ vollständig übereinstimmt und erst dadurch kompilieren kann. Wenn die Reihenfolge der Argumente eingehalten wird, kann das Model auch so erzeugt werden (Record-Konstruktor-Aufruf): Model „“ []. Es müssen jedoch immer alle Felder initialisiert werden.
() kann als ein Tupel ohne Elemente betrachtet werden, oder ein Wert ohne Daten.
Cmd
und Msg
Der Rückgabe-Wert von init ist ein Tuple tup = ( x, y ), welches an erster Stelle (fst tup) unser Modell enthält, und an zweiter Stelle (snd tup) stehen allfällig auszuführende Kommandos, die Funktionen mit Seiten-Effekten betreffen. elm kontrolliert die Aufrufe solcher Funktionen explizit, damit es nicht zu Laufzeitfehlern kommt. Der Entwickler wird dadurch gezwungen, auch den möglichen Fehlerfall im Code abzudecken. Ersichtlich wird das am Typ Task, der für den Aufruf solcher Funktionen verwendet werden muss:
type alias Task err ok = Task err ok
Um einen Task erstellen zu können, müssen zwei Argumente (Funktionen) übergeben werden. Das erste behandelt den Fehlerfall, das zweite den Erfolgsfall mit dem angeforderten Resultat. Funktionen, die Seiten-Effekte aufweisen, sind z.B. Http.get oder Date.now.
Jedes Cmd spezifiziert:
- Auf welche Effekte man zugreifen möchte und
- Welche Nachrichten in die Applikation zurückgelangen.
Und obwohl wir in der Signatur von init angegeben haben, dass wir Nachrichten vom Typ Msg zurückhaben wollen, haben wir diesen Typ nirgends spezifiziert. Zeit, dies nachzuholen.
type Msg = NoOp
Ich habe hier eine Nachricht deklariert, welche dem Namen nach, keine Operation zur Folge hat. Unter gewissen Umständen kann das von Nöten sein, hier wird es verwendet, um eine Fähigkeit des Typ-System von elm zu zeigen. Denn wenn nebst NoOp noch andere Nachrichten erwarten, wie z.B. wenn der Benutzer Text ins Textfeld eingibt oder löscht, dann können wir diese Information an diesen Typ anhängen, was dann so aussieht:
type Msg = NoOp | OnInputChanged String
Dieses Konstrukt nennt sich Union Type
und ist eins der mächtigen Werkzeuge der funktionalen Programmierung. Damit können auf natürliche Weise auch komplexe Strukturen ausgedrückt werden. Union Types werden oft auch tagged unions genannt, oder auch ADTs (algebraic data types). Sie beherbergen noch sooo viel mehr, wie der Begriff algebra in ADT vermuten lässt. Dieses höchst spannende Thema muss aber noch warten, weil hier geht es um elm, und bei elm lautet das Motto: „Let’s build stuff“.
Für den Moment reicht es, zu erkennen, dass ein Wert vom Typ Msg entweder vom Typ NoOp (Nachricht ohne zusätzlichen Wert) sein kann, oder vom Typ OnInputChanged mit einem zusätzlichen „Payload“ vom Typ String
view
Nun können wir eigentlich bereits unsere View erstellen. Mit voller Signatur lautet diese im Original:
view: Model -> Html Msg view model = div [] [ text "Your Elm App is working!" ]
Die view-Funktion erhält ein Model und gibt ein Konstrukt vom Typ Html Msg zurück, also Html, welches Nachrichten vom Typ Msg erzeugen kann.
Elm deckt im Modul Html grosse Teile der Html5-Funktionalität ab (mit wenigen, z.T. esoterischen Ausnahmen). Wenn ein Html-Element erzeugt werden soll, dann haben die Funktionen, die das ermöglichen, die folgende Signatur (hier am Beispiel h1):
h1 : List (Attribute msg) -> List (Html msg) -> Html msg
Nehmen wir das folgende Html an: <h1 class=“special“ z-index=“50″><span>Bingo</span><span>Mania</span></h1>
Ein Html-Element hat 0-n Attribute (class, z-index), sowie eine Liste von enthaltenen, oder Kind-Elementen, wie man das von einer Baumstruktur gewohnt ist. Die Funktions-Signatur drückt genau dasselbe aus: h1 ist der Name einer Funktion mit zwei Argumenten. Beide Argumente sind Listen, die erste enthält jedoch Werte vom Typ Html.Attribute msg , die zweite Werte vom Typ Html.Html msg. Hinweis: die kleingeschriebenen Typnamen (hier msg), weisen auf Platzhalter für beliebige Typen hin, in unserem Fall heisst der konkrete Typ ja Msg (Typnamen sind immer upper case).
In elm sähe dasselbe demnach so aus: h1 [ HA.class „special“, HA.zIndex „50“ ] [ span [] [text „Bingo“], span [] [text „Mania“] ]
Hinweis: Bestehendes Html kann mit dem Paket mbylstra/html-to-elm nach elm konvertiert werden. Demo
Zurück zum Anagrammerator: Hier ist der Code für die View, wie sie im Bild oben angedeutet ist, ganz ohne zusätzlichen Schnickschnack:
import Html.Attributes as HA view : Model -> Html Msg view model = div [] [ Html.h1 [] [ text "Anagrammerator" ] , Html.h2 [] [ text ("Input: " ++ model.input) ] , viewTextField model , viewResults model ] viewTextField : Model -> Html Msg viewTextField model = div [ HA.class "text-field-area" ] [ Html.span [] [ Html.label [ HA.for "input" ] [ text "Buchstaben auf Bank" ] , Html.input [ HA.id "input" , HA.type' "text" ] [] ] ] viewResults : Model -> Html Msg viewResults model = div [ HA.class "result-area" ] [ Html.h2 [] [ text "Resultate" ] , Html.ul [] (List.map viewAnagram model.anagrams) ] viewAnagram : Anagram -> Html Msg viewAnagram anagram = Html.li [ HA.style [ (,) "font-weight" "bold" ] ] [ text anagram ]
- Zuerst wird ein zusätzliches Modul für die Html-Attribute importiert und mit dem alias HA versehen, damit es später über diesen Namespace-Namen angesprochen werden kann. Wir könnten mit expose (id,style,type‘) auch gleich die für unseren Code benötigten Funktionen bereitstellen, so wie es für (Html, div, text) getan wurde. Grundsätzlich sollte man aber, der Lesbarkeit zuliebe, bei Abhängigkeiten immer möglichst explizit sein. Ganz schlimm ist import Html exposing (..), weil ich zu einem späteren Zeitpunkt nicht mehr einfach herausfinden kann, welcher Code nun importiert wurde.
- Die view-Funktion wurde aufgeteilt auf mehrere kleine Funktionen. Dies vereinfacht den Code, macht ihn wartbarer und lässt den Programmenschen sich wiederholende Muster im Code schneller erkennen.
- In der Funktion viewTextField wird ein label und ein input vom Typ text erstellt. Man sieht auch, wie auf den äusseren div eine css-Klassen angewandt wird. Styles werden (da create-elm-app das alles für uns automatisiert), im src/main.css definiert und automatisch neu geladen.
- Die Funktion viewResults verwendet die Funktion List.map, welche zwei Argumente verlangt: Eine Funktion, die einen Wert in einen Wert eines anderen Typs überführt (a -> b) und eine Liste von Werten, hier unsere (leere) Liste der Anagramme. Die Funktion wird dann auf jeden Wert in der Liste angewandt, was wiederum zu einer Liste führt, nur haben die Element in der neuen Liste den Typ Html Msg
preview and review
Wer seinen Browser beim Entwickeln betrachtet hat, wird feststellen, dass sich dieser bei jeder Änderung automatisch aktualisiert, ohne seinen Zustand zu verlieren. Falls die app nicht läuft, diese wieder starten, und im Browser den Output betrachten. Wer unbedingt muss, kann nun auch etwas „live“ stylen, inder er main.css anpasst und speichert.
Wir stellen aber fest, dass hier noch nichts von Bedeutung geschieht. Eingaben im Textfeld werden nicht sichtbar.
Eine erste Änderung könnte nun so aussehen:
Html.input [ HA.id "input" , HA.type' "text" , HA.value model.input -- setze den Wert des Input-Felds auf den Wert des Feldes `input` im Model. ] []
Wenn man nun versucht, ins Feld zu tippen, wird der Wert beim nächsten Zeichnen gleich wieder zurückgesetzt, was sofort stattfindet. Auch nicht so nützlich. Damit der Wert ins Model gelangt, müssen wir mit der folgenden Änderung das Ereignis abfangen und in eine Nachricht unseren Typs verwandeln:
import Html.Events as HE ... , Html.input [ HA.id "input" , HA.type' "text" , HA.value model.input , HE.onInput OnInputChanged -- übersetze das Ereignis in eine Nachricht ] []
Eventhandler-Funktionen sind im Modul Html.Events implementiert. Darum wird dieses importiert und mit Alias versehen. Aus diesem Modul verwenden wir die Funktion
onInput : (String -> msg) -> Attribute msg
Diese Funktion hat einen Eingabe-Parameter, welcher eine Funktion ist, die einen String kriegt und einen Wert vom Typ
Durch das Hinzufügen dieses Event-Handlers wird jetzt die update-Funktion mit dieser Nachricht aufgerufen, wenn sich der Text im Feld verändert.
update
In dieser Funktion kommen alle Nachrichten zusammen, egal aus welcher Quelle sie stammen (Benutzereingabe, Subscriptions, Tasks) und werden ausschliesslich hier behandelt. Natürlich sind Hilfsfunktionen angebracht, nein, erwünscht, aber oft sind diese Updates gar nicht so involviert.
update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of NoOp -> ( model, Cmd.none ) OnInputChanged val -> ( { model | input = val }, Cmd.none )
Die Funktion erhält die Nachricht, sowie das aktuelle Modell und retourniert, wie die init
-Methode, das neue Modell, sowie allfällige Effekt-Aufforderungen zu diesem Zeitpunkt. Hier sehen wir ein Beispiel für ein „pattern matching“. Ein case-statement muss sämtliche möglichen Belegungen des Typs behandeln, was im obigen Beispiel explizit getan wird. (_ ist ein (mit Vorsicht einzusetzender) „catch-all“-Platzhalter, welcher alle verbleibenden Fälle abdeckt)
Wenn eine NoOp-Nachricht reinkommt, tun wir gar nichts mit dem Modell und geben es ohne Effekte zurück. Im zweiten Fall, unserer OnInputChanged-Nachricht, erhalten wir einen Wert val, welches der neue Wert des Textfeldes ist, aus dem die Nachricht stammt.
Der Syntax zum Setzen eines Feldes in einem Record ist etwas speziell, d.h. nach Erläuterung macht die Schreibweise Sinn:
{ model | input = val }
Dieser Syntax bedeutet, dass ein neues Modell erzeugt wird, welches sich nur im Feld input vom alten unterscheidet. Neu hat das Feld den Wert des Nachrichten-Parameters val. Nach diesen Änderungen funktioniert das Textfeld wie erwartet und unser Etappenziel ist erreicht!
loop
Das Resultate-Feld ist jetzt halt leer geblieben. Wer möchte, kann im init Werte einfüllen, und mit Listen von Werten herumspielen, oder selbst versuchen, die Liste interaktive mit Werten zu befüllen. Wir holen uns beim nächsten Mal die Werte von einem Webserver via Http-Request.
Nun aber weiterhin viel Spass beim Ausprobieren!
Elm sitzt mit gekühltem Getränk im Schatten eines grell-gelben Sonnenschirms auf der Terasse des Berggasthauses Bischofalp und fragt sich, wie lange es wohl dauern wird, bis die anderen auch hier eintreffen. Vermutlich haben sich die anderen vor der Abreise noch mit massenhaft unnötigem Material eingedeckt, bevor sie die kurze Strecke in Angriff nahmen. Sie klaubt ihr Handy aus der Hüfttasche und vertreibt die Zeit mit ein paar Spielen:
elm-package install –yes elm-community/list-extra
import List.Extra as List import String ... OnInputChanged val -> let anagrams = String.split "" val |> List.permutations |> List.map String.concat |> List.unique in ( { model | input = val, anagrams = anagrams }, Cmd.none )
Damit fordert sie die Leistungsgrenzen ihres mobilen Browsers nun doch arg heraus. Und da ein Telefon mit Strom heute den Unterschied zwischen Leben und Tod bedeuten könnte, steckt sie es wieder weg… wo bleiben die anderen bloss?
Sind sie wohl schon aufgebrochen? Oder schon zusammengebrochen? Schaffen Sie den ersten Aufstieg des Elm-Höhenweges? Trink noch einen Kaffe Luz und erfahre im nächsten Teil, wie es React und Angular in der Zwischenzeit ergangen ist.