Введение
Всем привет! Меня зовут Никита, и мы в Typeable для разработки
фронтенда для части проектов используем FRP-подход, а конкретно его
реализацию на Haskell веб-фреймоворк reflex
.
На русскоязычных ресурсах отсутствуют какие-либо руководства по
данному фреймворку (да и в англоязычном интернете их не так много),
и мы решили это немного исправить.
В этой серии статей будет рассмотрено создание веб-приложения на
Haskell с использованием платформы
reflex-platform
. reflex-platform
предоставляет пакеты reflex
и reflex-dom
.
Пакет reflex
является реализацией
Functional reactive programming (FRP) на языке Haskell. В
библиотеке reflex-dom
содержится большое число
функций, классов и типов для работы с DOM
. Эти пакеты
разделены, т.к. FRP-подход можно использовать не только в
веб-разработке. Разрабатывать мы будем приложение Todo
List
, которое позволяет выполнять различные манипуляции со
списком задач.
Для понимания данной серии статей требуется ненулевой уровень знания языка программирования Haskell и будет полезно предварительное ознакомление с функциональным реактивным программированием.
Здесь не будет подробного описания подхода FRP. Единственное, что действительно стоит упомянуть это два основных полиморфных типа, на которых базируется этот подход:
Behavior a
реактивная переменная, изменяющаяся во времени. Представляет собой некоторый контейнер, который на протяжении всего своего жизненного цикла содержит значение.Event a
событие в системе. Событие несет в себе информацию, которую можно получить только во время срабатывания события.
Пакет reflex
предоставляет еще один новый тип:
Dynamic a
является объединениемBehavior a
иEvent a
, т.е. это контейнер, который всегда содержит в себе некоторое значение, и, подобно событию, он умеет уведомлять о своем изменении, в отличие отBehavior a
.
В reflex
используется понятие фрейма минимальной
единицы времени. Фрейм начинается вместе с возникшим событием и
продолжается, пока данные в этом событии не перестанут
обрабатываться. Событие может порождать другие события, полученные,
например, через фильтрацию, маппинг и т.д., и тогда эти зависимые
события также будут принадлежать этому фрейму.
Подготовка
В первую очередь потребуется установленный пакетный менеджер
nix
. Как это сделать описано здесь.
Чтобы ускорить процесс сборки, имеет смысл настроить кэш
nix
. В случае, если вы не используете NixOS, то вам
нужно добавить следующие строки в файл
/etc/nix/nix.conf
:
binary-caches = https://cache.nixos.org https://nixcache.reflex-frp.orgbinary-cache-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ryantrinkle.com-1:JJiAKaRv9mWgpVAz8dwewnZe0AzzEAzPkagE9SP5NWI=binary-caches-parallel-connections = 40
Если используете NixOS, то в файл
/etc/nixos/configuration.nix
:
nix.binaryCaches = [ "https://nixcache.reflex-frp.org" ];nix.binaryCachePublicKeys = [ "ryantrinkle.com-1:JJiAKaRv9mWgpVAz8dwewnZe0AzzEAzPkagE9SP5NWI=" ];
В этом туториале мы будем придерживаться стандартной структуры с тремя пакетами:
todo-client
клиентская часть;todo-server
серверная часть;todo-common
содержит общие модули, которые используются сервером и клиентом (например типы API).
Далее необходимо подготовить окружение для разработки. Для этого надо повторить действия из документации:
- Создать директорию приложения:
todo-app
; - Создать проекты
todo-common
(library),todo-server
(executable),todo-client
(executable) вtodo-app
; - Настроить сборку через
nix
(файлdefault.nix
в директорииtodo-app
);
- Также надо не забыть включить опцию
useWarp = true;
;
- Также надо не забыть включить опцию
- Настроить сборку через
cabal
(файлыcabal.project
иcabal-ghcjs.project
).
На момент публикации статьи default.nix
будет
выглядеть примерно следующим образом:
{ reflex-platform ? ((import <nixpkgs> {}).fetchFromGitHub { owner = "reflex-frp"; repo = "reflex-platform"; rev = "efc6d923c633207d18bd4d8cae3e20110a377864"; sha256 = "121rmnkx8nwiy96ipfyyv6vrgysv0zpr2br46y70zf4d0y1h1lz5"; })}:(import reflex-platform {}).project ({ pkgs, ... }:{ useWarp = true; packages = { todo-common = ./todo-common; todo-server = ./todo-server; todo-client = ./todo-client; }; shells = { ghc = ["todo-common" "todo-server" "todo-client"]; ghcjs = ["todo-common" "todo-client"]; };})
Примечание: в документации предлагается вручную склонировать репозиторийreflex-platform
. В данном примере мы воспользовались средствамиnix
для получения платформы из репозитория.
Во время разработки клиента удобно пользоваться инструментом
ghcid
. Он автоматически обновляет и перезапускает
приложение при изменении исходников.
Чтобы убедиться, что все работает, добавим в
todo-client/src/Main.hs
следующий код:
{-# LANGUAGE OverloadedStrings #-}module Main whereimport Reflex.Dommain :: IO ()main = mainWidget $ el "h1" $ text "Hello, reflex!"
Вся разработка ведется из nix-shell
, поэтому в
самом начале необходимо войти в этот shell:
$ nix-shell . -A shells.ghc
Для запуска через ghcid
требуется ввести следующую
команду:
$ ghcid --command 'cabal new-repl todo-client' --test 'Main.main'
Если все работает, то по адресу localhost:3003
вы
увидите приветствие Hello, reflex!
Почему 3003?
Номер порта ищется в переменной окружения
JSADDLE_WARP_PORT
. Если эта переменная не установлена,
то по умолчанию берется значение 3003.
Как это работает
Вы можете заметить, мы использовали при сборке не
GHCJS
, а обычный GHC
. Это возможно
благодаря пакетам jsaddle
и jsaddle-warp
.
Пакет jsaddle
предоставляет интерфейс для JS для
работы из-под GHC
и GHCJS
. С помощью
пакета jsaddle-warp
мы можем запустить сервер, который
посредством веб-сокетов будет обновлять DOM
и играть
роль JS-движка. Как раз для этого и был установлен флаг
useWarp = true;
, иначе по умолчанию использовался бы
пакет jsaddle-webkit2gtk
, и при запуске мы бы увидели
десктопное приложение. Стоит отметить, что еще существуют прослойки
jsaddle-wkwebview
(для iOS приложений) и
jsaddle-clib
(для Android приложений).
Простейшее приложение TODO
Приступим к разработке!
Добавим следующий код в
todo-client/src/Main.hs
.
{-# LANGUAGE MonoLocalBinds #-}{-# LANGUAGE OverloadedStrings #-}module Main whereimport Reflex.Dommain :: IO ()main = mainWidgetWithHead headWidget rootWidgetheadWidget :: MonadWidget t m => m ()headWidget = blankrootWidget :: MonadWidget t m => m ()rootWidget = blank
Можно сказать, что функция mainWidgetWithHead
представляет собой элемент <html>
страницы. Она
принимает два параметра head
и body
.
Существуют еще функции mainWidget
и
mainWidgetWithCss
. Первая функция принимает только
виджет с элементом body
. Вторая первым аргументом
принимает стили, добавляемые в элемент style
, и вторым
аргументом элемент body
.
Виджетами мы будем называть любой HTML элемент, или
группу элементов. Виджет может обладать своей сетью событий и
продуцирует некоторый HTML код. По сути, любая функция, требующая
классов типов, ответственных за построения DOM
, в типе
выходного значения, будет называться виджетом.
Функция blank
равносильна pure ()
и
она ничего не делает, никак не изменяет DOM
и никак не
влияет на сеть событий.
Теперь опишем элемент <head>
нашей
страницы.
headWidget :: MonadWidget t m => m ()headWidget = do elAttr "meta" ("charset" =: "utf-8") blank elAttr "meta" ( "name" =: "viewport" <> "content" =: "width=device-width, initial-scale=1, shrink-to-fit=no" ) blank elAttr "link" ( "rel" =: "stylesheet" <> "href" =: "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" <> "integrity" =: "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" <> "crossorigin" =: "anonymous") blank el "title" $ text "TODO App"
Данная функция сгенерирует следующее содержимое элемента
head
:
<meta charset="utf-8"><meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport"><link crossorigin="anonymous" href="http://personeltest.ru/aways/stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" rel="stylesheet"><title>TODO App</title>
Класс MonadWidget
позволяет строить или
перестраивать DOM
, а также определять сеть событий,
которые происходят на странице.
Функция elAttr
имеет следующий тип:
elAttr :: forall t m a. DomBuilder t m => Text -> Map Text Text -> m a -> m a
Она принимает название тэга, атрибуты и содержимое элемента.
Возвращает эта функция, и вообще весь набор функций, строящих
DOM
, то, что возвращает их внутренний виджет. В данном
случае наши элементы пустые, поэтому используется
blank
. Это одно из наиболее частых применений этой
функции когда требуется сделать тело элемента пустым. Так же
используется функция el
. Ее входными параметрами
являются только название тэга и содержимое, другими словами это
упрощенная версия функции elAttr
без атрибутов. Другая
функция, используемая здесь text
. Ее задача вывод
текста на странице. Эта функция экранирует все возможные служебные
символы, слова и тэги, и поэтому именно тот текст, который передан
в нее, будет выведен. Для того чтобы встроить кусок html,
существует функция elDynHtml
.
Надо сказать, что в приведенном выше примере использование
MonadWidget
является избыточным, т.к. эта часть строит
неизменяемый участок DOM
. А, как было сказано выше,
MonadWidget
позволяет строить или перестраивать
DOM
, а также позволяет определять сеть событий.
Функции, которые используются здесь, требуют только наличие класса
DomBuilder
, и тут, действительно, мы могли написать
только это ограничение. Но в общем случае, ограничений на монаду
гораздо больше, что затрудняет и замедляет разработку, если мы
будем прописывать только те классы, которые нам нужны сейчас.
Поэтому существует класс MonadWidget
, которые
представляет собой эдакий швейцарский нож. Для любопытных приведём
список всех классов, которые являются надклассами
MonadWidget
:
type MonadWidgetConstraints t m = ( DomBuilder t m , DomBuilderSpace m ~ GhcjsDomSpace , MonadFix m , MonadHold t m , MonadSample t (Performable m) , MonadReflexCreateTrigger t m , PostBuild t m , PerformEvent t m , MonadIO m , MonadIO (Performable m)#ifndef ghcjs_HOST_OS , DOM.MonadJSM m , DOM.MonadJSM (Performable m)#endif , TriggerEvent t m , HasJSContext m , HasJSContext (Performable m) , HasDocument m , MonadRef m , Ref m ~ Ref IO , MonadRef (Performable m) , Ref (Performable m) ~ Ref IO )class MonadWidgetConstraints t m => MonadWidget t m
Теперь перейдем к элементу body
страницы, но для
начала определим тип данных, который будем использовать для
задания:
newtype Todo = Todo { todoText :: Text }newTodo :: Text -> TodonewTodo todoText = Todo {..}
Тело будет иметь следующую структуру:
rootWidget :: MonadWidget t m => m ()rootWidget = divClass "container" $ do elClass "h2" "text-center mt-3" $ text "Todos" newTodoEv <- newTodoForm todosDyn <- foldDyn (:) [] newTodoEv delimiter todoListWidget todosDyn
Функция elClass
на вход принимает название тэга,
класс (классы) и содержимое. divClass
это сокращенная
версия elClass "div"
.
Все выше описанные функции отвечают за визуальное представление
и не несут в себе никакой логики, в отличие от функции
foldDyn
. Она является частью пакета
reflex
и имеет следующую сигнатуру:
foldDyn :: (Reflex t, MonadHold t m, MonadFix m) => (a -> b -> b) -> b -> Event t a -> m (Dynamic t b)
Она похожа на foldr :: (a -> b -> b) -> b ->
[a] -> b
и, по сути, выполняет такую же роль, только в
роли списка здесь событие. Результирующее значение обернуто в
контейнер Dynamic
, т.к. оно будет обновляться после
каждого события. Процесс обновления задаётся функцией-параметром,
которая принимает на вход значение из возникшего события и текущее
значение из Dynamic
. На их основе формируется новое
значение, которое будет находиться в Dynamic
. Это
обновление будет происходить каждый раз при возникновении
события.
В нашем примере функция foldDyn
будет обновлять
динамический список заданий (изначально пустой), как только будет
добавлено новое задание из формы ввода. Новые задания добавляются в
начало списка, т.к. используется функция (:)
.
Функция newTodoForm
строит ту часть
DOM
, в которой будет форма ввода описания задания, и
возвращает событие, которое несет в себе новое Todo
.
Именно при возникновении этого события будет обновляться список
заданий.
newTodoForm :: MonadWidget t m => m (Event t Todo)newTodoForm = rowWrapper $ el "form" $ divClass "input-group" $ do iEl <- inputElement $ def & initialAttributes .~ ( "type" =: "text" <> "class" =: "form-control" <> "placeholder" =: "Todo" ) let newTodoDyn = newTodo <$> value iEl btnAttr = "class" =: "btn btn-outline-secondary" <> "type" =: "button" (btnEl, _) <- divClass "input-group-append" $ elAttr' "button" btnAttr $ text "Add new entry" pure $ tagPromptlyDyn newTodoDyn $ domEvent Click btnEl
Первое нововведение, которое мы встречаем тут, это функция
inputElement
. Ее название говорит само за себя, она
добавляет элемент input
. В качестве параметра она
принимает тип InputElementConfig
. Он имеет много
полей, наследует несколько различный классов, но в данном примере
нам наиболее интересно добавить нужные атрибуты этому тегу, и это
можно сделать при помощи линзы initialAttributes
.
Функция value
является методом класса
HasValue
и возвращает значение, которое находится в
данном input
. В случае типа InputElement
оно имеет тип Dynamic t Text
. Это значение будет
обновляться при каждом изменении, происходящем в поле
input
.
Следующее изменение, которое тут можно заметить, это
использование функции elAttr'
. Отличие функций со
штрихом от функций без штриха для построения DOM
заключается в том, что эти функции вдобавок возвращают сам элемент
страницы, с которым мы можем производить различные манипуляции. В
нашем случае он необходим, чтобы мы могли получить событие нажатия
на этот элемент. Для этого служит функция domEvent
.
Эта функция принимает название события, в нашем случае
Click
и сам элемент, с которым связано это событие.
Функция имеет следующую сигнатуру:
domEvent :: EventName eventName -> target -> Event t (DomEventType target eventName)
Ее возвращаемый тип зависит от типа события и типа элемента. В
нашем случае это ()
.
Следующая функция, которую мы встречаем
tagPromptlyDyn
. Она имеет следующий тип:
tagPromptlyDyn :: Reflex t => Dynamic t a -> Event t b -> Event t a
Ее задача заключается в том, чтобы при срабатывании события,
поместить в него значение, которое находится в данный момент внутри
Dynamic
. Т.е. событие, являющееся результатом функции
tagPromptlyDyn valDyn btnEv
возникает одновременно с
btnEv
, но несёт в себе значение, которое было в
valDyn
. Для нашего примера это событие будет
происходить при нажатии кнопки и нести в себе значение из
текстового поля ввода.
Тут следует сказать про то, что функции, которые содержат в
своём названии слово promptly
, потенциально опасные
они могут вызывать циклы в сети событий. Внешне это будет выглядеть
так, как будто приложение зависло. Вызов tagPromplyDyn valDyn
btnEv
, по возможности, надо заменять на tag (current
valDyn) btnEv
. Функция current
получает
Behavior
из Dynamic
. Эти вызовы не всегда
взаимозаменяемые. Если обновление Dynamic
и событие
Event
в tagPromplyDyn
возникают в один
момент, т.е. в одном фрейме, то выходное событие будет содержать те
данные, которые получил Dynamic
в этом фрейме. В
случае, если мы будем использовать tag (current valDyn)
btnEv
, то выходное событие будет содержать те данные,
которыми исходный current valDyn
, т.е.
Behavior
, обладал в прошлом фрейме.
Здесь мы подошли к еще одному различию между
Behavior
и Dynamic
: если
Behavior
и Dynamic
получают обновление в
одном фрейме, то Dynamic
будет обновлен уже в этом
фрейме, а Behavior
приобретет новое значение в
следующем. Другими словами, если событие произошло в момент времени
t1
и в момент времени t2
, то
Dynamic
будет обладать значением, которое принесло
событие t1
в промежутке времени [t1, t2)
,
а Behavior
(t1, t2]
.
Задача функции todoListWidget
заключается в выводе
всего списка Todo
.
todoListWidget :: MonadWidget t m => Dynamic t [Todo] -> m ()todoListWidget todosDyn = rowWrapper $ void $ simpleList todosDyn todoWidget
Здесь встречается функция simpleList
. Она имеет
следующую сигнатуру:
simpleList :: (Adjustable t m, MonadHold t m, PostBuild t m, MonadFix m) => Dynamic t [v] -> (Dynamic t v -> m a) -> m (Dynamic t [a])
Эта функция является частью пакета reflex
, и в
нашем случае она используется для построения повторяющихся
элементов в DOM
, где это будут подряд идущие элементы
div
. Она принимает Dynamic
список,
который может изменяться во времени, и функцию для обработки
каждого элемента в отдельности. В данном случае это просто виджет
для вывода одного элемента списка:
todoWidget :: MonadWidget t m => Dynamic t Todo -> m ()todoWidget todoDyn = divClass "d-flex border-bottom" $ divClass "p-2 flex-grow-1 my-auto" $ dynText $ todoText <$> todoDyn
Функция dynText
отличается от функции
text
тем, что на вход принимает текст, обернутый в
Dynamic
. В случае, если элемент списка будет изменен,
то это значение также обновится в DOM
.
Также было использовались 2 функции, о которых не было ничего
сказано: rowWrapper
и delimiter
. Первая
функция является оберткой над виджетом. В ней нет ничего нового и
выглядит следующим образом:
rowWrapper :: MonadWidget t m => m a -> m arowWrapper ma = divClass "row justify-content-md-center" $ divClass "col-6" ma
Функция delimiter
просто добавляет
элемент-разделитель.
delimiter :: MonadWidget t m => m ()delimiter = rowWrapper $ divClass "border-top mt-3" blank
Полученный результат можно посмотреть в нашем репозитории.
Это все, что требуется для построения простого и урезанного
приложения Todo
. В этой части мы рассмотрели настройку
окружения и приступили непосредственно к самой разработке
приложения. В следующей части будет добавлена работа с элемента
списка.