Elm. Удобный и неловкий. Композиция

от автора

Продолжим говорить о Elm.

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)

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

  1. отсутствует группировка данных в модели и сообщений. Все лежит в одной плоскости. Таким образом отсутствуют границы компонентов, изменение логики одной части вероятнее всего затронет остальные;
  2. повторное использование кода возможно по принципу copy-paste со всеми вытекающими последствиями.

Удобная композиция

Исходный код удобной реализации. В рамках этой реализации попробуем разделить проект на самостоятельные компоненты. Допустимы зависимости между компонентами.

Структура проекта:

  1. в папке Type объявлены пользовательские типы;
  2. в папке Component объявлены пользовательские компонента;
  3. файл Main.elm входная точка проекта;
  4. файлы login.json и questions.json используются в качестве тестовых данных ответа сервера на авторизацию и сохранение информации о вопросе соответственно.

Пользовательские компоненты

Каждый компонент, исходя из архитектуры языка, должен содержать:

  1. модель (Model);
  2. сообщения (Msg);
  3. результат выполнения (Return);
  4. функцию инициализации (init);
  5. функция мутации (update);
  6. функцию представления (view).

Каждый компонент может содержать подписку (subscription) в случае необходимости.

image
Рис. 1. Диаграмма активности компонента

Инициализация

Каждый компонент должен быть инициирован, т.е. должны быть получены:

  1. модель;
  2. команда или список команд, которые должны инициализировать состояние компонента;
  3. результат выполнения. Результат выполнения в момент инициализации может понадобиться допустим для проверки авторизации пользователя, как в примерах к данной статье.

Перечень аргументов функции инициализации (init) зависит от логики работы компонента и может быть произвольным. Функций инициализации может быть несколько. Допустим, для компонента авторизации может быть предусмотрено два варианта инициализации: с токеном сессии и с данными пользователя.

Код, использующий компонент, после инициализации должен передать команды в elm runtime при помощи функции Cmd.map.

Мутация

Функция компонента update должна быть вызвана для каждого сообщения компонента. В качестве результата выполнения функция возвращает тройку:

  1. новую модель или новое состояние (Model);
  2. команду или список команд для Elm runtime (Cmd Msg). В качестве команд могут быть команды на выполнение HTTP-запросов, взаимодействие с портами и прочее;
  3. результат выполнения (Maybe Return). Тип Maybe имеет два состояния Nothing и Just a. В нашем случае, Nothing — результата отсутствует, Just a — результат имеется. Например, для авторизации результатом может быть Just (Authenticated UserData) — пользователь авторизован с данными UserData.

Код, использующий компонент, после мутации должен обновить модель компонента и передать команды в Elm runtime при помощи функции Cmd.map.

Обязательные аргументы функции update, в соответствии с архитектурой Elm приложений:

  1. сообщение (Msg);
  2. модель (Model).

При необходимости перечень аргументов можно дополнить.

Представление

Функция представления (view) вызывается в момент, когда необходимо в общее представление приложения вставить представление компонента.

Обязательным аргументом функции view должна быть модель компонента. При необходимости перечень аргументов можно дополнить.

Результат выполнения функции view должен быть передан в функцию Html.map.

Интеграция в приложение

В примере описано два компонента: Auth и Question. Компоненты описанным выше принципам. Рассмотрим каким образом они могут быть интегрированы в приложение.

Для начала определим то, как наше приложение должно работать. На экране имеется кнопка, при нажатию на которую:

  1. для неавторизованного пользователя отображается форма авторизации, после авторизации — форма размещения вопроса;
  2. для авторизованного пользователя отображается форма размещения вопроса.

Для описания приложения необходимы:

  1. модель (Model);
  2. сообщения (Msg);
  3. точку старта приложения (main);
  4. функция инициализации (init);
  5. функция мутации;
  6. функция представления;
  7. функция подписки.

Модель

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

Подписка отсутствует.

Далее

Данный пример хоть и чуть удобнее, но достаточно наивный. Чего не хватает:

  1. блокировка взаимодействия с приложением в процессе загрузки;
  2. валидация данных. Требует отдельного разговора;
  3. действительно выпадающее окно с возможностью закрыть.


ссылка на оригинал статьи https://habr.com/post/424341/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *