Elm Architecture in PureScript III

综合编程 2015-10-10

10 Oct 2015

Dynamic Lists of Counters

On thelast post, we implemented a pair of counters. Now, we’ll generalize that out to a dynamic list of counters, and later, give them all remove buttons. In the process, we’ll learn how to combine components, stack them, peek on them, and otherwise deal with them appropriately.

The code for this is available in this repository
.

Let’s get started! We want a list of counters, a button to add a counter, and a button to remove a counter. Let’s define our state and inputs:

type StateP =
  { counterArray :: Array Int
  , nextID :: Int
  }

initialState :: StateP
initialState =
  { counterArray: []
  , nextID: 0
  }

data Input a
  = AddCounter a
  | RemoveCounter a

Another quick detour to define our parent-level state and query types:

type State g =
  InstalledState StateP Counter.State Input Counter.Input g CounterSlot

type Query =
  Coproduct Input (ChildF CounterSlot Counter.Input)

And, our UI function:

ui :: forall g. (Plus g)
   => Component (State g) Query g
ui = parentComponent render eval
  where
    render state = 
      H.div_ 
        [ H.h1_ [ H.text "Counters" ]
        , H.ul_ $ map (i -> mslot (CounterSlot i) Counter.ui (Counter.init 0)) state.counterArray
        , H.button [ E.onClick $ E.input_ AddCounter ]
                   [ H.text "Add Counter" ]
        , H.button [ E.onClick $ E.input_ RemoveCounter ]
                   [ H.text "Remove Counter" ]
        ]

    eval :: EvalParent Input StateP Counter.State Input Counter.Input g CounterSlot
    eval (AddCounter next) = do
      modify addCounter
      pure next
    eval (RemoveCounter next) = do
      modify removeCounter
      pure next

mslot :: forall s f g p i. p -> Component s f g -> s -> HTML (SlotConstructor s f g p) i
mslot slot comp state = H.slot slot _ -> { component: comp, initialState: state }

Basically the same thing we’ve been working with already! Instead of keeping a CounterSlot 0
and CounterSlot 1
around, we’ve got an array of integers. When we want to render them, we map over them with the slot type constructor and the H.slot
to give them a place to go. Halogen figures out all of the event routing for us.

Removing a Counter

Alright, it’s time to give counters their own remove button. Rather than touch the counter at all, we’re simply going to wrap the existing counter component in a new component. The sole responsibility of this component will be handling the removal of counters.

There’s a bit of boiler plate around the State and Query, but after that, the result is pretty tiny!

-- src/Example/CounterRem.purs
data Input a = Remove a

type State g =
  InstalledState Unit Counter.State Input Counter.Input g CounterSlot
type Query =
  Coproduct Input (ChildF CounterSlot Counter.Input)

ui :: forall g. (Plus g)
   => Component (State g) Query g
ui = parentComponent render eval
  where
    render _ =
        H.div_ 
          [ mslot (CounterSlot 0) Counter.ui (Counter.init 0)
          , H.button [ E.onClick $ E.input_ Remove ]
                     [ H.text "Remove" ]
          ]
    eval :: EvalParent Input Unit Counter.State Input Counter.Input g CounterSlot
    eval (Remove a) = pure a

Since we’re not maintaining any state, we’ll just use the Unit
type to signify that. Our eval
function is going to punt the behavior to the parent component.

Now… Halogen does some impressive
type trickery. Coproducts, free monads, query algebrae… it can be pretty intimidating. There’s a decent amount of associated boilerplate as well. We’re about to get into some of that.

Let’s look at InstalledState
in the Halogen documentation
:

type InstalledState s s' f f' g p = 
  { parent   :: s
  , children :: Map p (Tuple (Component s' f' g) s')
  , memo     :: Map p (HTML Void (Coproduct f (ChildF p f') Unit)) 
  }

It’s a record with a parent state, a map from child slots to child states, and a map from child slots to memoized HTML.

But what is all of this coproduct
stuff again? A Coproduct
is defined like this:

newtype Coproduct f g a = Coproduct (Either (f a) (g a))

It’s a way of saying “I have a value of type a inside of a functor. That functor is either f or g.” We know we can specialize f
in the InstalledComponent
to our Input
query algebra. And ChildF p f'
is a given child’s identifier and the child’s query algebra. Halogen is using the coproduct structure to keep track of the children’s query algebra inputs.

Revisiting our type synonyms again, we have:

type State g =
  InstalledState Unit Counter.State Input Counter.Input g CounterSlot

The true state of this component isn’t just Unit
– it’s the result of installing the Counter.State
into this component. We’re giving that a name we can reference, and allowing the caller to provide the functor.

type Query =
  Coproduct Input (ChildF CounterSlot Counter.Input)

Finally, our QueryMiddle
just fills in the types for the combined query algebra.

Alright! Awesome! We’ve augmented a component with a Remove
button. Let’s embed that into a list. We’ll actually get to reuse almost everything from example three!

-- src/Example/Four.purs
data Input a = AddCounter a

type State g =
  InstalledState StateP (Counter.State g) Input Counter.Query g CounterSlot

type Query =
  Coproduct Input (ChildF CounterSlot Counter.Query)

ui :: forall g. (Plus g)
   => Component (State g) Query g
ui = parentComponent' render eval peek
  where

Ah! We’re peeking! I can tell because of the peek
function. And also the '
on the end of parentComponent'
. The '
indicates peeking.

Peeking is the way to inspect child components in purescript-halogen. So when a child component of a peeking parent is done with an action, then the parent gets a chance to see the action and act accordingly.

    render state =
      H.div_ 
        [ H.h1_ [ H.text "Counters" ]
        , H.ul_ (map (mapSlot CounterSlot Counter.ui (installedState unit)) state.counterArray)
        , H.button [ E.onClick $ E.input_ AddCounter ]
                   [ H.text "Add Counter" ]
        ]

    eval :: EvalParent _ _ _ _ _ g CounterSlot
    eval (AddCounter next) = do
      modify addCounter
      pure next

mapSlot slot comp state index = mslot (slot index) comp state

Rendering and evalling work exactly as you’d expect. Let’s look at peeking!

    peek :: Peek (ChildF CounterSlot Counter.Query) StateP (Counter.State g) Input Counter.Query g CounterSlot
    peek (ChildF counterSlot (Coproduct queryAction)) =
      case queryAction of
        Left (Counter.Remove _) ->
          modify (removeCounter counterSlot)
        _ ->
          pure unit

So this is kind of a more complex peek
than you’d normally start with. My bad. Generally, the peek
function has a definition that’d look like:

peek (ChildF childSlot action) =
  case action of
       DoThing next -> -- ...

But we’re working with the installed/child components who manage their state using the coproduct machinery, and as of now, we have to manually unwrap the coproduct and pattern match on the Either
value inside. When we match on the Left
value, we get to see the immediate child’s actions. If we were to match on the Right
value, then we’d get to inspect children’s of children’s actions.

In any case, we peek
on the child component, and if it just did a Remove
action, then we modify our own state. Otherwise, we ignore it.

-- src/Main.purs
main = ... do
  app <- runEx4
  appendToBody app.node

runEx4 = runUI Ex4.ui (installedState (Ex3.initialState))

And now we’ve got our dynamic list of removable embedded counters going.

Next up, we’ll be looking at AJAX, effects, and other fun stuff.

UPDATE: Modularize me, cap’n!

Ok, so I wasn’t happy with how unmodular the above example was. We had to redefine a whole component just to add a remove button. If I wanted another component that had a remove button, I’d have to redo all that work! No thanks. Instead, I made a higher order component out of it.

There’s no meaning for distinguishing between children, because it only has one. There’s no state involved either, so we’ll use Unit for both of them. The only query is Remove. So let’s put that all together!

-- src/Example/RemGeneric.purs
data QueryP a = Remove a

type State s f g =
  InstalledState Unit s QueryP f g Unit

type Query f =
  Coproduct QueryP (ChildF Unit f)

addRemove :: forall g s f. (Plus g)
          => Component s f g
          -> s
          -> Component (State s f g) (Query f) g
addRemove comp state = parentComponent render eval
  where
    render _ =
        H.div_ 
          [ H.slot unit _ -> { component: comp, initialState: state } 
          , H.button [ E.onClick $ E.input_ Remove ]
                     [ H.text "Remove" ]
          ]
    eval :: EvalParent QueryP Unit s QueryP f g Unit
    eval (Remove a) = pure a

Easy! We’ve got a few extra type variables to represent where the child state and query will go. Fairly standard type synonym definitions for use in client components. The only kinda tricky part is rendering: we accept a component and initial state as parameters.

Cool! Let’s see what the definition for the counter looks like with the remove button added:

-- src/Example/CounterRemPrime.purs
type State g = Rem.State Counter.State Counter.Input g
type Query = Rem.Query Counter.Input

ui :: forall g. (Plus g)
   => Component (State g) Query g
ui = Rem.addRemove Counter.ui (Counter.init 0)

More type synonyms! And a fairly nice one liner function to wrap the counter.

The code for the list itself is essentially unchanged. We do have to import the RemGeneric
as well as the CounterRemPrime
module to be able to use the RemGeneric.Input
type, but the type declarations hardly change at all.

All in all, this level of componentiziation is fairly easy! Defining the type synonyms is a bit of a pain, but you’ll likely be writing a lot fewer of them when you have more involved components.

Other posts in the series:

您可能感兴趣的

游戏开发与程序设计知识总结06——常见软件架构模式... 更新日志 每此对思维导图有改动或者在 github 中有了对应的实现,则增加一条更新日志。 前言 这是 游戏开发与程序设计知识总结 系列文章的第六篇常见软件架构模式。本系列文章的初衷源于我正在找工作,所以对开发工作中用到的一些知识点想做一次完整的梳理,查缺补漏。 每篇文章预...
Maintainable Architecture – Dependency Injection Creating a maintainable, flexible codebase is not easy but is an essential part of software engineering. In this series we’ll take a look at a simp...
面向应用的云端迁移方法 本文要点 在向云端迁移时,如果使用的是以架构为中心的方法,那么并不会提供用户想象中的优点。 尽可放心使用那些无需自己管理架构的甲方云服务。 在规划故障时,确保考虑了应用故障、服务故障、架构故障和设施故障。 认真考虑所需的服务规模,充分利用云所提供的弹性,一些时...
A Critique of “Clean Architecture” by Robert C. Ma... Clean Architecture is the latest book in the Clean series, following Clean Code , and The Clean Coder written by the Softwa...
软件变革下设计原则 传统大型软件系统 ,多以功能需求驱动设计与开发。在体系结构上是一个单体应用,变更修改往往是牵一而发动全身;在系统生态上是一个封闭系统,系统集成是大量定制开发。单体封闭的系统在交付中面临着越来越多的挑战,提升系统的竞争力首先是在软件架构上先行。软件系统发展也需像硬件一样不断地更新换代,软件架构设计需要...