Управление состоянием приложения с помощью конечного автомата

Конечный автомат — это модель системы, у которой есть конечное количество состояний и переходов между этими состояниями.

Звучит заумно, но всё это можно представить в виде лампочки. У неё есть два состояния: включена и выключена. Чтобы перевести лампочку из одного состояния в другое, мы подаём сигнал — нажимаем на кнопку включения. Тогда срабатывает переход, и состояние лампочки меняется.

Набор состояний и переходов между ними можно нарисовать в виде графа, где вершинами будут состояния, а рёбрами — переходы.

Граф состояний и переходов лампочки
Граф состояний и переходов лампочки

Такая абстракция хорошо подходит для построения пользовательских интерфейсов. Например — чтобы управлять состоянием приложения или какой-то его части.

Чё-та сложна. В чём польза?

Все изменения состояния в приложении описываются в одном месте. Для каждого перехода указано, какой сигнал его запустит, из какого состояния это может произойти и в какое состояние этот переход приведёт. Если переход осуществить нельзя, то либо ничего не произойдёт, либо можно выбросить исключение.

С конечным автоматом не возникает ситуации, когда «мы что-то нажали, и всё исчезло». Потому что причинно-следственные связи прописаны жёстко и не разбросаны по приложению в разных местах.

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

А ещё список переходов даёт возможность быстро нагенерировать кучу автотестов на изменения состояния.

Обзор тупого приложения для примера

Допустим, у нас есть приложение, которое загружает данные с сервера по нажатию на кнопку. (Работает в последнем Хроме, за другие браузеры не ручаюсь. В Сафари точно не работает.)

В этом приложении есть несколько состояний, которые отображаются на экране.

Главная страница с приветствием
Главная страница с приветствием
Индикатор загрузки, когда запрос идёт на сервер
Индикатор загрузки, когда запрос идёт на сервер
Отображение данных, когда они загрузились
Отображение данных, когда они загрузились
Отображение ошибки, когда данные не загрузились, или что-то пошло не так
Отображение ошибки, когда данные не загрузились, или что-то пошло не так

Тогда возможные переходы между состояниями:

  • с главной по нажатию на кнопку «загрузить» переходим в состояние загрузки;
  • из состояния загрузки попадаем в отображение данных, если всё ок;
  • либо в отображение ошибки, если что-то не ок;
  • из отображения данных или ошибки можем попасть на главную по нажатию на кнопку «очистить»;
  • из отображения данных можем перейти в состояние загрузки, если нажмём на кнопку «загрузить ещё».

Если представить это дело в виде графа, то получится так:

Граф состояний и переходов приложения из примера
Граф состояний и переходов приложения из примера

Фейковое API

Для эмуляции загрузки данных будем использовать джейсон-плейсхолдер. Напишем небольшую функцию, которая будет запрашивать данные с сервера:

// api.js
const fetchPosts = async () => {
	const response = await fetch('https://jsonplaceholder.typicode.com/posts');
	const posts = await response.json();
	return posts;
};

Состояния, переходы и сам автомат

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

// fsm/states.js
const states = {
	INITIAL: 'idle',
	LOADING: 'loading',
	SUCCESS: 'success',
	FAILURE: 'failure'
};

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

Внутри функции может быть несколько условий, по которым мы будем решать, какое состояние требуется вернуть.

// fsm/transitions.js
const transitions = {
  [states.INITIAL]: {
    fetch: () => /* возвращает states.LOADING */,
  },

  [states.LOADING]: {},

  [states.SUCCESS]: {
    reload: () => /* возвращает states.LOADING */,
    clear: () => /* возвращает states.INITIAL */,
  },

  [states.FAILURE]: {
    retry: () => /* возвращает states.LOADING */,
    clear: () => /* возвращает states.INITIAL */,
  },
}

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

Метод stateOf будет показывать, в каком состоянии находится автомат сейчас. Приватный метод updateState будет обновлять состояние и данные, если требуется.

// fsm/machine.js
class StateMachine {
	constructor({ initial, states, transitions, data = null }) {
		this.transitions = transitions;
		this.states = states;
		this.state = initial;
		this.data = data;
	}

	stateOf() {
		return this.state;
	}

	_updateState(newState, data = null) {
		this.state = newState;
		this.data = data;
	}
}

Запуск переходов

Чтобы можно было управлять переходами извне, создадим метод performTransition. Он будет принимать название перехода и проверять, возможен ли такой переход. Если возможен, то будет обновлять состояние.

// fsm/machine.js
performTransition(transitionName) {
  const possibleTransitions = this.transitions[this.state]
  const transition = possibleTransitions[transitionName]
  if (!transition) return

  // переход возвращает новое состояние для автомата
  const newState = transition()
  this._updateState(newState)
}

В теории всё просто: вот мы загружаем данные, значит переводим автомат в states.LOADING. Вот они загрузились, значит переводим в states.SUCCESS. Но на деле: как управлять потоком событий? как узнать, что данные действительно загрузились? как определить, что не произошла ошибка по пути?

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

Генераторы и асинхронные генераторы

Генератор — это функция, которая может приостанавливать своё выполнение и продолжать его позже. В основе работы генераторов лежат итераторы, поэтому любой генератор можно проитерировать через for...of.

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

function* transition() {
	yield states.LOADING;
	yield states.SUCCESS;
}

Этот генератор будет последовательно выбрасывать состояние загрузки, а в потом — состояние успеха.

Чтобы получить данные из генератора, мы можем проитерировать его вручную:

const generator = transition();
generator.next(); // { done: false, value: 'loading' }
generator.next(); // { done: false, value: 'success' }
generator.next(); // { done: true, value: undefined }

Либо через for...of:

for (const state of transition()) {
	console.log(state);
}

Тогда если мы хотим применить подобный переход к автомату, то мы немного изменим метод performTransition:

// fsm/machine.js
performTransition(transitionName) {
  const possibleTransitions = this.transitions[this.state]
  const transition = possibleTransitions[transitionName]
  if (!transition) return

  // вот тут используем for ... of
  for (const state of transition()) {
    this._updateState(state)
  }
}

Но этого мало 🙃
Ведь помимо нескольких состояний, у нас есть ещё и запрос на сервер, который надо обработать.

Запрос асинхронный, чтобы получить его результат, мы будем использовать await. Но await можно использовать только внутри асинхронной функции, поэтому генератор перехода станет асинхронным.

Асинхронный генератор почти не отличается от обычного, только вместо значений он выбрасывает промисы. И итерировать его придётся через for await...of:

// fsm/transitions.js
async function* transition() {
	yield states.LOADING;

	try {
		const data = await fetchPosts();
		yield states.SUCCESS;
	} catch (e) {
		yield states.FAILURE;
	}
}

Так как генератор выбрасывает промисы, то чтобы их развернуть, нам и здесь понадобится await. Поэтому метод performTransition тоже становится асинхронным.

// fsm/machine.js
async performTransition(transitionName) {
  const possibleTransitions = this.transitions[this.state]
  const transition = possibleTransitions[transitionName]
  if (!transition) return

  for await (const newState of transition()) {
    this._updateState(newState)
  }
}

Не только состояние, но ещё и данные

Состояния мы теперь записываем и обрабатываем, но пока что никуда не записываем данные от сервера.

В автомате для этого у нас есть поле data. Будем его обновлять при получении данных из перехода если они есть:

// fsm/transitions.js
async function* transition() {
	yield { newState: states.LOADING };

	try {
		const data = await fetchPosts();
		yield { newState: states.SUCCESS, data };
	} catch (e) {
		yield { newState: states.FAILURE };
	}
}

И в performTransition соответственно записываем их в поле data:

// fsm/machine.js
async performTransition(transitionName) {
  const possibleTransitions = this.transitions[this.state]
  const transition = possibleTransitions[transitionName]
  if (!transition) return

  for await (const {newState, data=null} of transition()) {
    this._updateState(newState, data)
  }
}

Рендер интерфейса

Окей, теперь надо это как-то отрисовать. Можно сюда вкорячить какой-нибудь Реакт, но мы пишем всё с нуля, поэтому и отрисовщик напишем тоже свой.

Функция-рендер будет принимать состояние автомата и данные, которые он содержит. По состоянию будет выбирать, какой шаблон надо отрисовывать. Если использовать Реакт, то не многое изменится: мы просто будем возвращать не строки, а компоненты.

// renderer.js
const render = (state, payload) => {
	switch (state) {
		case states.INITIAL:
			return `<div>...</div>`;

		case states.LOADING:
			return `<div>...</div>`;

		case states.FAILURE:
			return `<div>...</div>`;

		case states.SUCCESS:
			return `<div>...</div>`;

		default:
			return '';
	}
};

Использовать это будем так:

// index.js
const renderApp = (state, data) => {
	const html = render(state, data);
	document.getElementById('root').innerHTML = html;
};

renderApp(states.INITIAL);

Да-да, innerHTML лучше не использовать, приложение лучше полностью не перерисовывать. Но камон, этот пост не об этом 🙃

Отрисовка изменений

Тот же рендер мы будем использовать и когда автомат будет менять своё состояние.

Чтобы отследить изменения, нам понадобится как-то подписаться на обновления состояния. Создадим метод subscribe и чуть обновим updateState:

// fsm/machine.js
subscribe(event, callback) {
  if (event === 'update') this._onUpdate = callback || null
}

_updateState(newState, data=null) {
  this.state = newState
  this.data = data

  this._onUpdate
    && this._onUpdate(newState, data)
}

Теперь создадим экземпляр автомата, и в subscribe передадим событие update, на которое подписываемся и колбек:

// index.js
const fsm = new StateMachine({
	states,
	transitions,
	initial: states.INITIAL
});

fsm.subscribe('update', (state, data) => renderApp(state, data));

И осталось повесить вызовы переходов на события, например — клики по кнопкам. Вызывать переходы будем методом performTransition:

// index.js
fsm.performTransition('fetch');

Кнопка «загрузить ещё»

Я сделал два разных способа загрузки данных: один перетирает старые данные из автомата, второй — добавляет новые к старым. Работают они через два разных перехода: loadMore и reload от состояния states.SUCCESS.

В этой статье это уже не поместится, но ссылку на исходники я оставлю, если интересно почитайте 🙃

Но зачем писать с нуля

Конечно, писать с нуля это не надо. Есть куча библиотек, которые реализуют конечные автоматы. Например, вот:

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

Ссылки по теме

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

Статьи о конечных автоматах

О генераторах и итераторах

Библиотеки

Материалы к статье