Pull to refresh
84.41
Rating
Typeable
Functional programming, High assurance, Consulting

Создаем веб-приложение на Haskell с использованием Reflex. Часть 2

Typeable corporate blogWebsite developmentProgrammingHaskellFunctional Programming
Tutorial

Часть 1.


Всем привет! Продолжаем серию туториалов по разработке веб-приложения на Reflex.
В этой части мы добавим возможность выполнять различные манипуляции со списком задач.


intro


Действия над Todo


Добавим возможность отмечать задания выполненными, а также редактировать и удалять их.
В первую очередь для этого расширим тип Todo, добавив состояние. В случае, если задание не выполнено, то его можно редактировать.


data TodoState
  = TodoDone
  | TodoActive { stateEdit :: Bool }
  deriving (Generic, Eq, Show)

data Todo = Todo
  { todoText  :: Text
  , todoState :: TodoState }
  deriving (Generic, Eq, Show)

newTodo :: Text -> Todo
newTodo todoText = Todo { todoState = TodoActive False, .. }

Далее определим события, происходящие в системе. В рабочих проектах мы использовали два подхода для этого. Первый — это перечислить все возможные события как отдельные конструкторы и реализовать функцию-обработчик, которая будет обновлять состояние в зависимости от произошедшего события.


data TodoEvent
  = NewTodo Todo
  | ToggleTodo Int
  | StartEditTodo Int
  | FinishEditTodo (Text,Int)
  | DeleteTodo Int
  deriving (Generic, Eq, Show)

Плюсами такого подхода является возможность увидеть, какое конкретное событие происходит в системе, и обновление, которое оно несет в себе (все это позволяет сделать функция traceEvent). Но эти преимущества не всегда получается использовать, особенно когда событие несет в себе очень много данных, которые, в итоге, сложно проанализировать. Если все же необходимо увидеть изменение значений, то события, в любом случае, изменяют Dynamic, значение которого также можно отследить при помощи функции traceDyn.


Другой подход — использование функций обновления, которые представляются в виде моноида Endo (грубо говоря, это абстрактное название функции, у которой совпадает тип аргумента и результата). Суть этого подхода в том, что значением, которое несёт событие обновления, является функция, которая и задаёт саму логику обновления. В данном случае, мы теряем возможность вывести значение события (которая, как выяснилось, не всегда полезна), но очевидным плюсом является то, что нам нет необходимости иметь доступ к текущему состоянию, создавать тип со всеми возможными событиями (которых может быть очень много), а также определять отдельный обработчик, который будет обновлять состояние в соответствии с полученным событием.


В данном туториале мы будем использовать второй подход.


Изменим структуру корневого виджета:


rootWidget :: MonadWidget t m => m ()
rootWidget =
  divClass "container" $ do
    elClass "h2" "text-center mt-3" $ text "Todos"
    newTodoEv <- newTodoForm
    rec
      todosDyn <- foldDyn appEndo mempty $ leftmost [newTodoEv, todoEv]
      delimiter
      todoEv <- todoListWidget todosDyn
    blank

Первое, что мы тут видим — это использование расширения RecursiveDo (его, соответственно надо включить). Это один из распространенных приёмов в разработке reflex приложений, т.к. очень часто возникают ситуации, когда событие, происходящее в нижней части страницы, влияет на элементы на верху страницы. В данном случае событие todoEv используется в определении todosDyn, а todosDyn в свою очередь является аргументом для виджета, из которого приходит событие todoEv.


Далее мы видим обновление параметров функции foldDyn. Здесь есть использование новой функции leftmost. Она принимает список событий и возвращает событие, которое срабатывает в тот момент, когда сработает любое из событий в списке. Если в данный момент срабатывает два события из списка, то будет возвращено то, что стоит левее в списке (отсюда и название). Также список заданий теперь не список, а IntMap (для упрощения будем использовать type Todos = IntMap Todo). В первую очередь это сделано для того, чтобы мы могли напрямую обращаться к какому-либо элементу по идентификатору. Для обновления списка используется appEndo. В случае, если бы мы определили каждое событие как отдельный конструктор, то нам бы пришлось также определить функцию-обработчик, которая выглядела бы примерно так:


updateTodo :: TodoEvent -> Todos -> Todos
updateTodo ev todos = case ev of
  NewTodo todo           -> nextKey todos =: todo <> todos
  ToggleTodo ix          -> update (Just . toggleTodo) ix todos
  StartEditTodo ix       -> update (Just . startEdit) ix todos
  FinishEditTodo (v, ix) -> update (Just . finishEdit v) ix todos
  DeleteTodo ix          -> delete ix todos

Несмотря на отсутствие необходимости определять эту функцию, тут используются несколько других вспомогательных функций, которые все равно понадобятся нам в будущем.


startEdit :: Todo -> Todo
startEdit todo = todo { todoState = TodoActive True }

finishEdit :: Text -> Todo -> Todo
finishEdit val todo = todo 
  { todoState = TodoActive False, todoText = val }

toggleTodo :: Todo -> Todo
toggleTodo Todo{..} = Todo {todoState = toggleState todoState,..}
  where
    toggleState = \case
      TodoDone     -> TodoActive False
      TodoActive _ -> TodoDone

nextKey :: IntMap Todo -> Int
nextKey = maybe 0 (succ . fst . fst) . maxViewWithKey

Изменилась и функция добавления нового элемента, теперь она возвращает событие, а не само задание. Также добавим очистку поля после добавления нового задания.


newTodoForm :: MonadWidget t m => m (Event t (Endo Todos))
newTodoForm = rowWrapper $ el "form" $ divClass "input-group" $ mdo
  iEl <- inputElement $ def
    & initialAttributes .~
      (  "type" =: "text"
      <> "class" =: "form-control"
      <> "placeholder" =: "Todo" )
    & inputElementConfig_setValue .~ ("" <$ btnEv)
  let
    addNewTodo = \todo -> Endo $ \todos ->
      insert (nextKey todos) (newTodo todo) todos
    newTodoDyn = addNewTodo <$> value iEl
    btnAttr = "class" =: "btn btn-outline-secondary"
      <> "type" =: "button"
  (btnEl, _) <- divClass "input-group-append" $
    elAttr' "button" btnAttr $ text "Add new entry"
  let btnEv = domEvent Click btnEl
  pure $ tagPromptlyDyn newTodoDyn $ domEvent Click btnEl

Функция todoListWidget теперь возвращает изменение списка, и она так же подверглась небольшим изменениям:


todoListWidget :: MonadWidget t m => Dynamic t Todos -> m (Event t (Endo Todos))
todoListWidget todosDyn = rowWrapper $ do
  evs <- listWithKey 
    (M.fromAscList . IM.toAscList <$> todosDyn) todoWidget
  pure $ switchDyn $ leftmost . M.elems <$> evs

Первое, что замечаем, это замена функции simpleList функцией listWithKey. Отличаются они типом первого параметра — первая функция принимает список [], вторая — Map. Список будет выведен в отсортированным по ключу порядке. Здесь важно возвращаемое значение. Каждое задание возвращает событие (удаление, изменение). В конкретно нашем случае функция listWithKey будет иметь следующий тип:


listWithKey
  :: MonadWidget t m
  => Dynamic t (Map Int Todo)
  -> (Int -> Dynamic t Todo -> m (Event t TodoEvent))
  -> m (Dynamic t (Map Int TodoEvent))

Примечание: эта функция является частью пакета reflex и имеет более сложный тип. Здесь уже показан специализированный тип.

Здесь используется знакомая уже функция leftmost для всех значений из Map. Выражение leftmost . elems <$> evs имеет следующий тип: Dynamic t (Event t TodoEvent). Для того чтобы извлечь Event из Dynamic, используется функция switchDyn. Она работает следующим образом: возвращает событие, которое срабатывает тогда, когда срабатывает внутреннее событие. В том случае, если Dynamic и Event срабатывают одновременно, будет возвращен старый Event, до момента обновления события в Dynamic. Существует функция switchPromtlyDyn, которая работает иначе: если одновременно происходит обновление Dynamic, срабатывает событие, которое было до обновления Dynamic, и "стреляет" событие, которые теперь содержит Dynamic, то будет возвращено именно новое событие, которое теперь содержит Dynamic. Если такая ситуация является невозможной, то всегда лучше предпочитать использовать switchDyn, т.к. функция switchPromtlyDyn является более сложной и выполняет дополнительные действий, и, более того, может привести к циклам.


У задания появились разные состояния, поэтому функция отображения одного задания также претерпела изменения:


todoWidget :: MonadWidget t m => Int -> Dynamic t Todo -> m (Event t (Endo Todos))
todoWidget ix todoDyn = do
  todoEvEv <- dyn $ ffor todoDyn $ \td@Todo{..} -> case todoState of
    TodoDone         -> todoDone ix todoText
    TodoActive False -> todoActive ix todoText
    TodoActive True  -> todoEditable ix todoText
  switchHold never todoEvEv

Здесь мы используем новую функцию dyn. Она имеет следующий тип:


dyn
  :: (Adjustable t m, NotReady t m, PostBuild t m)
  => Dynamic t (m a)
  -> m (Event t a)

В качестве входного параметра она получает виджет, завернутый Dynamic. Это означает, что при каждом обновлении Dynamic, будет также обновляться DOM. Выходным значением является событие, которое несет значение, возвращаемое виджетом. Специализированный тип для нашего случая будет выглядеть следующим образом:


dyn
  :: MonadWidget t m
  => Dynamic t (m (Event t (Endo Todos)))
  -> m (Event t (Event t (Endo Todos)))

Тут мы встречаемся с событием, вложенным в другое событие. Есть две функции из пакета reflex, которые могут работать с таким типом: coincidence и switchHold. Первая функция возвращает событие, которое будет срабатывать только тогда, когда срабатывают одновременно внешнее и внутреннее события. Это не наш случай. Функция switchHold имеет следующий тип:


switchHold :: (Reflex t, MonadHold t m) => Event t a -> Event t (Event t a) -> m (Event t)

Эта функция переключается на новое событие каждый раз, когда срабатывает внешнее событие. До первого срабатывания внешнего события, будет стрелять событие, переданное первым параметром. Именно так мы и используем эту функцию в нашем случае. До какого-либо первого изменения списка, оттуда не может прийти никакое событие, и мы используем событие never. Это специальное событие, которое, в соответствии с названием, не срабатывает никогда.


Функция todoWidget использует разные виджеты для разных состояний.


todoActive :: MonadWidget t m => Int -> Text -> m (Event t (Endo Todos))
todoActive ix todoText = divClass "d-flex border-bottom" $ do
  divClass "p-2 flex-grow-1 my-auto" $
    text todoText
  divClass "p-2 btn-group" $ do
    (doneEl, _) <- elAttr' "button"
      (  "class" =: "btn btn-outline-secondary"
      <> "type" =: "button" ) $ text "Done"
    (editEl, _) <- elAttr' "button"
      (  "class" =: "btn btn-outline-secondary"
      <> "type" =: "button" ) $ text "Edit"
    (delEl, _) <- elAttr' "button"
      (  "class" =: "btn btn-outline-secondary"
      <> "type" =: "button" ) $ text "Drop"
    pure $ Endo <$> leftmost
      [ update (Just . toggleTodo) ix <$ domEvent Click doneEl
      , update (Just . startEdit) ix  <$ domEvent Click editEl
      , delete ix <$ domEvent Click delEl
      ]

todoDone :: MonadWidget t m => Int -> Text -> m (Event t (Endo Todos))
todoDone ix todoText = divClass "d-flex border-bottom" $ do
  divClass "p-2 flex-grow-1 my-auto" $
    el "del" $ text todoText
  divClass "p-2 btn-group" $ do
    (doneEl, _) <- elAttr' "button"
      (  "class" =: "btn btn-outline-secondary"
      <> "type" =: "button" ) $ text "Undo"
    (delEl, _) <- elAttr' "button"
      (  "class" =: "btn btn-outline-secondary"
      <> "type" =: "button" ) $ text "Drop"
    pure $ Endo <$> leftmost
      [ update (Just . toggleTodo) ix <$ domEvent Click doneEl
      , delete ix <$ domEvent Click delEl
      ]

todoEditable :: MonadWidget t m => Int -> Text -> m (Event t (Endo Todos))
todoEditable ix todoText = divClass "d-flex border-bottom" $ do
  updTodoDyn <- divClass "p-2 flex-grow-1 my-auto" $
    editTodoForm todoText
  divClass "p-2 btn-group" $ do
    (doneEl, _) <- elAttr' "button"
      (  "class" =: "btn btn-outline-secondary"
      <> "type" =: "button" ) $ text "Finish edit"
    let updTodos = \todo -> Endo $ update (Just . finishEdit todo) ix
    pure $
      tagPromptlyDyn (updTodos <$> updTodoDyn) (domEvent Click doneEl)

editTodoForm :: MonadWidget t m => Text -> m (Dynamic t Text)
editTodoForm todo = do
  editIEl <- inputElement $ def
    & initialAttributes .~
      (  "type" =: "text"
      <> "class" =: "form-control"
      <> "placeholder" =: "Todo")
    & inputElementConfig_initialValue .~ todo
  pure $ value editIEl

Все использованные тут функции были рассмотрены раньше, поэтому не будем углубляться в объяснение каждой в отдельности.


Оптимизация


Вернемся к функции listWithKey:


listWithKey
  :: MonadWidget t m
  => Dynamic t (Map Int Todo)
  -> (Int -> Dynamic t Todo -> m (Event t TodoEvent))
  -> m (Dynamic t (Map Int TodoEvent))

Функция работает таким образом, что любое обновления переданного Dynamic спровоцирует обновление каждого отдельного элемента. Даже если мы, например, изменили один элемент, это обновление будет передано в каждый элемент, хоть он и не изменит значения. Теперь вернемся к функции todoWidget.


todoWidget :: MonadWidget t m => Int -> Dynamic t Todo -> m (Event t (Endo Todos))
todoWidget ix todoDyn = do
  todoEvEv <- dyn $ ffor todoDyn $ \td@Todo{..} -> case todoState of
    TodoDone         -> todoDone ix todoText
    TodoActive False -> todoActive ix todoText
    TodoActive True  -> todoEditable ix todoText
  switchHold never todoEvEv

Если вспомним, как работает функция dyn, то она обновляет DOM каждый раз, когда происходит обновление todoDyn. Учитывая, что при изменении одного элемента из списка, это обновление передается на каждый элемент в отдельности, получаем, что весь участок DOM, который выводит наши задания, будет перестраиваться (проверить это можно при помощи панели разработчика в браузере). Это явно не то, что мы хотим. Тут на помощь приходит функция holdUniqDyn.


intro


todoWidget :: MonadWidget t m => Int -> Dynamic t Todo -> m (Event t TodoEvent)
todoWidget ix todoDyn' = do
  todoDyn <- holdUniqDyn todoDyn'
  todoEvEv <- dyn $ ffor todoDyn $ \td@Todo{..} -> case todoState of
    TodoDone         -> todoDone ix td
    TodoActive False -> todoActive ix td
    TodoActive True  -> todoEditable ix td
  switchHold never todoEvEv

Мы добавили строку todoDyn <- holdUniqDyn todoDyn'. Что тут происходит? Дело в том, что хоть Dynamic и "выстреливает", значение, которое он содержит, не меняется. Функция holdUniqDyn работает именно так, что если переданный ей Dynamic "выстрелил" и не изменил свое значение, то выходной Dynamic не будет "выстреливать", и, соответственно, в нашем случае не будет лишних перестроек DOM.


intro


Полученный результат можно посмотреть в нашем репозитории.


В следующей части рассмотрим другой способ управления событиями и использование библиотеки GHCJS-DOM.

Tags:haskellfrpreflexreflex-platformreflex-frpвеб-разработкафункциональное программированиефпфрпfp
Hubs: Typeable corporate blog Website development Programming Haskell Functional Programming
Total votes 9: ↑9 and ↓0 +9
Views1.5K

Top of the last 24 hours

Information

Founded
Location
Россия
Website
typeable.io
Employees
11–30 employees
Registered