Согласно Elm Architecture, вся логика приложения сконцентрирована в одном месте. Это довольно простой и удобный подход, но с ростом приложения можно увидеть функцию update длиной 700 строк, Msg с сотней конструкторов и Model, не умещающуюся в экран.


Такой код довольно тяжело изучать и, зачастую, поддерживать. Я бы хотел продемонстрировать очень простой прием, который улучшит уровень абстракций в вашем приложении.


Давайте разберем простой пример.


Для начала создадим маленькое приложение с одним лишь текстовым полем. Полный код может быть найден здесь.


type alias Model =
    { name : String
    }

view : Model -> Html Msg
view model =
    div []
        [ input [ placeholder "Name", value model.name, onInput ChangeName ] []
        ]

 type Msg
    = ChangeName String

update : Msg -> Model -> Model
update msg model =
    case msg of
        ChangeName newName ->
            { model | name = newName }

Приложение растет, мы добавляем фамилию, "о себе" и кнопку "Сохранить". Коммит тут.


type alias Model =
    { name : String
    , surname : String
    , bio : String
    }

view : Model -> Html Msg
view model =
    div []
        [ input [ placeholder "Name", value model.name, onInput ChangeName ] []
        , br [] []
        , input [ placeholder "Surname", value model.surname, onInput ChangeSurname ] []
        , br [] []
        , textarea [ placeholder "Bio", onInput ChangeBio, value model.bio ] []
        , br [] []
        , button [ onClick Save ] [ text "Save" ]
        ]

type Msg
    = ChangeName String
    | ChangeSurname String
    | ChangeBio String
    | Save

update : Msg -> Model -> Model
update msg model =
    case msg of
        ChangeName newName ->
            { model | name = newName }

        ChangeSurname newSurname ->
            { model | surname = newSurname }

        ChangeBio newBio ->
            { model | bio = newBio }

        Save ->
           ...

Ничего примечательного, все хорошо.


Но сложность резко возрастает, когда мы решаем добавить на нашу страницу еще один компонент, который совсем не связан с существующим — форма для собаки. Коммит.


type Msg
    = ChangeName String
    | ChangeSurname String
    | ChangeBio String
    | Save
    | ChangeDogName String
    | ChangeBreed String
    | ChangeDogBio String
    | SaveDog

update : Msg -> Model -> Model
update msg model =
    case msg of
        ChangeName newName ->
            { model | name = newName }

        ChangeSurname newSurname ->
            { model | surname = newSurname }

        ChangeBio newBio ->
            { model | bio = newBio }

        Save ->
            ...

        ChangeDogName newName ->
            { model | dogName = newName }

        ChangeBreed newBreed ->
            { model | breed = newBreed }

        ChangeDogBio newBio ->
            { model | dogBio = newBio }

        SaveDog ->
            ...

Уже на данном этапе можно заметить, что Msg содержит в себе две "группы" сообщений. Мое "программистское чутье" подсказывает, что такие вещи нужно абстрагировать. Что вот случится, когда появится еще 5 компонентов? А подкомпоненты? Ориентироваться в этом коде будет почти невозможно.


Можем ли мы ввести этот дополнительный уровень абстракции? Конечно!


type Msg
    = HoomanEvent HoomanMsg
    | DoggoEvent DoggoMsg

type HoomanMsg
    = ChangeHoomanName String
    | ChangeHoomanSurname String
    | ChangeHoomanBio String
    | SaveHooman

type DoggoMsg
    = ChangeDogName String
    | ChangeDogBreed String
    | ChangeDogBio String
    | SaveDog

update : Msg -> Model -> Model
update msg model =
    case msg of
        HoomanEvent hoomanMsg ->
            updateHooman hoomanMsg model

        DoggoEvent doggoMsg ->
            updateDoggo doggoMsg model

updateHooman : HoomanMsg -> Model -> Model
updateHooman msg model =
    case msg of
        ChangeHoomanName newName ->
            { model | name = newName }

        -- Code skipped --

updateDoggo : DoggoMsg -> Model -> Model
  -- Code skipped --

view : Model -> Html Msg
view model =
    div []
        [ h3 [] [ text "Hooman" ]
        , input [ placeholder "Name", value model.name, onInput (HoomanEvent << ChangeHoomanName) ] []
        , -- Code skipped --
        , button [ onClick (HoomanEvent SaveHooman) ] [ text "Save" ]
        , h3 [] [ text "Doggo" ]
        , input [ placeholder "Name", value model.dogName, onInput (DoggoEvent << ChangeDogName) ] []
        , -- Code skipped --
        ]

Утилизируя систему типов Elm мы разделили наши сообщения на два типа: человеческие и собачьи. Теперь порог вхождения в этот код станет гораздо проще. Как только какому-нибудь разработчику понадобится что-нибудь изменить в одном из компонентов, он сможет сразу по структуре типов определить, какие части кода ему нужны. Нужно добавить логику в сохранение собачьей информации? Погляди сообщения и запусти поиск по ним.


Представьте, что ваш код — это огромный справочник. Как вы будете искать интересующую вас информацию? По оглавлению (Msg и Model). Будет ли вам легко сориентироваться по оглавлению без деления на разделы и подразделы? Вряд ли.


Заключение


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


Потратив всего лишь час вашего времени (у нас на проекте я тратил меньше 20 минут на каждое приложение) вы можете значительно улучшить читаемость вашего кода и задать стандарт того, как нужно его писать в будущем. Хорош не тот код, в котором легко исправлять ошибки, а тот, который ошибки запрещает и задает пример того, как код должен писаться.


Точно такой же прием можно применить и к Model, выделяя нужную информацию в типы. Например, в нашем примере можно модель разделить всего на два типа: Hooman и Doggo, сократив количество полей в модели до двух.


Боже, храни систему типов Elm.


P.S. репозиторий с кодом можно найти здесь, если вы хотите посмотреть diff-ы

Комментарии (4)


  1. Neftedollar
    12.06.2019 01:39

    Почему это вообще должно быть в рамках одного компонента?
    Почему это не один рутовый компонент который отображает два дочерних?


    1. Nondv Автор
      13.06.2019 10:01

      Что конкретно Вы имеете в виду?


      1. Neftedollar
        13.06.2019 13:11

        Есть подозрение, что не уловил правильно суть статьи, но обычно (в Fable-Elmish по крайней мере) происходит построение дерева компонентов.  


        module NestedComponentOne =
          module Types = 
           type Model = { Counter : int }
           type Msg = | Increment | Decremenet 
          module State = 
            let init () = { Counter = 0 }, Cmd.empty
            let update mode = function  //same as let update model msg = match msg with 
            | Icrement -> { model with Counter = model.Counter + 1 }
            | Decrement -> { model with Counter = model.Counter - 1 }
          module View =
           let view model dispatch = 
            div [ ] [
              h1 [] [ str "Counter" ]
              str <| sprintf "Counter value is %s" model.Counter
              button [ OnCick = fun _ -> despatch Increment ] [ str "Increment" ]
              button [ OnCick = fun _ -> despatch Decrement ] [ str "Decrement" ]
            ]
        module NestedComponentTwo =
          ... Same
        module RootComponent = 
          module Types =
            type Model = { SomeFields : SomeTypes, NestedComponentOneState: NestedComponentOne.Types.Model, NestedComponentTwoState : NestedComponentOne.Types.Model }
            type Msg = 
             | SomeMsgs of SomeTypes
             | NestedComponentOneMsg of NestedComponentOne.Types.Msg
             | NestedComponentTwoMsg of NestedComponentTwo.Types.Msg
          module State = 
             let init() = 
                {SomeFields = someFieldsInit() 
                  NestedComponentOneState = NestedComponentOne.State.init()
                  NestedComponentTwoState = NestedComponentTwo.State.init() }
            let update model = function //same as let update model msg = match msg with 
            | SomeMsg someMsg -> ...
            | NestedComponentOneMsg msg -> NestedComponentOneMsg.State.update model.NestedComponentOneState msg
            | NestedComponentTwoMsg msg -> NestedComponentTwoMsg.State.update model.NestedComponentTwoState msg
          module View =
            let root model dispatch =
              div [] [
                 div [] [ str "ComponentOne"; NestedComponentOne.View.view model.NestedComponentOneState (NestedComponentOneMsg >> dispatch) ]
                 div [] [ str "ComponentTwo"; NestedComponentTwo.View.view model.NestedComponentTwoState (NestedComponentTwoMsg >> dispatch) ]
              ]

        как-то ну в разных файлах. в разных изолированных модулях. Это основная мысль


        1. Nondv Автор
          14.06.2019 00:26

          Да, такой подход имеет смысл, но реализовать его гораздо сложнее.
          Разделение на файлы исключительно ради разделения на файлы ничего не приносит за исключением ненужного усложнения системы.


          Если некоторую логику можно безболезненно выделить в отдельный модуль и при этом она совершенно полностью изолирована и, желательно, использована в нескольких местах — это определенно стоит сделать.


          Но в целом хранение приложения в едином модуле не является плохой практикой в Elm. Во всяком случае, насколько мне известно. Достаточно придерживаться здравого смысла.


          В посте же описан простой прием для улучшения разделения на уровни абстракции :)


          P.S. рекомендую посмотреть доклад Эвана Чаплики — Life of a file (надеюсь, правильно название помню). Лично я со многим не согласен там, но, думаю, важно знать свои опции и идеи, стоящие за дизайном языка/фреймворка/etc