Маленький пример применения библиотеки XState от David Khourshid для декларативного описания логики компонента VueJS 2. XState это очень развитая библиотека для создания и использования конечных автоматов на JS. Неплохое подспорье в трудном деле создания веб приложений.

Предистория


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

{
    idle: ['waitingConfirmation'],
    waitingConfirmation: ['idle','waitingData'],
    waitingData: ['dataReady', 'dataProblem'],
    dataReady: [‘idle’],
    dataProblem: ['idle']
}

По сути это было перечисление состояний и для каждого описан массив возможных состояний, в которые может перейти система. Приложение просто “говорит” машине состояний — хочу перейти в такое состояние, если это возможно машина переходит в нужное состояние.

Этот подход работает, но возникают неудобства. Например если кнопка в разном состоянии должна инициировать переход в разные состояния. Придется городить условия. Вместо декларативности получаем кашу.

Изучив теорию по роликам из Ютюба, стало понятно, что события нужны и важны. В голове родился такой вид декларации:

{
  idle: {
    GET: 'waitingConfirmation',
  },
  waitingConfirmation: {
    CANCEL: 'idle',
    CONFIRM: 'waitingData'
  },
  waitingData: {
    SUCCESS: 'dataReady',
    FAILURE: 'dataProblem'
  },
  dataReady: {
    REPEAT: 'idle'
  },
  dataProblem: {
    REPEAT: 'idle'
  }
}

А это уже очень напоминает то, как описывает состояния библиотека XState. Почитав внимательней доку, я решил убрать самодельный велосипед в сарай, и пересесть на фирменный.

VUE + XState


Установка очень простая, читайте доку, после установки включаем XState в компонент:

import {Machine, interpret} from ‘xstate’

Создаем машину на основе объекта-декларации:

const myMachine = Machine({
    id: 'myMachineID',
    context: {
      /* some data */
    },
    initial: 'idle',
    states: {
        idle: {
          on: {
            GET: 'waitingConfirmation',
          }
        },
        waitingConfirmation: {
          on: {
            CANCEL: 'idle',
            CONFIRM: 'waitingData'
          }
        },
        waitingData: {
          on: {
            SUCCESS: 'dataReady',
            FAILURE: 'dataProblem'
          },
        },
        dataReady: {
          on: {
            REPEAT: 'idle'
          }
        },
        dataProblem: {
          on: {
            REPEAT: 'idle'
          }
        }
    }
})

Понятно, что есть состояния ‘idle’, ‘’waitingConfirmation' … и есть события в верхнем регистре GET, CANCEL, CONFIRM ….

Сама по себе машина не работает, из нее надо создать сервис с помощью функции interpret. Ссылку на этот сервис разместим в наш state, а заодно и ссылку на текущее состояние current:

data: {
    toggleService: interpret(myMachine),
    current: myMachine.initialState,
}

Сервис надо стартануть — start(), а также указать, что при переходах состояния мы обновляем значение current:

mounted() {
    this.toggleService
        .onTransition(state => {
            this.current = state
         })
        .start();
    }

В методы добавляем функцию send, ее и используем для управления машиной — передачи ей событий:

methods: {
   send(event) {
      this.toggleService.send(event);
   },
  …
} 

Ну а дальше все просто. Передавать событие просто вызовом:

this.send(‘SUCCESS’)

Узнать текущее состояние:

this.current.value

Проверить нахождение машины в определенном состоянии так:

this.current.matches(‘waitingData')


Cоберем все вместе:

Template
<div id="app">
  <h2>XState machine with Vue</h2>
  <div class="panel">
    <div v-if="current.matches('idle')">
      <button @click="send('GET')">
        <span>Get data</span>
      </button>
    </div>
    <div v-if="current.matches('waitingConfirmation')">
      <button @click="send('CANCEL')">
        <span>Cancel</span>
      </button>
      <button @click="getData">
        <span>Confirm get data</span>
      </button>
    </div>
    <div v-if="current.matches('waitingData')" class="blink_me">
      loading ...
    </div>
    <div v-if="current.matches('dataReady')">
      <div class='data-hoder'>
        {{ text }}
      </div>
      <div>
        <button @click="send('REPEAT')">
          <span>Back</span>
        </button>
      </div>
    </div>
    <div v-if="current.matches('dataProblem')">
      <div class='data-hoder'>
        Data error!
      </div>
      <div>
        <button @click="send('REPEAT')">
          <span>Back</span>
        </button>
      </div>
    </div>
  </div>
  <div class="state">
    Current state: <span class="state-value">{{ current.value }}</span>
  </div>
</div>


JS
const { Machine, interpret } = XState

const myMachine = Machine({
    id: 'myMachineID',
    context: {
      /* some data */
    },
    initial: 'idle',
    states: {
        idle: {
          on: {
            GET: 'waitingConfirmation',
          }
        },
        waitingConfirmation: {
          on: {
            CANCEL: 'idle',
            CONFIRM: 'waitingData'
          }
        },
        waitingData: {
          on: {
            SUCCESS: 'dataReady',
            FAILURE: 'dataProblem'
          },
        },
        dataReady: {
          on: {
            REPEAT: 'idle'
          }
        },
        dataProblem: {
          on: {
            REPEAT: 'idle'
          }
        }
    }
	})



new Vue({
  el: "#app",
  data: {
  	text: '',
  	toggleService: interpret(myMachine),
    current: myMachine.initialState,
  },
  computed: {

  },
  mounted() {
    this.toggleService
        .onTransition(state => {
          this.current = state
        })
        .start();
  },
  methods: {
    send(event) {
      this.toggleService.send(event);
    },
    getData() {
      this.send('CONFIRM')
    	requestMock()
      .then((data) => {       
      	this.text = data.text   
      	this.send('SUCCESS')
      })
      .catch(() => this.send('FAILURE'))
    },

  }
})

function randomInteger(min, max) {
  let rand = min + Math.random() * (max + 1 - min)
  return Math.floor(rand);
}

function requestMock() {
  return new Promise((resolve, reject) => {
  	const randomValue = randomInteger(1,2)
  	if(randomValue === 2) {
    	let data = { text: 'Data received!!!'}
      setTimeout(resolve, 3000, data)
    }
    else {
    	setTimeout(reject, 3000)
    }
  })
}


Ну и конечно все это можно потрогать на jsfiddle.net

Visualizer


XState предоставляет замечательный инструмент — Visualizer . Можно посмотреть диаграмму именно вашей машины. И не только посмотреть но и пощелкать по событиям и осуществить переходы. Вот так выглядит наш пример:



Итог


XState отлично работает, вместе с VueJS. Это упрощает работу компонента, позволяет избавиться от лишнего кода. Главное — декларация машины позволяет быстро понять логику. Данный пример простой, но я уже пробовал и на более сложном примере для рабочего проекта. Полет нормальный.

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

  • Guarded transitions
  • Actions (entry, exit, transition)
  • Extended state (context)
  • Orthogonal (parallel) states
  • Hierarchical (nested) states
  • History

А есть еще аналогичные библиотеки, например Robot. Вот сравнение Comparing state machines: XState vs. Robot. Так что если вас заинтересовала тема, вам будет чем заняться.