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

    Введение


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


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


    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

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



    Выводы


    Преимущества 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-разработки достаточно удобно.

    Typeable
    Functional programming, High assurance, Consulting

    Комментарии 2

      0

      Привет! Отличная статья.


      Насколько я помню, Redux родился из Elm. Вы пробовали связку Typescript + Redux? Каково это в сравнении с Elm?

        +2

        Спасибо! Мы у себя TS+Redux не пробовали, у нас уже была кодобаза на Haskell и хотелось продолжать использовать чисто функциональный язык.
        У одного из наших коллег был опыт использования Redux + React Native + TS, и ему реализация той же самой архитектуры в Elm показалась гораздо понятнее и типизация лучше.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое