Сравнение Elm и Reflex

В этой статье мы поговорим о двух принципиально разных подходах к реактивному программированию.

Elm, в отличие от Reflex — это целый язык, а не библиотека, поэтому сравнивать их не очень корректно. Тем не менее, можно показать разницу между подходами, а также рассказать, какие практические трудности могут возникнуть при разработке с использованием каждой из технологий.
4bbcqo3e26twgdi03i_9dwwjvbs.png


Elm и TEA

Elm — это функциональный язык программирования для создания реактивных веб-приложений. Приложения на Elm обязаны следовать The Elm Architecture (TEA) — простому паттерну проектирования, который подразумевает разделение кода на три части: model, view и update:


  • Model — это данные, которые используются в приложении, а также данные, описывающие сообщения (messages), необходимые для любой интерактивности.
  • View — это функция, преобразующая model в пользовательский интерфейс.
  • Update — это функция, ответственная за обновление состояния (она принимает model и message, и возвращает обновлённую model).

FRP в стиле Reflex

Reflex — это фреймворк, позволяющий создавать реактивные веб-приложения на Haskell.

В отличие от Elm, Reflex не накладывает строгих ограничений на архитектуру приложения. Фреймворк предоставляет абстракции для управления состоянием, а программист волен использовать их, комбинируя произвольным образом.

Основных абстракций три. Это Event, Behavior и Dynamic.


Event

Event — это абстракция для описания дискретных событий, которые происходят время от времени, одномоментно. События параметризованы типом содержащегося в них значения.


Behavior

Behavior можно воспринимать как изменяющееся значение, которое может быть «считано» (sampled) в любой момент времени. Однако на обновление значения нельзя «подписаться».


Dynamic = Event + Behavior

Концептуально, Dynamic — это пара из Event и Behavior.

Получить каждый из компонентов Dynamic можно с помощью чистых функций updated и current:

current :: … => Dynamic t a -> Behavior t a
updated :: … => Dynamic t a -> Event t a

(Здесь и далее параметр типа t можно игнорировать, это особенность реализации. Ограничения (constraints) в типовых сигнатурах здесь и кое-где далее заменены на троеточия ради улучшения читаемости).

Dynamic гарантирует выполнение следующих инвариантов:


  • После «выстреливания» события, behavior изменяет собственное значение на значение из Event.
  • Каждому изменению behavior предшествует «выстреливание» события.

Используя библиотечные функции, создать «неправильный» Dynamic невозможно.

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

Важно отметить, что Dynamic может «обновляться» с тем же значением, которое было в нем раньше. Если от значений в Dynamic зависит участок DOM, произойдёт его перестройка, т.к. фреймворк не имеет возможности проверить произвольные значения на равенство (как этого избежать, будет показано далее).


Мы рассмотрим две реализации интерактивного виджета-«опросника» с использованием двух технологий. Пользователю показывается список вопросов с заранее известными вариантами ответа, среди которых пользователь выбирает по одному ответу на каждый вопрос, и после нажатия на кнопку проверки результата получает возможность сравнить свои ответы с правильными.

Кнопка проверки результата не должна быть доступна до того, как будет выбран ответ на каждый из вопросов. После проверки мы должны показать score: сколько ответов было правильными.

Полный код обоих приложений доступен по ссылке. (Ссылки на основные файлы для удобства: Elm, Reflex).

Для примера на Reflex, мы пользуемся фреймворком Obelisk. Он позволяет реализовывать не только фронтенд, но и бэкенд, а также отвечает за server-side rendering и routing. Мы же будем использовать его просто как систему сборки (бэкенда у нас не будет).


Описание состояния приложения

Как и Haskell, Elm поддерживает алгебраические типы данных. Код ниже объявляет типы данных, которые необходимы для создания нашего приложения (Model). Аналогичные объявления на Haskell концептуально ничем не отличаются, поэтому мы их не приводим.


Elm

type alias QuestionText = String
type alias AnswerText = String
type IsChosen = Chosen | NotChosen
type IsCorrect = Correct | Incorrect
type CanCheckAnswers = CanCheckAnswers | CantCheckAnswers
type AreAnswersShown = AnswersShown | AnswersHidden
type alias Answer =
    { answerText : AnswerText
    , isCorrect : IsCorrect
    }
type Score
    = NoScore
    | Score { totalQuestions : Int, correctAnswers : Int }
type alias Questions = List (QuestionText, List (Answer, IsChosen))
type alias Model =
    { areAnswersShown : AreAnswersShown
    , allQuestions : Questions
    , canCheckAnswers : CanCheckAnswers
    , score : Score
    }

Описание сообщений/событий


Elm

Тип, описывающий сообщение в Elm содержит все значения, которые могут нам понадобиться для обновления состояния. Для выбора варианта ответа мы будем использовать два индекса: номер вопроса и номер ответа на вопрос. Для простоты мы также храним в payload«е события новое значение IsChosen.

type Msg
    = SelectAnswer
      { questionNumber : Int
      , answerNumber : Int
      , isChosen : IsChosen }
    | CheckAnswers

Reflex

В Reflex мы имеем дело с несколькими независимыми значениями-событиями, поэтому для описания каждого из них мы используем отдельный тип:

-- | Выбор варианта ответа.
data SelectAnswer
  = SelectAnswer
  { questionNumber :: Int
  , answerNumber :: Int }

-- | Payload для события "показать ответы"
data CheckAnswers = CheckAnswers

Общая архитектура


Elm

Про архитектуру Elm-приложений было сказано очень многое — существует десятки статей на эту тему. Но, т.к. в Typeable мы не используем Elm, мы не можем поделиться опытом в этой области, и отсылаем читателя к другим источникам.



Reflex

Несмотря на то, что Reflex не накладывает жёстких ограничений на архитектуру, разумно следовать неким соглашениям об организации кода.

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

Начнём с общих соображений.

Достаточно удобно разделять представление (интерфейс) и внутреннюю логику приложения. Но что именно мы подразумеваем под представлением? Концептуально, представление можно определить как функцию, принимающую состояние виджета (в случае Reflex, оно динамическое, т.е. полностью или частично «обёрнуто» в Dynamic) и возвращающее некое описание интерфейса и множество событий, которые возникают при взаимодействии с интерфейсом.

За «описание интерфейса» отвечает монада, связанная констрейнтом DomBuilder. Здесь и далее мы везде используем констрейнт ObeliskWidget, (из Obelisk) которыйвключает в себя DomBuilder.

Таким образом, описание представления в виде функции на Haskell в общем случае могло бы иметь такой тип (пока что мы не конкретизируем типовые переменные events и state):

ui :: ObeliskWidget js t route m => state -> m events

Логика приложения будет закодирована в другой функции, которая, напротив, принимает множество событий и возвращает динамическое состояние:

model :: ObeliskWidget js t route m => events -> m state

Чтобы использовать их вместе, объявим вспомогательную функцию высшего порядка mkWidget:

mkWidget :: ObeliskWidget js t route m
  => (events -> m state) -> (state -> m events) -> m ()
mkWidget model ui  = void (mfix (model >=> ui))

Мы использовали функцию mfix(комбинатор неподвижной точки для монадического вычисления), имеющую тип MonadFixm => (a -> m a) -> m a,а также композицию стрелок Клейсли, которую иногда называют просто «рыбой»:

(>=>)::Monadm => (a -> m b) -> (b -> m c) -> (a -> m c)

Разделение на UI и «model» оказывается достаточно удобным при написании динамических виджетов. Можно усмотреть аналогию между ui и view в Elm, а также между «model» и update. Разница в том, как именно мы передаём состояние и события.

Вернёмся к нашему виджету.

Конкретизируем переменные state и events для нашего виджета-опросника.

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

data QuizEvents t = QuizEvents
  { selectAnswer :: Event t SelectAnswer
  , showAnswers :: Event t CheckAnswers }

Структура, содержащая динамические данные будет несколько сложнее:

data QuizState t = QuizState
  { areAnswersShown :: Dynamic t AreAnswersShown
  , allQuestions :: [(QuestionText, [(Answer, Dynamic t IsChosen)])]
  , canCheckAnswers :: Dynamic t CanCheckAnswers
  , score :: Dynamic t Score }

Мы «оборачиваем» в Dynamic только те части состояния, которые могут изменяться. В частности, в поле allQuestions в Dynamic «обёрнуто» только значение с типом IsChosen. Конечно, использование единственного Dynamic позволило бы написать более простой код. Но тогда мы были бы вынуждены каждый раз перестраивать даже те части DOM, которые являются статичными.

В этом ещё одно важное различие двух фреймворков — при использовании Reflex мы сами контролируем обновление DOM, а реализация VDOM, встроенная в Elm, делает это за нас.


Обновление состояния


Elm

Функция updateпринимает сообщение, старое состояние и возвращает новое:

update : Msg -> Model -> Model
update msg = case msg of
  SelectAnswer { questionNumber, answerNumber, isChosen } ->
      updateCanCheckAnswers <<
      ( mapAllQuestions
        <| updateAt questionNumber
        <| Tuple.mapSecond
        <| updateAnswers answerNumber isChosen )
  CheckAnswers -> mapAnswersShown (\_ -> AnswersShown) >> updateScore

Мы используем вспомогательные функции и оператор композиции функций (>>) чтобы выполнять обновление состояния по частям.

Достаточно неудобный синтаксис для обновления полей типов-записей в Elm приводит к необходимости вручную объявлять функции вроде mapAnswersShown, mapAllQuestions и т.п., которые просто применяют функцию к значению в поле. В Haskell мы бы могли воспользоваться линзами (см. пакет lens и его аналоги), которые генерируются автоматически для каждого типа данных.

updateCanCheckAnswers : Model -> Model
updateCanCheckAnswers model =
    { model | canCheckAnswers =
          if List.all hasChosenAnswer model.allQuestions
          then CanCheckAnswers
          else CantCheckAnswers }

updateScore : Model -> Model
updateScore model =
    let
        hasCorrectAnswer (_, answers) =
            List.any isCorrectAnswerChosen answers
        correctAnswers =
            List.length <| List.filter hasCorrectAnswer model.allQuestions
        totalQuestions = List.length model.allQuestions
    in
        { model | score =
              Score { correctAnswers = correctAnswers
                    , totalQuestions = totalQuestions } }

updateAnswers : Int -> IsChosen -> List (Answer, IsChosen) -> List (Answer, IsChosen)
updateAnswers answerIx newIsChosen =
    List.indexedMap <| \aix ->
        Tuple.mapSecond <| \isChosen ->
            if aix /= answerIx
            then
                if newIsChosen == Chosen
                then NotChosen
                else isChosen
            else newIsChosen

Reflex

Мы формируем динамическое состояние, принимая на вход события виджета, и сохраняя затем все динамические данные в полях возвращаемого значения QuizState:

mkQuizModel :: ObeliskWidget js t route m
  => [(QuestionText, [Answer])]
  -- ^ Список вопросов с ответами
  -> QuizEvents t
  -> m (QuizState t)
mkQuizModel questions events = do
  areAnswersShown <- holdDyn AnswersHidden (showAnswers events $> AnswersShown)
  allQuestions <- mkAllQuestionsModel questions events
  canCheckAnswers <- mkCanCheckAnswersModel allQuestions
  score <- mkScoreModel areAnswersShown allQuestions
  return QuizState{..}

Для этого мы используем несколько комбинаторов из модуля Reflex.Dynamic.

holdDyn :: MonadHold t m => a -> Event t a -> m (Dynamic t a) 

holdDyn — пожалуй, самый простой способ создать Dynamic из Event. Каждый раз, когда событие происходит, значение в полученном Dynamic изменяется на то, которое содержалось в событии. При этом, до того, как первое событие произошло, в Dynamic будет содержаться то значение, которое мы передали в качестве первого аргумента.

Таким образом, в динамическом значении areAnswersShown будет содержаться AnswersHidden до того, как произойдёт событие, хранящееся в поле showAnswers, а после этого — AnswersShown.

Во вспомогательной функции mkAllQuestionsModel мы итерируемся по пронумерованному списку вопросов, а во внутреннем цикле — по списку ответов на каждый из них, чтобы снабдить каждый динамическим состоянием с типом IsChosen:

mkAllQuestionsModel :: ObeliskWidget js t route m
  => [(QuestionText, [Answer])]
  -- ^ Список вопросов с ответами
  -> QuizEvents t
  -> m [(QuestionText, [(Answer, Dynamic t IsChosen)])]
mkAllQuestionsModel questions events = do
  for (enumerate questions)
    \(qNum, (questionText, answers)) -> do
      (questionText, ) <$> for (enumerate answers)
        \(aNum, Answer{..}) -> do
          let
            updChosenState SelectAnswer{questionNumber,answerNumber} isChosen = do
              guard (questionNumber == qNum)
              return
                if answerNumber == aNum
                then toggleChosen isChosen
                else NotChosen
          isChosenDyn <- foldDynMaybe updChosenState NotChosen
            (selectAnswer events)
          return (Answer{..}, isChosenDyn)

Выражение (questionText, ) — это синтаксический сахар для (\x -> (questionText, x)).

Комбинатор foldDynMaybe позволяет обновлять Dynamic, учитывая его предыдущее состояние, а также payload события. Maybe позволяет пропустить обновление в случае, если оно не требуется.

foldDynMaybe :: (Reflext,MonadHoldt m,MonadFixm) => (a -> b ->Maybeb) -> b ->Eventt a -> m (Dynamict b)

Выражение foldDynMaybe updChosenState NotChosen (selectAnswer events)создаёт Dynamic, который изменяется между двумя значениями: Chosen и NotChosen при событии клика на вариант ответа.

Функция для обновления состояния выглядит так:

updChosenState SelectAnswer{questionNumber,answerNumber} isChosen = do
  guard (questionNumber == qNum)                                      
  return                                                              
    if answerNumber == aNum                                           
    then toggleChosen isChosen                                        
    else NotChosen                                                    

Мы использовали guard::Alternativef =>Bool-> f (), чтобы вернуть Nothing (тем самым пропустив обновление Dynamic — напомним, что Maybe является представителем Alternative) в случае, если событие не относится к текущему вопросу. В противном случае, мы либо переключаем значение IsChosen, либо устанавливаем его в NotChosen, если произошёл клик на другой вариант ответа — благодаря этому выбрать можно только один ответ.

Далее, в функции mkCanCheckAnswersModel, мы формируем динамическое значение CanCheckAnswers, которое принимает значение CanCheckAnswers только тогда, когда каждый вопрос имеет выбранный ответ (иначе — CantCheckAnswers):

mkCanCheckAnswersModel :: ObeliskWidget js t route m
  => [(QuestionText, [(Answer, Dynamic t IsChosen)])]
  -> m (Dynamic t CanCheckAnswers)
mkCanCheckAnswersModel allQuestions = holdUniqDyn do
  -- Для каждого вопроса хотя бы один ответ выбран
  allQuestionsAnswered <- all (Chosen `elem`) <$> do
    for allQuestions \(_, answers) -> do
      for answers \(_, dynIsChosen) -> dynIsChosen
  return if allQuestionsAnswered then CanCheckAnswers else CantCheckAnswers

Dynamic является представителем класса типов Monad, поэтому мы можем использовать do-нотацию.

Важно заметить, что мы не хотим обновлять значение canCheckAnswers каждый раз, когда выбираем ответ — это привело бы к бесполезной перестройке DOM. Нас интересуют только те обновления, которые действительно изменяют значение. Поэтому мы используем holdUniqDynдля борьбы с «лишними» обновлениями:

holdUniqDyn :: (Eqa, …) =>Dynamict a -> m (Dynamict a)

Констрейнт Eq a говорит о том, что для типа a должна быть определена функция проверки на равенство.

Похожим образом мы формируем динамическое значение Score — оно содержит значение NoScore, если результаты ещё не были подсчитаны, и сами результаты в другом случае.

mkScoreModel :: ObeliskWidget js t route m
  => Dynamic t AreAnswersShown
  -> [(QuestionText, [(Answer, Dynamic t IsChosen)])]
  -> m (Dynamic t Score)
mkScoreModel areAnswersShown allQuestions = holdUniqDyn do
  areAnswersShown >>= \case
    AnswersHidden -> return NoScore
    AnswersShown -> do
      correctAnswers <- flip execStateT 0 do
        for_ allQuestions \(_, answers) -> do
          for_ answers \(answer, dynIsChosen) -> do
            isChosen <- lift dynIsChosen
            when (isChosen == Chosen && isCorrect answer == Correct) do
              modify (+ 1)
      return Score { correctAnswers, totalQuestions = length allQuestions }

Использование трансформера монад StateT позволяет описать процесс подсчёта количества правильных ответов в более «императивном» виде.


Рендеринг


Elm

Рендеринг в Elm достаточно нагляден и не требует долгих объяснений.

view : Model -> Html Msg
view model =
    div [] <|
      List.indexedMap (viewQuestion model.areAnswersShown) model.allQuestions ++
      [ div [ class "check-answers-button-container" ] [ viewFooter model ] ]

viewQuestion : AreAnswersShown -> Int -> (QuestionText, List (Answer, IsChosen)) -> Html Msg
viewQuestion areShown questionIx (question, answers) =
    div [class "question"] <|
        [ text question ] ++
        [ div [class "answers"]
          <| List.indexedMap (viewAnswer areShown questionIx) answers ]

viewAnswer : AreAnswersShown -> Int -> Int -> (Answer, IsChosen) -> Html Msg
viewAnswer areShown questionIx answerIx (answer, isChosen) =
    let
        events = [ onClick <|
                       SelectAnswer { questionNumber = questionIx
                                    , answerNumber = answerIx
                                    , isChosen = toggleChosen isChosen
                                    } ]
        className = String.join " " <|
            ["answer"] ++
            ( if isChosen == Chosen
              then ["answer-chosen"]
              else [] ) ++
            ( if areShown == AnswersShown
              then ["answer-shown"]
              else ["answer-hidden"] ) ++
            ( if answer.isCorrect == Correct
              then ["answer-correct"]
              else ["answer-incorrect"] )
        attrs = [ class className ]
    in
        div (attrs ++ events) [ text answer.answerText ]

viewFooter : Model -> Html Msg
viewFooter model =
    case model.score of
        NoScore ->
            case model.canCheckAnswers of
                CanCheckAnswers ->
                    button [ onClick CheckAnswers ] [ text "Check answers" ]
                CantCheckAnswers ->
                    div [ class "unfinished-quiz-notice" ]
                        [ text "Select answers for all questions before you can get the results." ]
        Score { totalQuestions, correctAnswers } ->
            text <|
                "Your score: " ++ String.fromInt correctAnswers ++
                " of " ++ String.fromInt totalQuestions

Мы полностью описали виджет. Теперь приступим к его инициализации:

initialModel =
  { areAnswersShown = AnswersHidden
  , allQuestions = allQuestions
  , score = NoScore
  , canCheckAnswers = CantCheckAnswers
  }

main =
  Browser.sandbox { init = initialModel, update = update, view = view }

Reflex

quizUI :: ObeliskWidget js t route m => QuizState t -> m (QuizEvents t)
quizUI QuizState{..} = wrapUI do
  selectAnswer <- leftmost <$> for (enumerate allQuestions)
    \(qNum, (questionText, answers)) -> do
      divClass "question" do
        text questionText
      answersUI qNum areAnswersShown answers
  showAnswers <- footerUI canCheckAnswers score
  return QuizEvents{..}

Функция leftmost позволяет комбинировать несколько событий одного типа в одно.

leftmost :: Reflex t => [Event t a] -> Event t a

Важно заметить, что события в Reflex могут наступать одновременно. Поэтому стоит помнить о возможности потерять что-нибудь важное: leftmost в этом случае игнорирует все события, кроме первого.

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

Также мы используем leftmost при построении списка ответов:

answersUI :: ObeliskWidget js t route m
  => Int
  -> Dynamic t AreAnswersShown
  -> [(Answer, Dynamic t IsChosen)]
  -> m (Event t SelectAnswer)
answersUI qNum areAnswersShown answers = elClass "div" "answers" do
  leftmost <$> for (enumerate answers)
    \(aNum, (Answer{answerText,isCorrect}, dynIsChosen)) -> do
      event <- answerUI areAnswersShown answerText isCorrect dynIsChosen
      return (event $> SelectAnswer { questionNumber = qNum, answerNumber = aNum })

В функции answersUI мы итерируемся по списку ответов, каждый раз вызывая функцию answerUI, в которой мы конструируем новый Dynamic, содержащий Map из динамических атрибутов элемента DOM, который представляет из себя вариант ответа. Пользуемся тем фактом, что Dynamic — монада, чтобы сформировать имя класса для HTML-элемента с вариантом ответа. Мы используем Writer для «императивности».

Конструкция$> SelectAnswer { questionNumber = qNum, answerNumber = aNum }нужна для того, чтобы заменить пустое значение »()» (называемое «unit»), которое является payload«ом события «click» по умолчанию, на нужный нам payload, в котором указано, на какой из ответов мы кликнули.

answerUI :: ObeliskWidget js t route m
  => Dynamic t AreAnswersShown
  -> AnswerText
  -> IsCorrect
  -> Dynamic t IsChosen
  -> m (Event t ())
answerUI areAnswersShown answerText isCorrect dynIsChosen =
  domEvent Click . fst <$> elDynAttr' "div" dynAttrs do
    text answerText
  where
    dynAttrs = do
      isChosen <- dynIsChosen
      areShown <- areAnswersShown
      let
        className = T.intercalate " " $ execWriter do
          tell ["answer"]
          when (isChosen == Chosen) $ tell ["answer-chosen"]
          tell [ if areShown == AnswersShown
                 then "answer-shown"
                 else "answer-hidden" ]
          tell [ if isCorrect == Correct
                 then "answer-correct"
                 else "answer-incorrect" ]
      return $ "class" =: className

Конструкция domEvent Click . fst <$> elDynAttr'… позволяет нам получить событие клика в виде значения.

footerUI — виджет, содержащий либо текст с предложением ответить на все вопросы, либо

кнопку проверки ответов, либо информацию о результатах:

footerUI :: ObeliskWidget js t route m
  => Dynamic t CanCheckAnswers -> Dynamic t Score -> m (Event t CheckAnswers)
footerUI canCheckAnswersDyn dynScore = wrapContainer do
  evt <- switchHold never <=< dyn $ do
    canCheckAnswers <- canCheckAnswersDyn
    score <- dynScore
    return if score /= NoScore
      then return never
      else case canCheckAnswers of
             CanCheckAnswers  -> checkAnswersButton
             CantCheckAnswers -> cantCheckNote
  dyn_ $ dynScore <&> \case
    NoScore -> blank
    Score{totalQuestions, correctAnswers} -> do
      text "Your score: "
      text . T.pack $ show correctAnswers
      text " of "
      text . T.pack $ show totalQuestions
  return (evt $> CheckAnswers)
  where
    wrapContainer = divClass "check-answers-button-container"
    checkAnswersButton = do
      domEvent Click . fst <$> do
        el' "button" do
          text "Check answers"
    cantCheckNote = do
      divClass "unfinished-quiz-notice" do
        text "Select answers for all questions before you can get the results."
      return never

Выражение switchHold never <=< dyn $ do …также очень часто можно встретить в коде. Мы используем его, когда хотим получить событие из динамически меняющегося виджета.

Имеет смысл детально разобрать, что здесь происходит.

Во-первых, скобки правильно расставляются так: (switchHold never <=< dyn) $ do

Давайте проследим за типами:

switchHold :: … => Event t a -> Event t (Event t a) -> m (Event t a)
never      :: … => Event t a
(<=<)      :: Monad m => (b -> m c) -> (a -> m b) -> a -> m c
dyn        :: … => Dynamic t (m a) -> m (Event t a)

  • switchHold даёт нам возможность «переключать» события, на которые мы «подписаны» по мере их поступления. Они поступают в качестве payload«ов другого события. Событие, передаваемое в качестве первого аргумента — то, которое будет актуальным, пока второе событие не сработает.
  • never — это просто событие, которое никогда не наступает.
  • (<=<) — уже знакомая нам «рыба».
  • dyn позволяет «использовать» динамический виджет. Возвращаемое событие срабатывает каждый раз, когда динамик изменяется.

Отсюда следует, что:

switchHold never <=< dyn :: Dynamic t (m (Event t a)) -> m (Event t a)

Ясно, что внутри Dynamic в любой момент времени будет находиться какой-то виджет, возвращающий событие — он и будет «использован».

Мы используем конструкцию if score /= NoScore then return never …, т.к. в случае, если результат уже подсчитан, событие «подсчитать результат» не должно произойти никогда.

Заметным отличием приведённого выше кода от кода на Elm является то, что события мы возвращаем явно, а не просто присваиваем их в качестве атрибутов элементов. Необходимость «протаскивать» события — плата за возможность обрабатывать и комбинировать их произвольным образом где угодно. Однако, в нашем production-коде мы делаем так далеко не всегда. Иногда мы можем воспользоваться классом типов EventWriter, который предоставляет функцию tellEvent. tellEvent весьма похожа на tell в обычном Writer:

tellEvent :: EventWriter t w m => Event t w -> m ()
tell :: MonadWriter w m => w -> m ()

Полностью описав виджет, мы можем сделать его работающим с помощью созданной ранее функции mkWidget:

mkQuizWidget :: ObeliskWidget js t route m => [(QuestionText, [Answer])] -> m ()
mkQuizWidget qs =
  mkWidget (mkQuizModel qs) quizUI

Превью виджета:

clveozdic_-xiqncbrdjl-o3k54.png


Преимущества Elm по сравнению с Reflex:


  • Более доступный тулинг. Не требуется знание Nix, всё, что нужно для сборки приложения, предоставляет исполняемый файл Elm.
  • Язык более простой для начинающего. Elm легко изучать в качестве первого функционального языка.
  • Время компиляции значительно меньше, и генерируемый код — тоже.
  • Не нужно явно разделять состояние приложения на динамическое и статическое.

Недостатки:


  • Elm — менее выразительный функциональный язык. В сравнении с Haskell, возрастает необходимость в дублировании кода из-за отсутствия таких механизмов, как тайпклассы (для ad hoc-полиморфизма), template Haskell (для кодогенерации) и generics (Datatype-generic programming, не путать с generics в ООП-языках).
  • Model необходимо перестраивать после каждого сообщения. Несмотря на то, что в чисто-функциональных языках персистентные структуры данных могут обновляться не полностью (sharing), сам факт того, что фреймворк «не видит», какая часть структуры данных была изменена, делает использование Virtual DOM необходимым.
  • Elm не поддерживает foreign function interface для вызова произвольных функций на JavaScript.

Несмотря на то, что Elm имеет несколько важных преимуществ, мы всё-таки остановили свой выбор на Reflex, т.к. использовать один и тот же язык для backend- и frontend-разработки достаточно удобно.

© Habrahabr.ru