elm

Архитектура Elm

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

При создании фронт-энд приложений в Elm, мы используем паттерн, называемый Elm-архитектурой. Этот паттерн предоставляет способ создания изолированных компонентов, которые могут переиспользоваться, комбинироваться и компоноваться бесконечным множеством способов.

Elm предоставляет для этого модуль Html.App. Для более легкого понимания, напишем небольшое приложение.

Содержание

Установим elm-html:

elm package install elm-lang/html

Создадим файл App.elm:

module App exposing (..)

import Html exposing (Html, div, text)
import Html.App

-- MODEL

type alias Model =
    String

init : ( Model, Cmd Msg )
init =
    ( "Hello", Cmd.none )

-- MESSAGES

type Msg
    = NoOp

-- VIEW

view : Model -> Html Msg
view model =

    div []

        [ text model ]

-- UPDATE

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =

    case msg of

        NoOp ->

            ( model, Cmd.none )



-- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg
subscriptions model =

    Sub.none


-- MAIN

main : Program Never
main =
    Html.App.program
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

Запустим приложение, выполнив:

elm reactor

И откроем http://localhost:8000/App.elm.

Здесь довольно много кода для того, чтобы написать «Hello», но это поможет нам понять структуру даже сложных приложений на Elm.

Структура Html.App

Импорты

import Html exposing (Html, div, text)
import Html.App
  • Мы будем использовать тип Html из модуля Html и пару функций: div и text.
  • Также мы добавим импорт App, который будет рулить нашим приложением.

Модель

type alias Model =
String

init : ( Model, Cmd Msg )
init =
( "Hello", Cmd.none )
  • Сперва мы определим модель нашего приложения с помощью псевдонима типа. В данном случае это просто строка.
  • Затем мы определим функцию init. Эта функция предоставит исходные данные для приложения.

Html.App ожидает кортеж вида (model, command). Первый элемент здесь — наше начальное состояние, т.е. «Hello». Второй элемент —выполняемая команда при старте, об этом позже.

При использовании elm-архитектуры, мы объединяем все модели компонентов в одно общее дерево состояния. Подробнее об этом позже.

Сообщения

type Msg
= NoOp

Сообщения — это то, что происходит в нашем приложении и на что реагируют компоненты. В данном случае приложение ничего не делает, поэтому у нас имеется только сообщение NoOp.

Примерами сообщений могли бы быть Expand или Collapse для отображения/скрытия виджета. В таком случае мы бы использовали union тип:

type Msg
= Expand
| Collapse

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

view : Model -> Html Msg
view model =
div []
[ text model ]

Функция view рендерит Html элемент, используя модель приложения. Заметим, что сигнатура типа — Html Msg. Это означает, что данный Html-элемент будет порождать сообщения, отмеченные тэгом Msg (как говорилось выше, union типы также называются тэгами). Мы это увидим, когда добавим какое-нибудь взаимодействие с пользователем.

Обновление

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
NoOp ->
( model, Cmd.none )

Затем мы определяем функцию update, которую будет вызывать Html.App каждый раз, когда будет получено какое-либо сообщение. Эта функция отвечает на сообщения, обновляя модель и возвращая команды, если нужно.

В данном примере мы только отвечаем на NoOp и возвращаем неизмененную модель и Cmd.none (что означает, что никакую команду выполнять не требуется).

Подписки

subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none

Мы используем подписки для обработки внешних воздействий на приложение. Например:

  • Движения мыши.
  • События клавиатуры.
  • Переход по страницам.

В данном случае нас не интересует никакие воздействия, поэтому мы используем Sub.none. Обратим внимание на сигнатуру Sub Msg. Все подписки компонента должны быть одного типа.

Main

main : Program Never
main =
Html.App.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}

Наконец, Html.App.program связывает все воедино и возвращает html элемент, который мы можем отрисовать на странице. program принимает наши функции init, view, update и подписки.

Сообщения

В предыдущей части, используя Html.App, мы создали статичное Html приложение. Теперь давайте сделаем приложение, отвечающее за слова на действия пользователя, используя сообщения. Взаимодействие с пользователем организуется именно с помощью сообщений — javascript события не обрабатываются непосредственно кодом приложения.

module Main exposing (..)

import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
import Html.App

-- MODEL

type alias Model =
Bool

init : ( Model, Cmd Msg )
init =
( False, Cmd.none )

-- MESSAGES

type Msg
= Expand
| Collapse

-- VIEW

view : Model -> Html Msg
view model =
if model then
div []
[ button [ onClick Collapse ] [ text "Collapse" ]
, text "Widget"
]
else
div []
[ button [ onClick Expand ] [ text "Expand" ] ]

-- UPDATE

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Expand ->
( True, Cmd.none )
Collapse ->
( False, Cmd.none )

-- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none

-- MAIN

main =
Html.App.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}

Это приложение очень похоже на то, что мы сделали ранее, но теперь у нас есть два сообщения: Expand и Collapse.

Давайте подробнее рассмотрим функции view и update.

View

view : Model -> Html Msg
view model =
if model then
div []
[ button [ onClick Collapse ] [ text "Collapse" ]
, text "Widget"
]
else
div []
[ button [ onClick Expand ] [ text "Expand" ] ]

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

Обратим внимание на функцию onClick. Так как это представление типа Html Msg, мы можем порождать сообщения типа Msg, используя onClick. Collapse и Expand относятся к этому типу.

Update

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Expand ->
( True, Cmd.none )
Collapse ->
( False, Cmd.none )

update отвечает на возможные сообщения. В зависимости от сообщения она возвращает нужное состояние. Если сообщение — Expand, состояние перейдет в True («развернуто»).

Теперь давайте посмотрим, как Html.App рулит всем этим.

Последовательность работы приложения

  1. App вызывает нашу функцию view с исходной моделью и отрисовывает представление.
  2. Когда пользователь кликает по кнопке Expand, представление порождает сообщение Expand.
  3. App принимает сообщение, что вызывает нашу функцию update с этим сообщением (Expand) и текущим состоянием модели.
  4. Функция обновления отвечает на сообщение, возвращая изменившееся состояние и команду, которую следует выполнить (либо none).
  5. App принимает обновленное состояние, сохраняет его и снова вызывает функцию view, передав ей уже новое состояние.

Обычно Html.App — единственное место, где Elm-приложение хранит состояние, оно централизовано в одном большом дереве состояния.

Сообщения с данными

Мы можем прикрепить данные к сообщению:

module Main exposing (..)

import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
import Html.App

-- MODEL

type alias Model =
Int

init : ( Model, Cmd Msg )
init =
( 0, Cmd.none )

-- MESSAGES

type Msg
= Increment Int

-- VIEW

view : Model -> Html Msg
view model =
div []
[ button [ onClick (Increment 2) ] [ text "+" ]
, text (toString model)
]

-- UPDATE

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Increment howMuch ->
( model + howMuch, Cmd.none )

-- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none

-- MAIN

main : Program Never
main =
Html.App.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}

Обратим внимание, что сообщение Increment требует целое число:

type Msg

= Increment Int

Затем, в представлении, мы порождаем это сообщение, прикрепив данные:

onClick (Increment 2)

И, наконец, в update мы используем сопоставление с образцом (pattern matching), чтобы извлечь данные:

update msg model =
case msg of
Increment howMuch ->
( model + howMuch, Cmd.none )

Композиция

Одно из главных преимуществ архитектуры Elm заключается в том, как она решает проблему композиции компонентов. Разберемся с этим на примере:

  • Создадим родительский компонент App
  • И дочерний компонент Widget

Дочерний компонент

Начнем с дочернего компонента. Вот код файла Widget.elm.

module Widget exposing (..)

import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)

-- MODEL

type alias Model =
{ count : Int
}

initialModel : Model
initialModel =
{ count = 0
}

-- MESSAGES

type Msg
= Increase

-- VIEW

view : Model -> Html Msg
view model =
div []
[ div [] [ text (toString model.count) ]
, button [ onClick Increase ] [ text "Click" ]
]

-- UPDATE

update : Msg -> Model -> ( Model, Cmd Msg )
update message model =
case message of
Increase ->
( { model | count = model.count + 1 }, Cmd.none )

Этот компонент почти идентичен приложению, которое мы написали в предыдущей части, за исключением подписок и функции main. Данный компонент:

  • Определяет свои собственные сообщения (Msg).
  • Определяет свою собственную модель.
  • Предоставляет функцию update, которая отвечает на его сообщения, т.е. Increase.

Обратим внимание на то, что компонент знает только о том, что определено внутри него. И view, и update используют только внутренние типы (Msg и Model).

В следующей части мы создадим родительский компонент.

Родительский компонент

Код родительского компонента:

module Main exposing (..)

import Html exposing (Html)
import Html.App
import Widget

-- MODEL

type alias AppModel =
{ widgetModel : Widget.Model
}

initialModel : AppModel
initialModel =
{ widgetModel = Widget.initialModel
}

init : ( AppModel, Cmd Msg )
init =
( initialModel, Cmd.none )

-- MESSAGES

type Msg
= WidgetMsg Widget.Msg

-- VIEW

view : AppModel -> Html Msg
view model =
Html.div []
[ Html.App.map WidgetMsg (Widget.view model.widgetModel)
]

-- UPDATE

update : Msg -> AppModel -> ( AppModel, Cmd Msg )
update message model =
case message of
WidgetMsg subMsg ->
let
( updatedWidgetModel, widgetCmd ) =
Widget.update subMsg model.widgetModel
in
( { model | widgetModel = updatedWidgetModel }, Cmd.map WidgetMsg widgetCmd )

-- SUBSCIPTIONS

subscriptions : AppModel -> Sub Msg
subscriptions model =
Sub.none

-- APP

main : Program Never
main =
Html.App.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}

Давайте рассмотрим наиболее важные фрагменты этого кода.

Модель

type alias AppModel =
{ widgetModel : Widget.Model ➊
}

Родительский компонент имеет собственную модель. Один из атрибутов модели содержит Widget.Model ➊. Отметим, что родительскому компоненту не требуется знать, чем является Widget.Model.

initialModel : AppModel
initialModel =
{ widgetModel = Widget.initialModel ➋
}

При создании исходной модели приложения мы просто вызываем Widget.initialModel ➋, чтобы получить экземпляр модели дочернего компонента.

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

initialModel : AppModel
initialModel =
{ navModel = Nav.initialModel,
, sidebarModel = Sidebar.initialModel,
, widgetModel = Widget.initialModel
}

Или у нас могло бы быть несколько дочерних компонентов одного типа:

initialModel : AppModel
initialModel =
{ widgetModels = [Widget.initialModel]
}

Сообщения

type Msg
= WidgetMsg Widget.Msg

Мы используем union тип, чтобы обернуть Widget.Msg и показать, что это сообщение принадлежит этому дочернему компоненту. Это позволит нашему приложению передавать сообщения соответствующим компонентам (это станет ясно, когда мы разберем фунцию Update).

В приложении с несколькими дочерними компонентами у нас получилось бы что-то вроде:

type Msg
= NavMsg Nav.Msg
| SidebarMsg Sidebar.Msg
| WidgetMsg Widget.Msg

View

view : AppModel -> Html Msg
view model =
Html.div []
[ Html.App.map➊ WidgetMsg➋ (Widget.view➌ model.widgetModel➍)
]

Корневая функция view приложения рендерит Widget.view➌. Но представление Widget.view порождает Widget.Msg, которое не совместимо с данным представлением (родительского компонента), которое порождает Main.Msg.

  • Мы используем App.map ➊, чтобы привести сообщения из Widget.view к типу, который ожидается здесь (Msg). Html.App.map отмечает сообщения, приходящие из вложенного представления, тэгом WidgetMsg.
  • Мы передает только ту часть модели, которая ему нужна, т.е. widgetModel ➍.

Update

update : Msg -> AppModel -> (AppModel, Cmd Msg)
update message model =
case message of
WidgetMsg➊ subMsg➋ ->
let
(updatedWidgetModel, widgetCmd)➍ =
Widget.update➌ subMsg model.widgetModel
in
({ model | widgetModel = updatedWidgetModel }, Cmd.map➎ WidgetMsg widgetCmd)

Когда сообщение WidgetMsg➊ получено функцией update, мы делегируем обновление функции update дочернего компонента. При этом, дочерний компонент обновит только то, что имеет для него значение — значения атрибута widgetModel «главной» модели.

Мы используем сопоставление с образцом для извлечения subMsg ➋ из WidgetMsg. Сообщение subMsg будет такого типа, который ожидается функцией Widget.update.

Используя сообщение subMsg и модель model.widgetModel, мы вызываем Widget.update ➌, что вернет нам обновленную widgetModel и команду.

Затем, мы снова используем сопоставление с образцом, чтобы разложить ➍ ответ от Widget.update.

И наконец, нам нужно привести команду, возвращенную Widget.update, к нужному типу. Для этого мы используем Cmd.map и отмечаем команду тэгом WidgetMsg (так же, как делали в представлении).

Последовательность выполнения

Рассмотрим диаграммы, которые отражают нашу архитектуру:

Начальный рендер

Диаграмма начального рендера

Диаграмма начального рендера

(1) App вызывает Main.initialModel, чтобы получить исходную модель приложения.

(2) Main вызывает Widget.initialModel

(3) Widget возвращает свою модель

(4) Main возвращает собранную модель, включающую модель дочернего компонента

(5) App вызывает Main.view, передавая туда основную модель

(6) Main.view вызывает Widget.view, передавая туда модель дочернего компонента widgetModel из основной модели

(7) Widget.view возвращает свой фрагмент Html родительскому компоненту

(8) Main.view возвращает весь Html приложению (App)

(9) App отрисовывает все это в браузере.

Взаимодействие с пользователем

Диаграмма взаимодействия с пользователем

Диаграмма взаимодействия с пользователем

(1) Пользователь кликает по кнопке «Increase».

(2) Widget.view порождает сообщение Increase, которое получает Main.view.

(3) Main.view отмечает это сообщение тэгов, так оно превращается в (WidgetMsg Increase) и передается приложению (App)

(4) App вызывает Main.update с этим сообщением и основной моделью

(5) Т.к. сообщение отмечено тэгом WidgetMsg, Main.update делегирует обновление функции дочернего компонента Widget.update, также передавая ей widgetModel — часть основной модели

(6) Widget.update изменяет модель соответственно полученному сообщению, в данном случае —Increase, и возвращает обновленную widgetModel, а также команду

(7) Main.update обновляет основную модель и передает приложению (App)

(8) Затем App снова рендерит представление уже с обновленной основной моделью.

Ключевые моменты

  • Архитектура Elm предлагает прозрачный способ композиции (или вкладывания) компонентов на сколь угодно глубоком уровне.
  • Дочерним компонентам ничего не нужно знать о родителе. Они используют свои собственные типы и сообщения.
  • Если дочернему компоненту нужно что-то конкретное, он «просит» об этом, используя сигнатуры функций. Родительский компонент ответственен за предоставление того, что требуется дочернему компоненту.
  • Родительскому компоненту не нужно знать, что содержат модели дочерних компонентов, или какие сообщения они порождают. Все, что от него требуется — передавать дочерним компонентам то, что они требуют.

Перейти к первой части, посвященной теме Elm.