Продолжим говорить о Elm.
В этой статье рассмотрим вопросы архитектуры Elm приложения и возможные варианты реализации компонентного подхода разработки.
В качестве задачи рассмотрим реализацию выпадающего окна, которое позволяет зарегистрированному пользователю добавить вопрос. В случае анонимного пользователя предлагает сначала авторизоваться или зарегистрироваться.
Так же предположим, что впоследствии может потребоваться реализовать прием других типов пользовательского контента, но логика работы с авторизованными и анонимными пользователями останется прежней.
Неловкая композиция
Исходный код наивной реализации. В рамках этой реализации будем все хранить в одной модели.
Все данные необходимые для авторизации и опроса пользователя лежат в модели на одном уровне. Такая же ситуация и с сообщениями (Msg).
type alias Model = { user: User , ui: Maybe Ui -- Popup is not open is value equals Nothing , login: String , password: String , question: String , message: String } type Msg = OpenPopup | LoginTyped String | PasswordTyped String | Login | QuestionTyped String | SendQuestion
Тип интерфейса описан в виде union type Ui, который используется с типом Maybe.
type Ui = LoginUi -- Popup shown with authentication form | QuestionUi -- Popup shown with textarea to leave user question
Таким образом ui = Nothing описывает отсутствие выпадающего окна, а Just — попап открыт с конкретным интерфейсом.
В функции update происходит сопоставление пары, сообщение и данные пользователя. В зависимости от этой пары выполняются различные действия.
update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case (msg, model.user) of
Допустим при клике на кнопку “Open popup” генерируется сообщение OpenPopup. Сообщение OpenPopup в функции update обрабатывается различным образом. Для анонимного пользователя генерируется форма авторизации, а для авторизованного — форма, в которой можно оставить вопрос.
update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case (msg, model.user) of -- Anonymous user message handling section (OpenPopup, Anonymous) -> ( { model | ui = Just LoginUi, message = "" }, Cmd.none) -- Authenticated user message handling section (OpenPopup, User userName) -> ( { model | ui = Just QuestionUi, message = "" }, Cmd.none)
Очевидно, у данного подхода возможны проблемы с ростом функций приложения:
- отсутствует группировка данных в модели и сообщений. Все лежит в одной плоскости. Таким образом отсутствуют границы компонентов, изменение логики одной части вероятнее всего затронет остальные;
- повторное использование кода возможно по принципу copy-paste со всеми вытекающими последствиями.
Удобная композиция
Исходный код удобной реализации. В рамках этой реализации попробуем разделить проект на самостоятельные компоненты. Допустимы зависимости между компонентами.
Структура проекта:
- в папке Type объявлены пользовательские типы;
- в папке Component объявлены пользовательские компонента;
- файл Main.elm входная точка проекта;
- файлы login.json и questions.json используются в качестве тестовых данных ответа сервера на авторизацию и сохранение информации о вопросе соответственно.
Пользовательские компоненты
Каждый компонент, исходя из архитектуры языка, должен содержать:
- модель (Model);
- сообщения (Msg);
- результат выполнения (Return);
- функцию инициализации (init);
- функция мутации (update);
- функцию представления (view).
Каждый компонент может содержать подписку (subscription) в случае необходимости.

Рис. 1. Диаграмма активности компонента
Инициализация
Каждый компонент должен быть инициирован, т.е. должны быть получены:
- модель;
- команда или список команд, которые должны инициализировать состояние компонента;
- результат выполнения. Результат выполнения в момент инициализации может понадобиться допустим для проверки авторизации пользователя, как в примерах к данной статье.
Перечень аргументов функции инициализации (init) зависит от логики работы компонента и может быть произвольным. Функций инициализации может быть несколько. Допустим, для компонента авторизации может быть предусмотрено два варианта инициализации: с токеном сессии и с данными пользователя.
Код, использующий компонент, после инициализации должен передать команды в elm runtime при помощи функции Cmd.map.
Мутация
Функция компонента update должна быть вызвана для каждого сообщения компонента. В качестве результата выполнения функция возвращает тройку:
- новую модель или новое состояние (Model);
- команду или список команд для Elm runtime (Cmd Msg). В качестве команд могут быть команды на выполнение HTTP-запросов, взаимодействие с портами и прочее;
- результат выполнения (Maybe Return). Тип Maybe имеет два состояния Nothing и Just a. В нашем случае, Nothing — результата отсутствует, Just a — результат имеется. Например, для авторизации результатом может быть Just (Authenticated UserData) — пользователь авторизован с данными UserData.
Код, использующий компонент, после мутации должен обновить модель компонента и передать команды в Elm runtime при помощи функции Cmd.map.
Обязательные аргументы функции update, в соответствии с архитектурой Elm приложений:
- сообщение (Msg);
- модель (Model).
При необходимости перечень аргументов можно дополнить.
Представление
Функция представления (view) вызывается в момент, когда необходимо в общее представление приложения вставить представление компонента.
Обязательным аргументом функции view должна быть модель компонента. При необходимости перечень аргументов можно дополнить.
Результат выполнения функции view должен быть передан в функцию Html.map.
Интеграция в приложение
В примере описано два компонента: Auth и Question. Компоненты описанным выше принципам. Рассмотрим каким образом они могут быть интегрированы в приложение.
Для начала определим то, как наше приложение должно работать. На экране имеется кнопка, при нажатию на которую:
- для неавторизованного пользователя отображается форма авторизации, после авторизации — форма размещения вопроса;
- для авторизованного пользователя отображается форма размещения вопроса.
Для описания приложения необходимы:
- модель (Model);
- сообщения (Msg);
- точку старта приложения (main);
- функция инициализации (init);
- функция мутации;
- функция представления;
- функция подписки.
Модель
type alias Model = { user: User , ui: Maybe Ui } type Ui = AuthUi Component.Auth.Model | QuestionUi Component.Question.Model
Модель содержит информацию о пользователе (user) и типе текущего интерфейса (ui). Интерфейс может быть либо в состоянии по умолчанию (Nothing), либо одним из компонентов Just a.
Для описания компонентов мы используем тип Ui, который связывает (тегирует) каждую модель компонента с конкретным вариантом из множества типа. Например, тег AuthUi связывает модель авторизации (Component.Auth.Model) с моделью приложения.
Сообщения
type Msg = OpenPopup | AuthMsg Component.Auth.Msg | QuestionMsg Component.Question.Msg
В сообщениях необходимо тегировать все сообщения компонентов и включить их в сообщения приложения. Тег AuthMsg и QuestionMsg связывают сообщения компонента авторизации и задания вопроса пользователем соответственно.
Сообщение OpenPopup необходимо для обработки запроса на открытие интерфейса.
Функция main
main : Program Never Model Msg main = Html.program { init = init , update = update , subscriptions = subscriptions , view = view }
Входная точка приложения описана типично для Elm-приложения.
Функция инициализации
init : ( Model, Cmd Msg ) init = ( initModel, Cmd.none ) initModel : Model initModel = { user = Anonymous , ui = Nothing }
Функция инициализации создает стартовую модель и не требует выполнения команд.
Функция мутации
update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case (msg, model.ui) of (OpenPopup, Nothing) -> case Component.Auth.init model.user of (authModel, commands, Just (Component.Auth.Authenticated userData)) -> let (questionModel, questionCommands, _) = Component.Question.init userData in ( { model | ui = Just <| QuestionUi questionModel, user = User userData }, Cmd.batch [Cmd.map AuthMsg commands, Cmd.map QuestionMsg questionCommands] ) (authModel, commands, _) -> ( { model | ui = Just <| AuthUi authModel }, Cmd.map AuthMsg commands ) (AuthMsg authMsg, Just (AuthUi authModel)) -> case Component.Auth.update authMsg authModel of (_, commands, Just (Component.Auth.Authenticated userData)) -> let (questionModel, questionCommands, _) = Component.Question.init userData in ( { model | ui = Just <| QuestionUi questionModel, user = User userData }, Cmd.batch [Cmd.map AuthMsg commands, Cmd.map QuestionMsg questionCommands] ) (newAuthModel, commands, _) -> ( { model | ui = Just <| AuthUi newAuthModel }, Cmd.map AuthMsg commands ) (QuestionMsg questionMsg, Just (QuestionUi questionModel)) -> case Component.Question.update questionMsg questionModel of (_, commands, Just (Component.Question.Saved record)) -> ( { model | ui = Nothing }, Cmd.map QuestionMsg commands ) (newQuestionModel, commands, _) -> ( { model | ui = Just <| QuestionUi newQuestionModel }, Cmd.map QuestionMsg commands ) _ -> ( model, Cmd.none )
Т.к. модель и сообщения приложению связаны, будем обрабатывать пару сообщение (Msg) и тип интерфейса (model.ui: Ui).
update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case (msg, model.ui) of
Логика работы
Если получено сообщение OpenPopup и в модели указан интерфейс по умолчанию (model.ui = Nothing), то инициализируем компонент Auth. Если компонент Auth сообщает, что пользователь авторизован — инициализируем компонент Question сохраняем в модель приложения. Иначе, сохраняем в модель приложения модель компонента.
(OpenPopup, Nothing) -> case Component.Auth.init model.user of (authModel, commands, Just (Component.Auth.Authenticated userData)) -> let (questionModel, questionCommands, _) = Component.Question.init userData in ( { model | ui = Just <| QuestionUi questionModel, user = User userData }, Cmd.batch [Cmd.map AuthMsg commands, Cmd.map QuestionMsg questionCommands] ) (authModel, commands, _) -> ( { model | ui = Just <| AuthUi authModel }, Cmd.map AuthMsg commands )
Если получено сообщение с тегом AuthMsg a и в модели указан интерфейс авторизации (model.ui = Just (AuthUi authModel)), то передаем сообщение компонента и модель компонента в функцию Auth.update. В результате получим новую модель компонента, команды и результат.
Если пользователь авторизован инициализируем компонент Question, иначе обновляем данные об интерфейса в модели приложения.
(AuthMsg authMsg, Just (AuthUi authModel)) -> case Component.Auth.update authMsg authModel of (_, commands, Just (Component.Auth.Authenticated userData)) -> let (questionModel, questionCommands, _) = Component.Question.init userData in ( { model | ui = Just <| QuestionUi questionModel, user = User userData }, Cmd.batch [Cmd.map AuthMsg commands, Cmd.map QuestionMsg questionCommands] ) (newAuthModel, commands, _) -> ( { model | ui = Just <| AuthUi newAuthModel }, Cmd.map AuthMsg commands )
Аналогичным компоненту Auth образом обрабатываются сообщения для компонента Question. В случае успешного размещения вопроса, интерфейс меняется на по умолчанию (model.ui = Nothing).
(QuestionMsg questionMsg, Just (QuestionUi questionModel)) -> case Component.Question.update questionMsg questionModel of (_, commands, Just (Component.Question.Saved record)) -> ( { model | ui = Nothing }, Cmd.map QuestionMsg commands ) (newQuestionModel, commands, _) -> ( { model | ui = Just <| QuestionUi newQuestionModel }, Cmd.map QuestionMsg commands )
Все остальные случаи игнорируются.
_ -> ( model, Cmd.none )
Функция представления
view : Model -> Html Msg view model = case model.ui of Nothing -> div [] [ div [] [ button [ Events.onClick OpenPopup ] [ text "Open popup" ] ] ] Just (AuthUi authModel) -> Component.Auth.view authModel |> Html.map AuthMsg Just (QuestionUi questionModel) -> Component.Question.view questionModel |> Html.map QuestionMsg
Функция представления в зависимости от типа интерфейса (model.ui) генерирует либо интерфейс по умолчанию, либо вызывает функцию представления компонента и отображает тип сообщения компонента в тип сообщения приложения (Html.map).
Функция подписки
subscriptions : Model -> Sub Msg subscriptions model = Sub.none
Подписка отсутствует.
Далее
Данный пример хоть и чуть удобнее, но достаточно наивный. Чего не хватает:
- блокировка взаимодействия с приложением в процессе загрузки;
- валидация данных. Требует отдельного разговора;
- действительно выпадающее окно с возможностью закрыть.
ссылка на оригинал статьи https://habr.com/post/424341/
Добавить комментарий