PureScriptのUIライブラリHalogenとDekuで同じアプリを作って比較してみた
私はまったくといっていいほどX(旧Twitter)にはポストしないで専ら読むだけなのですが、最近そのXを眺めていたらゆきくらげさんや無名隱者さん達がDeku
というUIライブラリに言及しているのを見かけまして、ちょっと気になったのでPureScriptで一番メジャーなUIライブラリHalogen
と比べてみました。
はじめに
どう比較するか
全く同じ機能を持つアプリケーションをHalogen
とDeku
で作って比較します。
アプリケーション自体は、最近書いたTagless Finalを使ったクリーンアーキテクチャーの記事と同じものを使用します。
その理由は、上記の記事は丁度Halogen
を使っていたのと、クリーンアーキテクチャーにより責務が分けられている故にライブラリに依存する部分だけ書き換えれば済むので楽だったからです(実際UseCase、Gateway、Presenterなどのレイヤーは全く同じコードが使えました)。
比較の目的
それぞれのライブラリの書き味・使い心地がどう違うのかを確かめるのが目的です。
やらないこと
- 比較に使うのは一画面だけの小さなアプリなので、ライブラリのあらゆる機能についての比較は行いません(現実的でない)。
-
Halogen
やDeku
自体の説明
(これらのライブラリはドキュメントがしっかり書かれているので、公式のドキュメントを見るのが一番いいと思います)
題材とするアプリケーション
この記事でサンプルとして作ったアプリケーションを題材として用います。
GitHubから言語がPureScriptのリポジトリを検索して結果を表示するだけのアプリです。
(アーキテクチャの解説を目的として作ったものなので見た目はマジで適当です)
ソースコード本体
これがHalogen
を使った版です
そしてこちらがDeku
を使った版
比較してみる
全体像の比較
先に全体像を載せて、その後に部分部分の比較を行おうと思います。
まずはHalogen
版です。
data Action
= SetSearchRepositoryName String
| SearchRepository
component :: forall q i o m. MonadAff m => H.Component q i o m
component =
H.mkComponent
{ initialState
, render
, eval: H.mkEval $ H.defaultEval { handleAction = handleAction }
}
initialState :: forall i. i -> SearchGitHubRepositoryState
initialState _ = { searchRepositoryName: mempty, repositories: Right mempty, isLoading: false }
render :: forall m. SearchGitHubRepositoryState -> H.ComponentHTML Action () m
render state =
HH.div_
[ HH.h1_ [ HH.text "Search GitHub Repository" ]
, HH.label_
[ HH.div_ [ HH.text "Enter repository name:" ]
, HH.input
[ HP.value state.searchRepositoryName
, HE.onValueInput SetSearchRepositoryName
]
]
, HH.button
[ HP.disabled $ state.isLoading
, HE.onClick \_ -> SearchRepository
]
[ HH.text "Search" ]
, renderRepositories state.repositories
]
where
renderRepositories = case _ of
Left err ->
HH.div_ [ HH.text $ "Failed loading repositories: " <> err ]
Right repositories ->
HH.div_ (renderRepository <$> repositories)
renderRepository repository =
HH.div
[ styleContainer ]
[ HH.div [styleOwner] [HH.text repository.owner]
, HH.div [styleUrl] [HH.a [HP.href repository.url ] [ HH.text repository.name ]]
, HH.div [styleUpdateDate] [HH.text repository.updateDate]
]
styleContainer = HP.style "display: flex; column-gap: 8px;"
styleOwner = CSS.style do
width $ px 150.0
overflow hidden
textWhitespace whitespaceNoWrap
textOverflow ellipsis
styleUrl = CSS.style do
width $ px 350.0
overflow hidden
textWhitespace whitespaceNoWrap
textOverflow ellipsis
styleUpdateDate = CSS.style do
width $ px 100.0
handleAction :: forall o m. MonadAff m => Action -> H.HalogenM SearchGitHubRepositoryState Action () o m Unit
handleAction = case _ of
SetSearchRepositoryName searchRepositoryName -> do
H.modify_ (_ { searchRepositoryName = searchRepositoryName })
SearchRepository -> do
searchRepositoryByName =<< H.gets _.searchRepositoryName
次にDeku
版です。
component_ = Proxy :: Proxy """
<div>
<h1>Search GitHub Repository</h1>
~form~
~result~
</div>
"""
component :: Nut
component = Deku.do
setRepositories /\ repositories <- useState'
setLoading /\ isLoading <- useState'
let
gf = gitHubRepositoryPortFunction gitHubRepositoryGatewayPortFunction
pf = gitHubRepositoryPresenterPortFunction {
setRepositories: \r -> liftEffect $ setRepositories (Right r),
setLoading: \loading -> liftEffect $ setLoading loading,
setErrorMessage: \m -> liftEffect $ setRepositories (Left m)
}
functions = build (merge (gf)) pf
searchRepositoryByName name = runReaderT (execute (GitHubRepositoryName name)) functions
component_ ~~ {
form: Deku.do
setName /\ name <- useState'
ref <- useRef mempty name
fixed [
D.label_
[ D.div_ [ D.text_ "Enter repository name:" ]
, D.input
[ DA.xtypeText
, DA.value name
, DL.valueOn_ DL.change setName
]
[]
]
, D.button
[ DA.disabled $ isLoading <#> show
, DL.click_ \_ -> ref >>= searchRepositoryByName >>> launchAff_
]
[ D.text_ "Search" ]
]
, result: repositories <#~> renderRepositories
}
where
renderRepositories = case _ of
Left err ->
D.div_ [ D.text_ $ "Failed loading repositories: " <> err ]
Right r ->
D.div_ (renderRepository <$> r)
renderRepository repository =
D.div
[ DA.style_ "display: flex; column-gap: 8px;" ]
[ D.div [ DA.style_ styleOwner ] [D.text_ repository.owner]
, D.div [ DA.style_ styleUrl ] [D.a [DA.href_ repository.url ] [ D.text_ repository.name ]]
, D.div [ DA.style_ styleUpdateDate ] [D.text_ repository.updateDate]
]
styleOwner = CSS.render do
width $ px 150.0
overflow hidden
textWhitespace whitespaceNoWrap
textOverflow ellipsis
styleUrl = CSS.render do
width $ px 350.0
overflow hidden
textWhitespace whitespaceNoWrap
textOverflow ellipsis
styleUpdateDate = CSS.render do
width $ px 100.0
どちらもメインの処理(UseCaseの処理)は外に出してあるのでライブラリの比較に集中できそうです。
(メインの処理については今回の関心事ではないので割愛します)
DOMの構築方法の比較
テキストの部分とボタンの部分のDOMを構築している箇所を比較してみましょう。
, HH.label_
[ HH.div_ [ HH.text "Enter repository name:" ]
, HH.input
[ HP.value state.searchRepositoryName
, HE.onValueInput SetSearchRepositoryName
]
]
, HH.button
[ HP.disabled $ state.isLoading
, HE.onClick \_ -> SearchRepository
]
[ HH.text "Search" ]
D.label_
[ D.div_ [ D.text_ "Enter repository name:" ]
, D.input
[ DA.xtypeText
, DA.value name
, DL.valueOn_ DL.change setName
]
[]
]
, D.button
[ DA.disabled $ isLoading <#> show
, DL.click_ \_ -> ref >>= searchRepositoryByName >>> launchAff_
]
[ D.text_ "Search" ]
どちらも関数を使ってDOMを構築しており、コードの見た目はそっくりです。
しかし、ここ以外の部分にも目を向けてみると大きく異なる箇所が一箇所あります。
Deku
版のこの部分です。
component_ = Proxy :: Proxy """
<div>
<h1>Search GitHub Repository</h1>
~form~
~result~
</div>
"""
そう、ご覧の通りDeku
はHTMLが直接書けるのです!
しかもタグに誤りがある場合コンパイルエラーになってくれます。
~
で囲まれた部分はプレースホルダのようなもので、動的な要素に関してはこの部分にDeku
のコードを埋め込むような形になります。
~form~
や~result~
の内容を作っているのがこの部分です。
component_ ~~ {
form: ...
, result: ...
}
この例ではやっていませんが、属性なんかもこの形式で扱えます。
静的な部分は見慣れたHTMLで書きたい場合Deku
はいいかもしれません。
PureScriptが読めない人でもこの部分に関してはわかりますしね。
状態管理の比較
それぞれがどういうアーキテクチャで状態管理をしているか比較します。
Halogen
まずHalogen
ですがFlux
に似た感じのアーキテクチャになっています。
Stateはこんな感じです。
type SearchGitHubRepositoryState
= { searchRepositoryName :: String
, repositories :: Either ErrorMessage GitHubRepositories
, isLoading :: Boolean
}
そしてこちらが代数的データ型でアクションを定義している部分です。
data Action
= SetSearchRepositoryName String
| SearchRepository
例えばInput要素では、StateのsearchRepositoryNameを参照しています。
そして入力のイベントにSetSearchRepositoryName
のアクションが紐付けられています。
HH.input
[ HP.value state.searchRepositoryName
, HE.onValueInput SetSearchRepositoryName
]
イベントが発火されると、アクションに対応するハンドラーの処理が呼ばれ、そこで状態の更新を行います。
handleAction = case _ of
-- アクションSetSearchRepositoryNameの処理
SetSearchRepositoryName searchRepositoryName -> do
H.modify_ (_ { searchRepositoryName = searchRepositoryName })
余談ですがハンドラー関数handleAction
が返す型はhandleAction :: forall o m. MonadAff m => Action -> H.HalogenM SearchGitHubRepositoryState Action () o m Unit
となっており、HalogenM
型を返すのですが、このHalogenM
がMonadState
のインスタンスになっているため、modify_
で状態を更新できるのです。
instance monadStateHalogenM :: MonadState state (HalogenM state action slots output m) where
state = HalogenM <<< liftF <<< State
ちなみに検索のアクションに対応する処理はこうなっています。
こちらは(今回説明しない)searchRepositoryByName
関数の処理の中で状態を更新しています。
SearchRepository -> do
searchRepositoryByName =<< H.gets _.searchRepositoryName
Deku
Deku
の方はreactiveなアーキテクチャになっており、React Hooks
のようなState Hooks
を用いて状態を管理します。
具体的にはHooks
の関数として用意されているuseState'
関数やuseState
関数を使います。
これらを使うと、状態を更新するための関数と状態からなるTuple
が返されるので、状態の参照や更新にはこれらを使うことになります。
使っている箇所の例はこちらです。
setName /\ name <- useState'
これらをInput要素では次のように使っています。
D.input
[ DA.xtypeText
, DA.value name
, DL.valueOn_ DL.change setName
]
[]
使い方としてはまぁ簡単でvalueにname
を渡し、値が変更されたらsetName
を呼ぶように指定しているだけです。
buttonがクリックされたときの説明もしておきましょう。
D.button
[ DA.disabled $ isLoading <#> show
, DL.click_ \_ -> ref >>= searchRepositoryByName >>> launchAff_
]
[ D.text_ "Search" ]
ref >>= searchRepositoryByName
の部分ですが、ref
というのは次のようにname
を元にuseRef
関数で作られたものです。
setName /\ name <- useState'
ref <- useRef mempty name
name
を直接使うこともできますが、パフォーマンス上こちらを使う方がいいそうなので基本的にこちらを使っています。
(参照: https://purescript-deku.netlify.app/core-concepts/more-hooks)
ちなみにMonadState
の関数を使えたHalogen
版はDriverのレイヤーに更新のこういう処理を定義していました。
presenterPortFunction :: forall m. MonadState SearchGitHubRepositoryState m => GitHubRepositoryPresenterPortFunction m
presenterPortFunction = {
setRepositories: \r -> modify_ (_ { repositories = Right r }),
setLoading: \loading -> modify_ (_ { isLoading = loading }),
setErrorMessage: \m -> modify_ (_ { repositories = Left m })
}
しかしDeku
の場合は状態の更新にuseState
やuseState'
で作った関数を用いる必要があるため、依存関係をとりまとめてUseCaseの関数を作る部分をView側に持ってきています(別のところで作ってComponentを作る関数に渡してもよかったのですが、一旦ここに持ってきてます)。
setRepositories /\ repositories <- useState'
setLoading /\ isLoading <- useState'
let
gf = gitHubRepositoryPortFunction gitHubRepositoryGatewayPortFunction
pf = gitHubRepositoryPresenterPortFunction {
setRepositories: \r -> liftEffect $ setRepositories (Right r),
setLoading: \loading -> liftEffect $ setLoading loading,
setErrorMessage: \m -> liftEffect $ setRepositories (Left m)
}
functions = build (merge (gf)) pf
searchRepositoryByName name = runReaderT (execute (GitHubRepositoryName name)) functions
その影響で、Halogen
版にはあったDriverのレイヤーの処理と、上記のようなことを行っていたControllerの処理が不要になりました(移動しただけ)。
依存関係の参照としては外側から内側に向いていることは変わりないので問題ないでしょう。
CSSの比較
こちらに関しては両者ほとんど変わりません。
styleOwner = CSS.style do
width $ px 150.0
overflow hidden
textWhitespace whitespaceNoWrap
textOverflow ellipsis
styleUrl = CSS.style do
width $ px 350.0
overflow hidden
textWhitespace whitespaceNoWrap
textOverflow ellipsis
styleUpdateDate = CSS.style do
width $ px 100.0
styleOwner = CSS.render do
width $ px 150.0
overflow hidden
textWhitespace whitespaceNoWrap
textOverflow ellipsis
styleUrl = CSS.render do
width $ px 350.0
overflow hidden
textWhitespace whitespaceNoWrap
textOverflow ellipsis
styleUpdateDate = CSS.render do
width $ px 100.0
使用している箇所も似ています。
renderRepository repository =
HH.div
[ styleContainer ]
[ HH.div [styleOwner] [HH.text repository.owner]
, HH.div [styleUrl] [HH.a [HP.href repository.url ] [ HH.text repository.name ]]
, HH.div [styleUpdateDate] [HH.text repository.updateDate]
]
renderRepository repository =
D.div
[ DA.style_ "display: flex; column-gap: 8px;" ]
[ D.div [ DA.style_ styleOwner ] [D.text_ repository.owner]
, D.div [ DA.style_ styleUrl ] [D.a [DA.href_ repository.url ] [ D.text_ repository.name ]]
, D.div [ DA.style_ styleUpdateDate ] [D.text_ repository.updateDate]
]
どちらもpurescript-css
を使うことができるので、書き味はほとんど変わらないですね。
ただDeku
の方は上述の通りHTMLを直接書ける(つまりCSSもHTMLの中に書ける)という違いはあります。
まとめ
- DOMの構築方法やCSSの扱い方は似ている。ただし
Deku
は静的な部分に関してはHTMLを直接書ける。 - 状態管理の方法は異なる。
Halogen
はFluxライク、Deku
はHooksライク。
おわりに
同じ題材を用いて2つのライブラリを比較してみましたが、いかがでしたでしょうか。
個人的にはDeku
の状態管理方法はシンプル(主観です)でいいなと思いましたが、その一方で更新の処理を外に出しづらいなとも思いました(Halogen
は型クラスの仕組みを使っていてそこら辺コントロールしやすかった)。
あとはDeku
の非PureScriptエンジニアでも一定HTMLに手を加えられそうというのはいいですね。
他にもWeb Component
にする場合もDeku
はサポートしていて、やりやすそうでした。
(Discordを読み漁った感じ、Halogen
はFFI使えばいいじゃんという考え方っぽい)
アプリケーション全体のアーキテクチャ観点だと、レイヤーが多い場合のClean Architectureと比べるとThree Layer Haskell Cakeはシンプルでいいのですが、Three Layer Haskell Cakeの「すべての型クラスのインスタンスになる型(所謂AppM)を用意しないといけない」という部分は個人的に嫌だなぁと思っていて、それに対して一つ一つの画面を小さくWeb Component化すればつらみを軽減できるはずなので、そういった面でもDeku
はよさそうだと思いました。
ただ実績の面だとプロダクション環境で使われているというHalogen
の方が安心感はありますね。
(Starの数も圧倒的に多い→ユーザーが多い→情報が多い)
Discussion