Конечные автоматы
В концепции конечного автомата, вероятно, нет ничего нового для всех нас. Это даже не жаргон программирования. Фактически, поведение конечного автомата можно легко наблюдать повсюду. Возьмем, к примеру, лампочку.
Свет имеет 2 конечных состояния: включен и выключен. И его состояние должно быть включено или выключено. Невозможно включить и выключить одновременно. Когда он выключен, и мы хотим его включить, и наоборот, мы просто нажимаем выключатель на стене. Подумайте о включении и выключении в качестве входов для переключения состояния света.
Конечный автомат (лампочка) - это абстрактная модель, которая может одновременно находиться только в одном состоянии (включено или выключено). Переход из одного состояния в другое (Светлый 🌞 - ›Темный ☽) - это переход. Этот переход происходит в ответ на определенные вводы (включить или выключить 🎛)
Вы можете узнать больше об идеях и концепциях конечного автомата в Документации XState или Википедии.
Конечный автомат в форме входа в систему
Давайте возьмем эти идеи и изложим их в форме. Представьте, что мы создаем форму входа в систему. Пользователям потребуется ввести свой адрес электронной почты, пароль и нажать кнопку «Отправить», чтобы сделать запрос API, удостоверяющий эти учетные данные на сервере. Фактически, нам также необходимо убедиться, что адрес электронной почты действителен, а пароль должен содержать не менее 6 символов.
Создать конечный автомат формы
Наша первая задача - выяснить, какие состояния может иметь форма. Как ты думаешь об этом?
Уже получили ответы? У вас есть электронная почта, пароль, ошибка в состоянии? Если они у вас есть, то, к сожалению, это не совсем ответ. Дайте вам подсказку: наша форма может находиться только в одном состоянии за раз.
Итак, давайте теперь сравним ваше второе предположение с моим:
Форма имеет состояние idle
, dataFilling
, emailError
, passwordError
, pending
, resolved
и rejected
. Давайте разберем их:
idle
: состояние формы при первой установкеidle
- ›dataFilling
: этот переход происходит, когда пользователь начинает вводить адрес электронной почты и пароль.dataFilling
- ›emailError
: когда поле адреса электронной почты размыто, а адрес электронной почты недействителен. Обратный переход (emailError
- ›dataFilling
) происходит, когда пользователь изменяет электронную почту.dataFilling
- ›passwordError
: когда поле пароля размыто и пароль недействителен. Обратный переход аналогичен электронной почте.dataFilling
- ›pending
: когда пользователь нажимает кнопку« Отправить »и делает запрос APIpending
- ›resolved
: после успешной аутентификации пользователяpending
- ›rejected
: когда сервер отклоняет запрос аутентификации
Давайте воплотим эти идеи в код:
machine.js
import { Machine, assign } from "xstate";
export const formMachine = Machine({
initial: "idle",
context: {
email: "",
password: ""
},
states: {
idle: {},
dataFilling: {},
emailError: {},
passwordError: {},
pending: {},
resolved: {},
rejected: {}
}
});
export const formOptions = {};
В XState замечательно то, что он инструмент визуализации. Если мы поместим константу formMachine
в визуализатор, мы сможем получить общую картину на нашей текущей машине форм:
Хотя наша машина на данный момент довольно проста, она будет доработана, когда мы постепенно добавим больше переходов, событий и действий.
Подключите Form Machine к компоненту React
Сначала нам нужно выяснить, какие элементы нашей машины форм должны быть привязаны к компоненту пользовательского интерфейса.
- Контекст:
email
иpassword
находятся в контексте машины формы. Нам нужно синхронизировать эти два с соответствующими текстовыми полями. - События: отправляйте события
FIELD_CHANGE
иFIELD_BLUR
в обработчики событийonChange
иonBlur
текстовых полей.
Наш исходный компонент формы React может быть просто:
App.js
export default function App() {
const onChange = e => {
};
const onBlur = e => {
};
return (
<form className="App" style={{ display: "flex", flexDirection: "column" }}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
name="email"
onChange={onChange}
onBlur={onBlur}
/>
<label htmlFor="password" style={{ marginTop: 16 }}>
Password{" "}
</label>
<input
id="password"
name="password"
type="password"
onChange={onChange}
onBlur={onBlur}
/>
</form>
);
}
Чтобы реализовать конечный автомат формы в компоненте React, мы могли бы использовать хук useMachine
из XState:
App.js
import { formMachine, formOptions } from "./machine";
export default function App() {
const [state, send] = useMachine(formMachine, formOptions);
// Now we could retrieve current's state via state.value and context via state.context
// and send events via send method 🥂
...
}
Теперь давайте получим адрес электронной почты и пароль и синхронизируем их с нашими текстовыми полями:
export default function App() {
...
const { email, password } = state.context;
...
return (
...
<input value={email} ... />
<input value={password} ... />
...
)
}
Отправлять события текстовых полей в конечный автомат
Затем нам нужно обработать события ввода: onChange
и onBlur
:
const onChange = e => {
const { name, value } = e.target;
send({
type: "FIELD_CHANGE",
field: name,
value
});
};
const onBlur = e => {
send({
type: "FIELD_BLUR",
field: e.target.name
});
};
С помощью этих обработчиков каждый раз, когда текстовые поля вызывают события onChange
и onBlur
, мы отправляем события типов FIELD_CHANGE
и FIELD_BLUR
на нашу машину. Давайте разберемся с этими двумя в formMachine
:
machine.js
export const formMachine = Machine({
...
context: {
email: "",
password: ""
},
states: {
idle: {
on: {
FIELD_CHANGE: {
target: "dataFilling",
actions: "changeField"
}
}
},
dataFilling: {
on: {
FIELD_CHANGE: {
actions: "changeField"
}
}
},
...
}
});
export const formOptions = {
actions: {
changeField: assign((context, event) => ({
[event.field]: event.value
})
}
}
Чтобы обработать событие в конечном автомате, мы используем ключ on
в объекте состояния, в котором находится форма. Когда форма находится в состоянии idle
, наше событие FIELD_CHANGE
изменит состояние на dataFilling
и вызовет действие changeField
.
Действие указано в formOptions
для удобства чтения. Мы могли бы полностью встроить его. В каждом действии XState у нас есть доступ к объектам context
и event
. В этом случае мы используем field
и value
из события FIELD_CHANGE
для изменения контекста email
или password
соответственно.
Обратите внимание, что нам нужно обработать событие FIELD_CHANGE
после того, как произошел переход с idle
на dataFilling
. Это потому, что событие и его действия будут обработаны, только если мы укажем его в определенном состоянии.
При такой настройке вы должны увидеть, что значения полей электронной почты и пароля обновляются при вводе.
Теперь давайте обработаем событие FIELD_BLUR
на машине: machine.js
import { isEmail } from "validator";
export const formMachine = Machine({
...,
dataFilling: {
on: {
...,
FIELD_BLUR: [
{ target: "emailError", cond: "isInvalidEmail" },
{ target: "passwordError", cond: "isInvalidPassword" }
]
}
},
emailError: {
on: {
FIELD_CHANGE: {
target: "dataFilling",
actions: "changeField"
}
}
},
passwordError: {
on: {
FIELD_CHANGE: {
target: "dataFilling",
actions: "changeField"
}
}
},
})
export const formOptions = {
guards: {
isInvalidEmail: (context, event) =>
!isEmail(context.email) && event.field === "email",
isInvalidPassword: (context, event) =>
context.password.length < 6 && event.field === "password"
},
actions: {
...
}
};
Мы могли видеть, что событие FIELD_BLUR
изменит состояние с dataFilling
на emailError
или passwordError
в зависимости от того, неверен ли адрес электронной почты или пароль. Эти проверки выполняются благодаря isInvalidEmail
и isInvalidPassword
функциям условий, которые определены в formOptions
.
Каждая функция условия - это функция, принимающая context
и event
как два параметра и возвращающая либо true
, либо false
. Если адрес электронной почты недействителен, мы меняем состояние на emailError
. В этом состоянии, если пользователь вводит в поле электронной почты и, таким образом, отправляет FIELD_CHANGE
событие на машину, мы перемещаем состояние обратно на dataFilling
и одновременно запускаем действие changeField
.
Теперь, когда у нас есть состояние emailError
и passwordError
, давайте отобразим сообщения об ошибках в компоненте: App.js
export default function App() {
...
const hasEmailError = state.matches("emailError");
const hasPasswordError = state.matches("passwordError");
...
return (
...
{hasEmailError && (
<span style={styles.errorMessage}>Invalid email</span>
)}
...
{hasPasswordError && (
<span style={styles.errorMessage}>
Password must be at lest 6 character long
</span>
)}
);
}
Обработка события отправки формы
Мы могли бы отправить FORM_SUBMIT
событие при отправке формы: App.js
...
const onSubmit = e => {
e.preventDefault();
send("FORM_SUBMIT");
};
...
И обработайте событие в машине формы: machine.js
dataFilling: {
on: {
...,
FORM_SUBMIT: [
{ target: "emailError", cond: "isInvalidEmail" },
{ target: "passwordError", cond: "isInvalidPassword" },
{ target: "pending" }
]
}
}
Сначала нам нужно убедиться, что пользователь ввел действительные учетные данные. В противном случае мы меняем состояние на emailError
и passwordError
. Состояние перемещается в pending
только тогда, когда и адрес электронной почты, и пароль верны.
Нам также необходимо обновить наши условия защиты:
guards: {
isInvalidEmail: (context, event) => {
if (event.field) {
return !isEmail(context.email) && event.field === "email";
} // This is to make sure we move the form to correct error state
// (either email or password depending on which field got blurred)
return !isEmail(context.email) // This is used in form submission
// we want to make sure both fields are valid,
// regardless of which got blurred;
},
// Same thing with password
isInvalidPassword: (context, event) => {
if (event.field) {
return context.password.length < 6 && event.field === "password";
}
return context.password.length < 6;
}
}
Сделайте запрос API для аутентификации пользователя
Давайте смоделируем запрос API входа в систему, представив, что наш «сервер» вернет успешный ответ через 1 секунду:
machine.js
const mockApiLogin = () => {
return new Promise((resolved, rejected) => {
setTimeout(() => resolved({ message: "Success" }), 1000);
});
};
После того, как событие FORM_SUBMIT
было отправлено на машину с допустимыми значениями полей, состояние изменится с dataFilling
на pending
. Следовательно, нам нужно сделать запрос API в состоянии pending
:
machine.js
export const formMachine = Machine({
...
pending: {
invoke: {
src: "login",
onDone: "resolved",
onError: "rejected"
}
},
resolved: {},
rejected: {}
})
export const formOptions = {
...
services: {
login: (context, event) => mockApiLogin()
}
}
...
Если наш запрос API завершится успешно, что мы намеренно высмеяли, мы ожидаем, что состояние изменится с pending
на resolved
. Поместим лог в наш компонент: console.log("State: ", state.value);
и протестируем поток. Вы должны увидеть такой результат:
Поздравляю! Мы только что создали весь поток входа в систему с конечным автоматом. Если вы посмотрите на компонент пользовательского интерфейса, вся логика, содержащаяся в форме, передается машине формы для обработки. Мы полагаемся только на то, в каком состоянии находится форма, чтобы отображать определенные компоненты, такие как сообщения об ошибках. И убедитесь, что мы отправляем в машину правильные события.
Таким образом, этот компонент пользовательского интерфейса устойчив к изменениям логики, когда мы переходим от логической обработки снизу вверх к конечному автомату. Отслеживание ошибок также намного проще, поскольку мы точно знаем, где это могло произойти: наш конечный автомат формы. Если что-то пойдет не так, мы можем сначала определить, в каком состоянии находится форма, и проверить, правильно ли обрабатываются или отправляются какие-либо события или действия.
Никаких локальных состояний компонентов, никакой логики, обрабатываемой прямо из обработчиков событий UI-компонентов - ›Никаких ошибок 🚫🐞
TL;DR
- Используя конечный автомат, мы гарантируем, что наши модели, такие как компоненты пользовательского интерфейса, могут одновременно находиться только в одном состоянии. В определенном состоянии мы также явно обрабатываем определенные типы событий и запускаем определенные действия для каждого события.
- Использование конечного автомата требует изменения наших ментальных моделей обработки состояния. Больше нет обработчиков состояния снизу вверх, где мы обновляем состояние в обработчике событий.
- Чтобы добавить конечный автомат к вашей модели, вам сначала нужно определить состояния, в которых может находиться модель. Затем для каждого события, отправляемого на машину, мы указываем следующее состояние вместе с действиями, которые мы хотим, чтобы событие запускалось.
- Поскольку состояние и все события, действия обрабатываются на машине, компоненты пользовательского интерфейса более устойчивы к изменениям логики.
- Все коды вы можете найти в этом проекте CodeSandbox.
Это конец этого блога. Я хотел бы услышать ваши идеи и мысли 🤗 Пожалуйста, запишите их ниже 👇👇👇
✍️ Автор
Винь Ле @ vinhle95
👨🏻💻🤓🏋️🏸🎾🚀
Деловой человек, ученик на протяжении всей жизни, любитель технологий и разработчик программного обеспечения.
Изначально этот блог опубликован по адресу «https://blog.vinhlee.com/ фактическиx-state.