Конечные автоматы с @wordpress/data и TypeScript
Опубликовано: 2021-09-16Предположим, мы хотим создать экран входа в наше приложение. Когда пользователь видит его в первый раз, это пустая форма. Пользователь заполняет поля и, как только они все установлены, он нажимает кнопку входа в систему, чтобы подтвердить учетные данные и войти в систему. Если проверка прошла успешно, он переходит к следующему экрану. Но если это не так, им выводится сообщение об ошибке и предлагается повторить попытку.
Если бы вам нужно было реализовать состояние такого экрана и вы читали мои сообщения о том, как определить состояние приложения с помощью TypeScript и @wordpress/data
, держу пари, вы бы сделали что-то вроде этого:
type State = { readonly username: string; readonly password: string; readonly isLoggingIn: boolean; readonly errorMessage: string; };
который имеет все необходимые поля для управления состоянием, но… это лучшее, что мы можем сделать?
Конечные автоматы
Конечный автомат (FSM) — это, вообще говоря, математическая модель, которая позволяет нам определить конечное множество состояний и возможные переходы между ними. (подробнее в Википедии). Мне нравится эта абстракция, потому что она идеально соответствует нашим потребностям, когда речь идет о моделировании состояния наших приложений. Например, диаграмма состояний для нашего экрана входа в систему может выглядеть примерно так:

который, как вы можете видеть, ясно и лаконично описывает поведение, которое мы хотим реализовать.
Определение состояний с помощью @wordpress/data
В нашей вводной серии по React мы увидели, как использовать пакет @wordpress/data
для создания и управления состоянием нашего приложения. Мы сделали это, определив основные компоненты нашего хранилища данных:
- Набор селекторов для запроса состояния
- Набор действий для запуска запроса на обновление
- Функция редуктора , которая, учитывая текущее состояние и действие обновления, обновляет состояние.
По сути, хранилища в @wordpress/data
уже ведут себя как конечные автоматы, поскольку функция редьюсера «переходит» из одного состояния в другое с помощью действия:
( state: State, action: Action ) => State
Как определить конечный автомат с помощью TypeScript в @wordpress/data
Похоже, @wordpress/data
довольно близки к модели FSM, которую мы представили несколькими строками выше: мы просто упускаем определенные состояния, в которых мы можем находиться, и переходы между ними… Итак, давайте подробнее рассмотрим, как мы можем решить эти проблемы, шаг за шагом.
Явные состояния
Мы начали этот пост с демонстрации возможной реализации состояния нашего приложения. Наше первое предложение состояло в наборе атрибутов, которые выполняли свою работу, но это совсем не было похоже на конечный автомат. Итак, давайте начнем с того, что наши состояния явно определены в нашей модели:
type State = | Form | LoggingIn | Success | Error; type Form = { readonly status: 'form'; readonly username: string; readonly password: string; }; type LoggingIn = { readonly status: 'logging-in'; readonly username: string; readonly password: string; }; type Success = { readonly status: 'success'; // ... }; type Error = { readonly status: 'error'; readonly message: string; };
Довольно очевидно, да? Для каждого состояния существует один тип, а общее состояние приложения определяется как размеченное объединение типов. Он так называется потому, что (а) состояние — это «объединение разных типов» (то есть это либо Form
, либо LoggingIn
, либо какое угодно ) и (b) у нас есть атрибут (в данном случае , status
), что позволяет нам различать, в каком конкретном состоянии мы находимся в каждый момент времени.
Я думаю, что это решение намного лучше, чем то, которое у нас было в начале. В нашем первоначальном решении мы смогли определить «недопустимые состояния», потому что можно было войти в систему (просто установите для isLoggingIn
значение true
) и в то же время быть в состоянии ошибки (просто установить для errorMessage
значение, отличное от пустого нить). Но это явно не имеет смысла! Который из них? Мы авторизуемся или должны показать ошибку?
С другой стороны, новое решение намного точнее, когда речь идет о представлении состояния: оно делает «недействительные» состояния «невозможными». Если мы находимся в LoggingIn
, нет возможности настроить сообщение об ошибке (для него нет атрибута!). Если мы показываем Error
, вы не можете войти в систему. Намного лучше, не так ли?
Действия
В @wordpress/data
действия обновляют хранилище в нашем состоянии. Поскольку мы моделируем наше состояние как FSM, наши действия будут стрелками на исходной диаграмме. Все, что вам нужно сделать, это смоделировать их как размеченное объединение типов (по соглашению дискриминатор обычно называется type
), и все готово:
type SetCredentials = { readonly type: 'SET_CREDENTIALS'; readonly username: string; readonly password: string; }; type Login = { readonly type: 'LOGIN'; }; type ShowApp = { readonly type: 'SHOW_APP'; // ... }; type ShowError = { readonly type: 'SHOW_ERROR'; readonly message: string; }; type BackToLoginForm = { readonly type: 'BACK_TO_LOGIN_FORM'; };
Наши действия должны представлять стрелки, которые были на исходной диаграмме, верно? Ну, не знаю, как вы, а мне кажется, что эти типы действий совсем не похожи на стрелки! Стрелки имеют направление (переходят из одного состояния в другое); действия нет.

Нам нужен способ, чтобы в нашем коде было ясно, что определенные действия полезны только для перехода из одного состояния в другое. И пока лучшее решение, которое я нашел, это использовать сам редуктор:
function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } case ...: ... } return state; }
Если наш редьюсер начинает с фильтрации status
нашего state
, мы знаем «источник» нашей стрелки. Затем мы смотрим на текущее действие и, если это «стрелка, направленная наружу», мы генерируем новое «целевое» состояние.
Вы можете ясно увидеть, как это работает, в приведенном выше фрагменте. Если текущий status
— form
, действие SET_CREDENTIALS
переводит нас в то же состояние, в котором мы были (обновление учетных данных, да), а действие LOGIN
изменяет состояние на logging-in
. Любое другое действие в этом состоянии игнорируется, поэтому состояние не изменяется.
Это все хорошо и прекрасно, но… Не знаю, мне не очень нравится код, а вам? Я хочу убедиться, что мой код ясен, лаконичен, не требует пояснений и типобезопасен. Можем ли мы это сделать?
Строго типизированный редуктор
Чтобы исправить беспорядок, в который мы только что попали, нам просто нужно реорганизовать наш reducer
, чтобы каждое возможное состояние в нашем FSM имело свой редьюсер. Если мы сделаем это правильно, они будут строго типизированы и будет понятно, что можно, а что нельзя.
Начнем с создания типа для каждого состояния в нашем приложении. Каждый тип будет группировать действия, которые могут привести нас из данного состояния в другое место. Например, в нашем состоянии Form
есть две направленные наружу стрелки ( SetCredentials
и Login
), поэтому давайте создадим тип FormAction
с объединением двух действий. Повторите процесс для каждого состояния, и вы получите следующее:
type FormAction = SetCredentials | Login; type LoggingInAction = ShowError | ShowApp; type SuccessAction = ...; type ErrorAction = BackToLoginForm; type Action = | FormAction | LoggingInAction | SuccessAction | ErrorAction;
Затем определим все редюсеры, которые нам нужны. Опять же, по одному для каждого состояния:
function reduceForm( state: Form, action: FormAction ): Form | LoggingIn { switch ( action.type ) { switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } } reduceLoggingIn: ( state: LoggingIn, action: LoggingInAction ) => Success | Error reduceError: ( state: Error, action: ErrorAction ) => Form reduceSuccess: ( state: Success, action: SuccessAction ) => ...
Благодаря TypeScript и типам, которые мы только что определили, теперь мы можем быть предельно точными в отношении исходного состояния и набора действий, ожидаемых каждым редьюсером, а также целевого состояния, которое мы можем получить в результате.
Наконец, нам просто нужно связать все это с помощью уникальной функции reducer
:
function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': return reduceForm( state, action as FormActions ) ?? state; case 'logging-in': return reduceLoggingIn( state, action as LoggingInActions ) ?? state; case 'error': return reduceError( state, action as ErrorActions ) ?? state; case 'success': return reduceSuccess( state, action as SuccessActions ) ?? state; } }
Полученный код, на мой взгляд, понятнее и проще в сопровождении. Кроме того, теперь TypeScript может проверять, что мы делаем, и обнаруживать любые отсутствующие сценарии. Потрясающий!
Заключение
В этом посте мы увидели, что такое конечный автомат и как мы можем реализовать его в хранилище @wordpress/data
с помощью TypeScript. Наше окончательное решение довольно простое, но оно предлагает множество преимуществ: оно лучше отражает состояние и намерения нашего приложения и использует мощь TypeScript.
Надеюсь, вам понравился этот пост! Если да, поделитесь, пожалуйста, с друзьями и коллегами. И, пожалуйста, дайте мне знать, как вы решаете эти проблемы в разделе комментариев ниже.
Избранное изображение Патрика Хендри на Unsplash.