Введение
В этой статье мы поговорим о двух принципиально разных подходах к реактивному программированию.
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
(комбинатор неподвижной точки для монадического вычисления), имеющую тип MonadFix
m => (a -> m a) -> m a,
а также композицию стрелок Клейсли, которую иногда называют просто «рыбой»:
(>=>)
::
Monad
m => (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 :: (
Reflex
t,
MonadHold
t m,
MonadFix
m) => (a -> b ->
Maybe
b) -> b ->
Event
t a -> m (
Dynamic
t 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
::
Alternative
f =>
Bool
-> f ()
, чтобы вернуть Nothing
(тем самым пропустив обновление Dynamic — напомним, что Maybe является представителем Alternative) в случае, если событие не относится к текущему вопросу. В противном случае, мы либо переключаем значение IsChosen
, либо устанавливаем его в NotChosen
, если произошёл клик на другой вариант ответа — благодаря этому выбрать можно только один ответ.
Далее, в функции mkCanCheckAnswersModel, мы формируем динамическое значение CanCheckAnswers
, которое принимает значение CanCheckAnswers только тогда, когда каждый вопрос имеет выбранный ответ (иначе — Cant
CheckAnswers
):
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 :: (
Eq
a, …) =>
Dynamic
t a -> m (
Dynamic
t 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-разработки достаточно удобно.
Вам может быть интересно:
- Зачем мы транспилируем Haskell в JavaScript
- Как мы выбираем языки программирования в Typeable
- Создаем веб-приложение на Haskell с использованием Reflex. Часть 2
- Создаем веб-приложение на Haskell с использованием Reflex. Часть 1
Версия на английском языке: https://typeable.io/blog/2021-03-22-reflex-vs-elm.html