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を使って初期化しています。 selectreduceは両方ともアプリケーション内でStoreから状態を取得、あるいは、状態を変更するために必要な関数となります。 それぞれ、State -> aState -> Stateといった型の関数を渡してもらうことになります。 それぞれSelectorReducerと呼ぶことにします。

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画面があるという想定で書いています。

そして、ルートの情報を管理しないといけなくなったので、さきほど定義したStateinitialStateに修正を入れています。

detectRouteは、urlからRoute型をつくります。実装については、purescript-routを使って行なっています。 ここでは細かい使い方は割愛します。

routeルーターの宣言です。change関数は先ほど定義したreduceを通してStaterouteを更新します。 このルーターは後々アプリケーションの初期化時に使います。

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命名していますが、呼び名はなんでもよいです。 このレイヤーは、ユーザー操作から実際の状態変更の間を請け負うレイヤーです。 ここでは、もっとも簡単な例として、さきほどのincrreduceに渡すだけの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をつくり、storerouteと合わせて、アプリケーションをマウントします。

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の流れから、ducksrepatchredux-zeroが出てきているのですが、たぶん発想の源流は似たようなもんなのではないかなと考えています。

そして、ゆとりプログラマらしい発想で、StoreにStateRefを持たせたものを内部的にunsafePerformEffでつくることによって、main関数の流れから外れているところでも同じstoreを参照できる、という構造にし、Event定義をせずにすむようにしました。

最後に

このフレームワークは、巧妙な型を使ってできているというわけではないので、PureScriptの基礎的なことを知っていれば、すぐに使い始めることができるかと思います。 でも、こんな何処の馬の骨かわからんようなもんがつくったものよりも、実績のあるフレームワークがあるので多分誰も使わないでしょう。 私の趣味で作ったので、自分で使い込んでいっていい感じにしていきたいと思います。(飽きる可能性もありますが)