Недавно я наткнулся на интересное обсуждение на Full Stack Radio — Bulding Better UI Components with State Machines. Речь шла о том, что концепция машины состояний может помочь при разработке компонентов Vue. Я начал просматривать готовые решения, но они оказались не столь просты, и мне захотелось сделать более простую реализацию, о которой я и хочу рассказать в этой статье. Статья может оказаться полезной не только для тех кто использует Vue, но и для пользователей Angular, React и т.д., а также для программистов других «конфессий».

В обсуждении затрагивалась тема, близкая всем, кто писал компоненты. Представьте. что вы пишете компонент, который обращается за данными, и вам надо отобразить спиннер на то время, пока запрос выполняется. Обычно в таких случаях вы создаете логическую переменную isLoading. Изначально isLoading = false, а перед тем, как запросить данные вы присваиваете переменной isLoading значение true. После того, как данные пришли вы опять присваиваете ей значение false. Видимость спиннера привязана к isLoading.

Этот подход отлично работает. Но компонент редко бывает столь простым. И в ходе работы приходится создавать еще логические переменные, которые хранят разные состояния. Проблема в том, что если у вас одна переменная состояния, она порождает два состояния, а две логические переменные уже четыре состояния, три — восемь и т.д. И беда в том, программист может ошибиться и не обработать какие-то состояния, в которые может прийти система. А это приводит к ошибке. Кроме того довольно сложно понимать такой компонент и приходится плодить условия.

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

На эту тему довольно много статей (например Finite State Machines in Vue, но большинство из них после краткой теории рекомендуют подключить одну из готовых библиотек. Например davidkpiano/xstate.

Мне xstate показался излишне громоздким и захотелось попробовать свой велосипед. К тому же это отличный повод разобрать новый, для меня, паттерн.

Вот тут код с которым можно поиграть — State machine with Vue, version 2. А тут сырая версия, со слегка отличающимся подходом — State machine with Vue, version 1.
Отталкивался я от статьи State Machines.

Итак машина состояний:

class StateMachine {
    constructor (initialState, transitions) {
        this.state = initialState
        this.transitions = transitions
    }
    transition (nextState, method, params) {
  	const transitionsArray = this.transitions[this.state]	
        if(transitionsArray.indexOf(nextState) === -1) return this.state
        if(method) method(...params)
        this.state = nextState
        return this.state
    }
}

Инициализируем ее так:

const machine = new StateMachine('idle', {
    idle: ['waitingConfirmation'],
    waitingConfirmation: ['idle','waitingData'],
    waitingData: ['dataReady', 'dataProblem'],
    dataReady: ['waitingConfirmation'],
    dataProblem: ['waitingConfirmation']
})

В методах компонента создаем функцию перехода:

transition(nextState, method = null, params = []) {
    this.machineState = machine.transition(nextState, method, params)
}

В событиях сразу пишем куда хотим перейти:

@click=“transition('waitingConfirmation')"

Если надо при этом вызвать метод компонента пишем так:

@click="transition('waitingData', getData, [222])”

Второй параметр — метод компонента который надо вызвать с этим переходом.
Третий параметр — массив переменных для этого метода.

Внутри методов вызов такой:

this.transition('dataReady')

Я специально не стал скрывать кнопки с помощью v-if, просто пока делаю цвет их шрифта сереньким. Зато видно, что нажатие на них не срабатывает, если машина не разрешает.

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

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


  1. Almatyn Автор
    28.12.2019 16:49

    Насчет декомпозиции сложных компонентов — оно конечно верно, но по факту всегда есть компонент управляющий мелкими, и там может возникнуть достаточно сложная логика. А так я конечно за максимально простые и атомарные компоненты. Хотя сложность вряд ли уйдет от декомпозиции. Она скорее размажется по разным местам. А еще уж очень сильно мельчить компоненты иногда не удобно, более трудоемко может получиться. На практике есть какая-то золотая середина.

    P.S. это ответ на пост Soarex16.


    1. Soarex16
      28.12.2019 17:01

      Возможно я чего-то не замечаю, но не могли бы Вы пояснить преимущества Вашего варианта перед vuex, redux и им подобным?


      1. Almatyn Автор
        28.12.2019 17:35

        Мой вариант ни как не заменяет Vuex (redux), это разные вещи. Во Vuex — хранится состояние вашего приложения, общие для всех компонентов данные. И мой пример мог бы спокойно делать fetch из общего хранилища Vuex. Я его не прикрутил сюда для простоты. A state machine в данном случае просто тип алгоритма работы отдельного компонента.


  1. Soarex16
    28.12.2019 16:49

    Спасибо за интересный пост.
    Довольно сильно похоже на redux, но в отличие от Вашего решения, redux не имеет явной стейт машины, а полагается на reducer-ы в качестве объектов изменяющих глобальное состояние.

    Проблема в том, что если у вас одна переменная состояния, она порождает два состояния, а две логические переменные уже четыре состояния, три — восемь и т.д. И беда в том, программист может ошибиться и не обработать какие-то состояния, в которые может прийти система. А это приводит к ошибке. Кроме того довольно сложно понимать такой компонент и приходится плодить условия

    Вам не кажется, что в таком случае (сложный компонент, много логики) логичнее было бы его декомпозировать? В случае применения того же redux можно декомпозировать состояние на более мелкие и простые компоненты.

    Небольшой дисклеймер: Не хочу данным комментарием принизить ценность Вашей публикации, просто хотелось высказать свои размышления в тему менеджмента состояния в сложных компонентах. Еще хочу отметить, что не считаю redux решением всех проблем (на самом деле это уже отдельная тема для холивара).

    :)


    1. Almatyn Автор
      28.12.2019 16:52

      ответ смотрите выше


    1. Almatyn Автор
      28.12.2019 17:26

      На самом деле вопрос очень интересный. В случае с моим примером действительно можно вывести группу кнопок в отдельный компонент ButtonWithConfirmation, который сам следит за своим состоянием. При клике на кнопку Submit покажет две кнопки. На Cancel вернет все в начальное состояние а по клику на Confirm сделает emit события confirm. Вроде просто. Но все-таки придется сокрытием этого компонента руководить из родителя. Т.е. декомпозиция не все вопросы снимает. А ведь это самый простой случай.
      Тема интересная — можно ли декомпозировать все компоненты так, чтобы в них в каждом была только одна логическая переменная отвечающая за состояние.
      Мне кажется, что не всегда возможно. Да и не зря начались все эти разговоры про машины состояний.


  1. zim32
    28.12.2019 13:00

    Спасибо идея хорошая реализация вроде тоже. Не понял правда зачем дублировать метод transition, а не вызывать stateMachine.transition, но то такое.


    1. Almatyn Автор
      28.12.2019 15:13

      чтобы в темплейте transition было видно и чтобы сразу выставлять this.machineState
      можно конечно сделать и по другому.


  1. Focushift
    28.12.2019 14:49

    Зачем так сложно?
    При отправке запроса получаем промис, передаем его в компонент отрисовывающий спиннер и все. Спинер будет сам отслеживать когда промис полностью выполнится и сам «спрячется».


    1. Almatyn Автор
      28.12.2019 15:27

      Если вы имеете в виду в компоненте спиннера создать property и туда передавать промис, то это звучит не очень. Оно конечно можно, но как-то не стандартно.

      Т.е. во Vue даже передача функции в свойства не общепринятая практика. Более того, это можно, но не рекомендуется. Вот тут How to Pass a Function as a Prop in Vue обсуждали это.

      А уж передавать промис в свойства, как то совсем непривычно. Надо подумать.


    1. Almatyn Автор
      28.12.2019 15:52

      Вы наверное из Реакта эту практику взяли. Во Vue в property передают просто переменную, в данном случае isVisible, и в зависимости от нее решают отображать или не отображать спиннер. И в общем рекомендуется только так и делать. Обратно делать emit события. Других вариантов лучше избегать. Но а в общем это частности. Статья-то не об этом.


      1. Focushift
        28.12.2019 16:00

        Нет, сначала в первом ангуляре так сделал.
        Тем более в типах пропса можно указать его как Promise.


  1. Almatyn Автор
    28.12.2019 16:36

    Для Vue это не стандартная практика. Лучше наверно так не делать.


    1. GerrAlt
      28.12.2019 22:03

      а точно вообще нужно тащить это все через Vue? может обойтись простой функцией, принимающей промис и создающей троббер, который спрячется при резолве промиса?


  1. skiedr
    30.12.2019 12:04

    Советую посмотреть доклад на эту тему на dotCSS о создании стилей с состоянием. Весьма перекликается с темой вопроса.


    1. Almatyn Автор
      30.12.2019 15:09

      Отличная презентация. Как раз в тему. И с CSS вопрос решается.


  1. Spunreal
    30.12.2019 16:41

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

    Проблемы использования конечных автоматов (КА) можно обобщить одной фразой:


    Кратное увеличение сложности разработки и поддержки при увеличении количества состояний.

    Иерархические конечные автоматы так же не помогут, они только отодвинут hell-линию.


    1. Almatyn Автор
      30.12.2019 17:50

      Интересное замечание, но без них еще хуже.
      Мне кажется для задач фронтенда hell-линия будет вне досягаемости. К тому же есть еще другие методы борьбы со сложностью. Сам Vue ее не плохо позволяет снизить.

      Про иерархические КА надо будет узнать.
      И интересно, что этот дальше в ряду — КА, иерархические КА?


      1. Spunreal
        30.12.2019 23:33

        Дальше поведенческие деревья, но их точно вряд-ли понадобится использовать во фронтенде.