PureScriptで意識の低いSPA用パッケージをつくりました
追記
本記事のパッケージは現在メンテされておりません。 代わりのUIパッケージとして書き直したこちらを是非チェックしてみてください。 purescript-freedom
はじめに
PureScript Advent Calendar 2017 - Qiitaの3日目の記事です。 今年につくった、意識の低いSPA用パッケージであるGitHub - oreshinya/purescript-cherry: No longer maintenanceを紹介させていただきます。
使い方
カウンターを例に書いていきます。
State
を定義しましょう
type State = { count :: Int } initialState :: State initialState = { count: 0 }
State
はアプリケーションが抱える状態です。昨今ではおなじみのSingle sourceです。アプリケーション全体の状態を管理します。
カウンターなので、count
という状態を持たせることにしてみます。
Store
をつくりましょう
import Prelude import Cherry.Store as S import Control.Monad.Eff (Eff) import Control.Monad.Eff.Ref (REF) store :: forall e. S.Store (ref :: REF | e) State store = S.createStore initialState select :: forall e a. (State -> a) -> Eff (ref :: REF | e) a select = S.select store reduce :: forall e. (State -> State) -> Eff (ref :: REF | e) Unit reduce = S.reduce store
3つの関数を定義しました。ひとつはstore
です。さきほど定義したinitialState
を使って初期化しています。
select
とreduce
は両方ともアプリケーション内でStore
から状態を取得、あるいは、状態を変更するために必要な関数となります。
それぞれ、State -> a
、State -> State
といった型の関数を渡してもらうことになります。
それぞれSelector
、Reducer
と呼ぶことにします。
Router
をつくりましょう
import Prelude import Cherry.Router (router) import Control.Monad.Eff (Eff) import Control.Monad.Eff.Ref (REF) import Data.Maybe (fromMaybe) import DOM (DOM) import Rout (match, end) data Route = Home | NotFound type State = { count :: Int , route :: Route } initialState :: State initialState = { count: 0 , route: Home } detectRoute :: String -> Route detectRoute url = fromMaybe NotFound $ match url $ Home <$ end route :: forall e. Eff (dom :: DOM, ref :: REF | e) Unit route = router change where change url = reduce $ _ { route = detectRoute url }
まずRoute
型を定義します。ホーム画面と404画面があるという想定で書いています。
そして、ルートの情報を管理しないといけなくなったので、さきほど定義したState
やinitialState
に修正を入れています。
detectRoute
は、urlからRoute
型をつくります。実装については、purescript-routを使って行なっています。
ここでは細かい使い方は割愛します。
route
はルーターの宣言です。change
関数は先ほど定義したreduce
を通してState
のroute
を更新します。
このルーターは後々アプリケーションの初期化時に使います。
View
をつくりましょう
import Cherry.Router as R import VOM (VNode, h, t, (:=), (~>)) home :: forall e. Int -> VNode (dom :: DOM, ref :: REF, history :: HISTORY | e) home count = h "div" [] [ h "h1" [] [ t "Home" ] , h "div" [] [ t $ show count ] , h "a" [ "onClick" ~> (const $ R.navigateTo "/not_found") ] [ t "404 Not Found" ] ] notFound :: forall e. VNode e notFound = h "div" [] [ h "h1" [] [ t "404" ] , h "a" [ "href" := "https://github.com/oreshinya/purescript-cherry", "target" := "_blank" ] [ t "Github" ] ] view :: forall e. State -> VNode (dom :: DOM, ref :: REF, history :: HISTORY | e) view state = case state.route of Home -> home state.count NotFound -> notFound
ホーム画面と404画面を定義しています。最後のview
関数でroute
によって、画面を切り替えています。このview
関数は後々アプリケーションの初期化時に使います。
コード中に出てくるVNode
型は仮想DOMの型で、purescript-vomによって提供されています。
ここでは細かい使い方は割愛します。
Reducer
をつくりましょう
incr :: State -> State incr s = s { count = s.count + 1 }
カウントを増やせるようにするために、incr
関数をつくりました。
Action
をつくりましょう
increment :: forall e. Eff (ref :: REF | e) Unit increment = reduce incr home :: forall e. Int -> VNode (dom :: DOM, ref :: REF, history :: HISTORY | e) home count = h "div" [] [ h "h1" [] [ t "Home" ] , h "div" [ "onClick" ~> const increment ] [ t $ show count ] , h "a" [ "onClick" ~> (const $ R.navigateTo "/not_found") ] [ t "404 Not Found" ] ]
便宜上、Action
と命名していますが、呼び名はなんでもよいです。
このレイヤーは、ユーザー操作から実際の状態変更の間を請け負うレイヤーです。
ここでは、もっとも簡単な例として、さきほどのincr
をreduce
に渡すだけのincrement
を作りました。
実際には、このレイヤーでは、affjaxなどをつかって通信を送り、結果をまってからreduceするなど、
いくらかの副作用を伴った処理になるでしょう。今回は使用しませんでしたが、上述したselect
関数もここで使われるような想定です。
つくったAction
をホーム画面で使います。
これでカウントアップできるようになりました。
初期化しましょう
import Cherry (mount) import Cherry.Renderer (createRenderer) main :: Eff (dom :: DOM, ref :: REF, history :: HISTORY, console :: CONSOLE) Unit main = do renderer <- createRenderer "#app" view mount store renderer [ route ]
これまでにつくったview
を用いて、renderer
をつくり、store
、route
と合わせて、アプリケーションをマウントします。
mount
の最後の引数は、マウント時に一度だけ発火するEff
の配列を渡します。
reduce
を通して状態を変更すると、viewが差分更新するようになります。
全体的にはこんな感じ
module Main where import Prelude import Cherry (mount) import Cherry.Renderer (createRenderer) import Cherry.Router as R import Cherry.Store as S import Control.Monad.Eff (Eff) import Control.Monad.Eff.Console (CONSOLE) import Control.Monad.Eff.Ref (REF) import DOM (DOM) import DOM.HTML.Types (HISTORY) import Data.Maybe (fromMaybe) import Rout (match, end) import VOM (VNode, h, t, (:=), (~>)) data Route = Home | NotFound type State = { count :: Int , route :: Route } initialState :: State initialState = { count: 0 , route: Home } store :: forall e. S.Store (ref :: REF | e) State store = S.createStore initialState select :: forall e a. (State -> a) -> Eff (ref :: REF | e) a select = S.select store reduce :: forall e. (State -> State) -> Eff (ref :: REF | e) Unit reduce = S.reduce store detectRoute :: String -> Route detectRoute url = fromMaybe NotFound $ match url $ Home <$ end route :: forall e. Eff (dom :: DOM, ref :: REF | e) Unit route = R.router change where change url = reduce $ _ { route = detectRoute url } home :: forall e. Int -> VNode (dom :: DOM, ref :: REF, history :: HISTORY | e) home count = h "div" [] [ h "h1" [] [ t "Home" ] , h "div" [ "onClick" ~> const increment ] [ t $ show count ] , h "a" [ "onClick" ~> (const $ R.navigateTo "/not_found") ] [ t "404 Not Found" ] ] notFound :: forall e. VNode e notFound = h "div" [] [ h "h1" [] [ t "404" ] , h "a" [ "href" := "https://github.com/oreshinya/purescript-cherry", "target" := "_blank" ] [ t "Github" ] ] view :: forall e. State -> VNode (dom :: DOM, ref :: REF, history :: HISTORY | e) view state = case state.route of Home -> home state.count NotFound -> notFound incr :: State -> State incr s = s { count = s.count + 1 } increment :: forall e. Eff (ref :: REF | e) Unit increment = reduce incr main :: Eff (dom :: DOM, ref :: REF, history :: HISTORY, console :: CONSOLE) Unit main = do renderer <- createRenderer "#app" view mount store renderer [ route ]
リポジトリのサンプルにも似たようなサンプルコードをのせています。そちらのほうがもうちょっとサンプルとしてバリエーションがあります :)
なぜつくったか
もともとはpuxを使おうとしていたのですが、まずは以下をみてください。
data Event = Increment | Decrement -- | Return a new state (and effects) from each event foldp :: ∀ fx. Event -> State -> EffModel State Event fx foldp Increment n = { state: n + 1, effects: [] } foldp Decrement n = { state: n - 1, effects: [] } -- | Return markup from the state view :: State -> HTML Event view count = div do button #! onClick (const Increment) $ text "Increment" span $ text (show count) button #! onClick (const Decrement) $ text "Decrement"
ある状態遷移をしたいときは、まずEvent
を定義し、foldp
に足したEvent
について行う処理を足していく、というような形になっていて、Event
とその処理に分割されています。(てかほぼElm Architectureなんですけど)
そして、実際のアプリケーションでは、これらの処理は縦にずらーっと並ぶような形になります。
あまり見目麗しくはないので、私はできればこれを避けたいと考えました。
もっと言えば、このEvent
とかいうものを定義したくないと思いました。
この構造は、実際にフレームワークをunsafeな処理をいれずにつくろうとしたら自然にこうなった記憶があるのでたぶん必然的な構造ではあると思うのですが、 一旦そういった言語とか実装の都合は置いといて、この構造について単なる構造的なメリットを考えると、time travel debugging時(puxにそういうのあるか知らないけど)に操作に名前がついていてみやすいということ、そしてもう一つは、理屈上は1つのイベントに対して複数の処理をひもづけられることということかと思います。
Event
をなくすとなると、
前者のメリットがなくなることについては、少しデメリットなのですが、ジェーエスでreduxをやっていたときの感想として、私はtime travel debuggingはたまにしかやらないうえ、イベント名をほとんどみないタイプだったのでそんなに困んなそうだなと思ったこと(ちなみにcherryはまだtime travel debuggingを提供していないです)、
後者のメリットがなくなることについても、まず滅多にそんなことやらなくて、わりと複雑めなSPAで基盤的処理にちょっとだけこの性質を利用したことがあるという程度なので、困らなそうだなと思ったこと、
から、Event
排除の方向でいくことにしました。
JS界隈でreduxの流れから、ducksやrepatch、redux-zeroが出てきているのですが、たぶん発想の源流は似たようなもんなのではないかなと考えています。
そして、ゆとりプログラマらしい発想で、StoreにState
のRef
を持たせたものを内部的にunsafePerformEffでつくることによって、main関数の流れから外れているところでも同じstoreを参照できる、という構造にし、Event
定義をせずにすむようにしました。
最後に
このフレームワークは、巧妙な型を使ってできているというわけではないので、PureScriptの基礎的なことを知っていれば、すぐに使い始めることができるかと思います。 でも、こんな何処の馬の骨かわからんようなもんがつくったものよりも、実績のあるフレームワークがあるので多分誰も使わないでしょう。 私の趣味で作ったので、自分で使い込んでいっていい感じにしていきたいと思います。(飽きる可能性もありますが)