🌲

PureScriptのUIライブラリHalogenとDekuで同じアプリを作って比較してみた

2023/12/31に公開

私はまったくといっていいほどX(旧Twitter)にはポストしないで専ら読むだけなのですが、最近そのXを眺めていたらゆきくらげさん無名隱者さん達がDekuというUIライブラリに言及しているのを見かけまして、ちょっと気になったのでPureScriptで一番メジャーなUIライブラリHalogenと比べてみました。

はじめに

どう比較するか

全く同じ機能を持つアプリケーションをHalogenDekuで作って比較します。
アプリケーション自体は、最近書いたTagless Finalを使ったクリーンアーキテクチャーの記事と同じものを使用します。
その理由は、上記の記事は丁度Halogenを使っていたのと、クリーンアーキテクチャーにより責務が分けられている故にライブラリに依存する部分だけ書き換えれば済むので楽だったからです(実際UseCase、Gateway、Presenterなどのレイヤーは全く同じコードが使えました)。

比較の目的

それぞれのライブラリの書き味・使い心地がどう違うのかを確かめるのが目的です。

やらないこと

  • 比較に使うのは一画面だけの小さなアプリなので、ライブラリのあらゆる機能についての比較は行いません(現実的でない)。
  • HalogenDeku自体の説明
    (これらのライブラリはドキュメントがしっかり書かれているので、公式のドキュメントを見るのが一番いいと思います)

題材とするアプリケーション

この記事でサンプルとして作ったアプリケーションを題材として用います。
GitHubから言語がPureScriptのリポジトリを検索して結果を表示するだけのアプリです。
(アーキテクチャの解説を目的として作ったものなので見た目はマジで適当です)

ソースコード本体

これがHalogenを使った版です
https://github.com/pujoheadsoft/purescript-cleanarchitecture-tagless-final

そしてこちらがDekuを使った版
https://github.com/pujoheadsoft/purescript-cleanarchitecture-tagless-final-deku

比較してみる

全体像の比較

先に全体像を載せて、その後に部分部分の比較を行おうと思います。
まずはHalogen版です。

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版です。

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を構築している箇所を比較してみましょう。

Halogen版
, 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" ]
Deku版
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版のこの部分です。

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型を返すのですが、このHalogenMMonadStateのインスタンスになっているため、modify_で状態を更新できるのです。

Halogen.Query.HalogenM
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の場合は状態の更新にuseStateuseState'で作った関数を用いる必要があるため、依存関係をとりまとめて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の比較

こちらに関しては両者ほとんど変わりません。

Halogen版
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
Deku版
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

使用している箇所も似ています。

Halogen版
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]
    ]
Deku版
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