Technology
ClojureScript, pekonilla vai ilman?

Read time 5 min
Kannattaako funktionaalista reaktiivista ohjelmointia (FRP) hyödyntää ClojureScript-toteutuksissa?
Nykyisen projektini serveripuolen kielivalinta on Clojure, ja käyttöliittymäpuolen Flex. Flex on kuitenkin kuoleva kieli, eikä sitä enää kehitetä sillä intohimolla mitä ohjelmointikielestä odottaisi. Päädyimme korvaamaan Flexin ClojureScriptillä, koska syntaksi eroaa serveripään kielestä vain hitusen ja ClojureScriptiä kehitetään avoimesti ja intohimoisesti.
Hyvä tietää: threading-macrot ->
ja ->>
->
toimii siten, että funktion lopputulos passataan seuraavan funktion ensimmäiseksi argumentiksi. ->>
toimii samoin, mutta paluuarvo menee seuraavan funktion viimeiseksi parametriksi. Alla olevat esimerkit ovat siis identtisiä:
(->> [1 2 3 4]
(map inc)
(filter even?))
(filter even? (map inc [1 2 3 4]))
JavaScript-interop
ClojureScriptissä JavaScript-interop toimii kuten Java-interop Clojuressa – muutamaa poikkeusta lukuunottamatta. JavaScript-objektin funktiota kutsutaan kuten Java-metodia Clojuresta:
(.map observable f)
Ja prototyyppifunktiota:
(.textFieldValue Bacon.UI $elem)
JavaScript-objektin propertya kutsutaan ClojureScriptistä seuraavasti:
(.-property objekti)
Toisin kuin Clojuressa ClojureScriptin kääntäjä käyttää Googlen Closure compileria ja jos käännöksen taso on:advanced
, kaikki muuttujat munglataan. Jotta ulkoisia JavaScript kirjastoja voidaan käyttää ClojureScriptistä:advanced
-optimoinnilla, on kääntäjälle esiteltävä erityinen externs-tiedosto, missä kerrotaan mitä muuttujia ei saa munglata. Edellinen esimerkki ei siis toimi jos seuraavia rivejä ei ole esitelty externs-tiedostossa:
Bacon.UI = function() {}
Bacon.UI.prototype.textFieldValue = function () {}
Bacon.js:n kääriminen onkin siis viisasta tehdä omaksi namespacekseen, missä JavaScript-interop on kääritty näkyvistä (event-stream
, map
ja filter
-funktiotto-partial
-apufunktion kanssa):
(defn- to-partial
"Convert function and argument list to partially bound function.
Resulting function takes single argument, value."
[f args]
(fn [value]
(let [args-with-value (-> (vec args) (conj value))]
(apply f args-with-value))))
(defn filter [observable pred & args]
(.filter observable (to-partial pred args)))
(defn map [observable f & args]
(.map observable (to-partial f args)))
(defn event-stream [$elem event]
(.asEventStream $elem event))
Käyttö esimerkiksi:
(defn example-usage []
(-> (b/event-stream $text-area
"keyup input cut paste change")
(b/filter allowed-chars?)
(b/map cut-to max-length)))
Externs-tiedostoja ei tarvitse, jos ClojureScriptin kääntää optimoinnilla :whitespace
tai :simple
.
Pekonilla kiitos
Aikaisemmat kokemukseni FRP:stä olivat RxJS validointikirjaston käytöstä. Nämä kokemukset olivat hyviä, joten vuorossa oli bacon.js:n kokeilu.
Case: Viestinlähetys -komponentti
Pekonia testattiin komponenttiin, minkä avulla käyttäjä pystyy lähettämään maksimissaan 42 merkkiä pitkän tekstin valitsemilleen vastaanottajille.
Komponenttiin kuului tekstikenttä, vastanottajien lukumäärä, merkkiäänen valintakomponentti ja lähetä-nappi. Lähetä nappi aktivoituu, kun muissa komponenteissa on tarvittavat valinnat.
Käytännöksi otettiin, että jQuery-objektien palauttamat funktiot nimetään $
-etuliitteellä:
(defn- container-by-instance [instance]
{:pre [(keyword? instance)]
:post [((complement nil?) %)]}
(instance @widget-instances))
(defn- select-for-instance [instance selector]
(find (container-by-instance instance) selector))
(defn- $msg-area [i] (select-for-instance i ".message-area"))
($msg-area) ;;=> jQuery-objekti
Yllä @widget-instances
palauttaa widget-instances
-atomin sen hetkisen arvon. Atomi on alustettu komponentin containerilla, jonka kutsuja passaa komponentille:
(def widget-instances (atom {}))
(defn init [$container instance]
(swap! widget-instances assoc instance $container)
(render-template instance)
(init-widget instance))
Pihvi näyttää seuraavalta:
(defn- init-widget [instance]
(let [max-length 42
text-area (as-text-area ($msg-area instance) max-length instance)
selected-ids (table-selection instance)
counter-change (as-counter-text text-area max-length)
has-message? (b/map text-area has-text?)
has-recipients? (b/map selected-ids (complement empty?))
snd-btn-click (b/event-stream ($send-button instance) "click")
on-submit (b/sampled-by (request-params text-area
selected-ids
instance)
snd-btn-click)]
;side effects
(-> selected-ids
(b/map as-recipient-label)
(b/assign inner ($recipient-counter instance)))
(-> counter-change
(b/assign inner ($msg-counter instance)))
(-> has-message?
(b/and has-recipients?)
(b/assign set-enabled ($send-button instance)))
(-> on-submit
(b/on-value send-messages max-length instance))))
text-area
-propertyn muodostus:
(defn- msg-value-as-property
[$text-area max-length instance]
(-> (b/event-stream $text-area
"keyup input cut paste change")
(b/map (partial msg-value instance))
(b/map remove-line-breaks)
(b/map cut-to max-length)
(b/to-property)))
Vastaanottajat valitaan erillisestä taulukomponentista, mistä valintojen muutoksesta on tehty bacon-property:
(defn- table-selection [instance]
(-> (b/event-stream (t/$container instance)
"update selectionchange")
(b/map #(t/selected-ids instance))
(b/to-property [])))
Hyvää tässä lähestymistavassa on koodin kompaktisuus: sivuvaikutukset on kerätty yhteen paikkaan neljään riviin, se on helposti luettavissa ja muutettavissa.
Toisaalta saman toteutuksen kirjoittaminen pelkällä ClojureScriptillä (käyttäen tässäkin käytettävää jayq-kirjastoa) olisi aikaa mennyt kertaluokkaa vähemmän.
Versio vegaaneille
Vegeversiossa ei siis käytetä bacon.js:a, mutta hyödynnetään JavaScriptin eventtejä jayq:n (jQueryn) avulla. En kirjoita ylempää esimerkkiä uudelleen, mutta esitän toisen komponentin toteutuksen. Kompleksisuus ei aivan täsmää, mutta esimerkin valossa koen sen olevan vertailukelpoinen.
Case: Usergroups -komponentti
Komponentti koostuu kahdesta listasta, kantaan jo tallennetuista käyttäjäryhmistä ja vain LDAP:ssa esiintyvistä ryhmistä. Kun ryhmää klikataan, näytetään details-näkymässä ryhmän tiedot ja passataan callback-funktio, jota kutsutaan ryhmän id:llä kun käyttäjä antaa ryhmälle oikeuksia.
(defn init [get-selection-fn]
(render-sidebar)
(let-ajax [with-rights {:url "/userGroups.json" :dataType :json}
from-ldap {:url "/FromLDAP.json" :dataType :json}]
(add-with-rights (decode with-rights))
(add-without-rights (groups-without-rights (decode with-rights)
(decode from-ldap)))
(add-event-listeners)
(set-selected get-selection-fn)))
let-ajax
käyttää jQueryn Deferred toteutusta odottaakseen kummatkin GET
kutsut ennen kun jatkaa suoritusta.
Kuuntelijoiden asettaminen:
(defn- on-group-created
[id]
(init #(get-item-with-id id)))
(defn- set-selected [selection-fn]
(remove-class ($ ".user-group-item.selected")
:selected)
(let [$selection (selection-fn)]
(add-class $selection :selected)
(d/show-details $selection on-group-created)))
(defn- add-event-listeners []
(doseq [item ($user-group-items)]
(let [get-selection-fn #($ item)]
(on ($ item)
:click
#(set-selected get-selection-fn)))))
user-group-item
-elementtiä kliksauttaessa hoidetaan ko. näkymän (overview) rendaus ja passataan callback details-komponentille. Tässä callback on alussa kutsuttu init
-funktio, missä valittu ryhmä on äsken klikattu ryhmä.
Tämän lähestymistavan huono puoli on callbackien passailu. Saman asian voisi hoitaa eventeillä ja event bubblingilla, mutta koodin paketointi kärsii, koska vastuussa oleva elementti muuttuu kaikkien keskustelevien elementtien parentiksi. Toisaalta koodin kirjoittaminen oli nopeaa ja suoraviivaista.
Pekonilla vai ilman?
Työkalut tarkoitukseen? Missä menee raja? Onko alun FRPlearning curve liian iso? Koodin paketointi? Itsellä learning curve oli melko iso, mutta akateemisesti erittäin hyödyllinen. FRP toteutuksen lopputulokseen olen myös tyytyväinen, mutta komponentin implementoimiseen meni paljon enemmän aikaa kun plain ClojureScript komponentin implementoimiseen. Balanssin löytäminen luettavuuden ja tehokkuuden välillä taitaa olla se isoin haaste.