Конечные автоматы

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

Свет имеет 2 конечных состояния: включен и выключен. И его состояние должно быть включено или выключено. Невозможно включить и выключить одновременно. Когда он выключен, и мы хотим его включить, и наоборот, мы просто нажимаем выключатель на стене. Подумайте о включении и выключении в качестве входов для переключения состояния света.

Конечный автомат (лампочка) - это абстрактная модель, которая может одновременно находиться только в одном состоянии (включено или выключено). Переход из одного состояния в другое (Светлый 🌞 - ›Темный ☽) - это переход. Этот переход происходит в ответ на определенные вводы (включить или выключить 🎛)

Вы можете узнать больше об идеях и концепциях конечного автомата в Документации XState или Википедии.

Конечный автомат в форме входа в систему

Давайте возьмем эти идеи и изложим их в форме. Представьте, что мы создаем форму входа в систему. Пользователям потребуется ввести свой адрес электронной почты, пароль и нажать кнопку «Отправить», чтобы сделать запрос API, удостоверяющий эти учетные данные на сервере. Фактически, нам также необходимо убедиться, что адрес электронной почты действителен, а пароль должен содержать не менее 6 символов.

Создать конечный автомат формы

Наша первая задача - выяснить, какие состояния может иметь форма. Как ты думаешь об этом?

Уже получили ответы? У вас есть электронная почта, пароль, ошибка в состоянии? Если они у вас есть, то, к сожалению, это не совсем ответ. Дайте вам подсказку: наша форма может находиться только в одном состоянии за раз.

Итак, давайте теперь сравним ваше второе предположение с моим:

Форма имеет состояние idle, dataFilling, emailError, passwordError, pending, resolved и rejected. Давайте разберем их:

  • idle: состояние формы при первой установке
  • idle - ›dataFilling: этот переход происходит, когда пользователь начинает вводить адрес электронной почты и пароль.
  • dataFilling - ›emailError: когда поле адреса электронной почты размыто, а адрес электронной почты недействителен. Обратный переход (emailError - ›dataFilling) происходит, когда пользователь изменяет электронную почту.
  • dataFilling - ›passwordError: когда поле пароля размыто и пароль недействителен. Обратный переход аналогичен электронной почте.
  • dataFilling - ›pending: когда пользователь нажимает кнопку« Отправить »и делает запрос API
  • pending - ›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.

Сказать привет 🙌 на:

🔗 Twitter

🔗 Средний

🔗 LinkedIn

🔗 Github

🔗 Персональный сайт