Валидация сложных форм с помощью Constraint Validation API
- воскресенье, 4 мая 2025 г. в 00:00:06
DOM предоставляет API для валидации пользовательского ввода. Вообще говоря, мы им пользуемся часто, например:
<input type="email" required />
Поведение по умолчанию работает следующим образом: если у нас есть форма (<form>
) с несколькими полями ввода, то при изменении любого из них происходит валидация введённых данных. При вызове submit
для полей, которые содержат атрибуты валидации и не прошли её, отображаются подсказки об ошибках. Отправка формы (submit
) не произойдёт, пока все данные не станут валидными.
Отключить эти подсказки (то есть валидацию) можно с помощью атрибута novalidate
. Если добавить его к тегу <form>
, это отключит валидацию для всей формы. Если же применить атрибут к отдельному полю, будет отключена только его валидация.
Набор доступных атрибутов валидации зависит от значения атрибута type
. Полный перечень допустимых комбинаций представлен в таблице.
Преимущества:
Встроенная интернационализация. Подсказки отображаются на языке, выбранном в настройках операционной системы, и имеют понятное содержание.
Для стилизации элемента, не прошедшего валидацию, доступны псевдоклассы :invalid
, :out-of-range
, :empty
.
Для обеспечения хорошего уровня доступности (accessibility) не требуется дополнительных действий
Можно применять несколько правил валидации, каждое из которых будет сопровождаться соответствующим текстом подсказки.
Можно задавать собственный текст подсказки (этим мы займемся позже).
Недостатки:
Нельзя кастомизировать подсказки.
С динамической валидацией (когда валидность одного поля зависит от значения другого) не всё работает гладко.
Давайте попробуем обойти эти недостатки и применить встроенный механизм валидации для сложной формы.
Требования к форме
В нашей вымышленной форме оплаты должно быть пять полей:
Емэйл для получения чека. Допустимы только адреса вида @habr.com
Выбор валюты
Сумма платежа
выбранной валюты
времени суток
Пароль
длина от 5 до 12 символов
должен содержать символ рубля (₽), если выбрана валюта "Российский рубль"
должен содержать символ доллара ($) для всех других валют
Повтор пароля. Должен полностью совпадать с введённым паролем.
Реализация
const CurrenciesEnum = {
RUR: 'RUR',
USD: 'USD'
}
const Currencies = Object.keys(CurrenciesEnum);
const MIN_RUR_SUM = 1000
const MAX_RUR_SUM = 150000
const MIN_USD_SUM = 100
const MAX_USD_SUM = 5000
class PaymentForm {
// тут будем хранить информацию об шибках,
// как пара свойство => текст ошибки
errors = new Map;
email = '';
currency = CurrenciesEnum.RUR;
sum = '';
password = '';
passwordCopy = '';
// вернет минимальную сумму оплаты на текущий момент,
// с учетом выбранной валюты
get minSum() {
const hour = new Date().getHours()
switch (this.currency) {
case 'RUR':
return hour > 12 ? MIN_RUR_SUM : MIN_RUR_SUM + 500;
case 'USD':
return hour > 12 ? MIN_USD_SUM : MIN_USD_SUM + 5;
default:
return 0
}
}
// вернет максимальную сумму оплаты с учетом валюты
get maxSum() {
switch (this.currency) {
case 'RUR':
return MAX_RUR_SUM;
case 'USD':
return MAX_USD_SUM;
default:
return Number.MAX_VALUE
}
}
// вернет паттерн для пароля с учетом выбранной валюты
get passwordPattern() {
return `${CurrenciesSymbols[this.currency]}{1}`
}
// будем дисейблить копию пароля пока пароль невалиден
get passwordCopyDisabled() {
return this.password === '' || this.errors.has('password');
}
}
function Form() {
return (
<form>
<input
required
type="email"
name="email"
pattern='@habr.com'
/>
<select name="currency" value={paymentForm.currency}>
{Currencies.map(currency => (
<option value={currency} key={currency}>
{currency}
</option>
))}
</select>
<input
value={paymentForm.sum}
min={paymentForm.minSum}
min={paymentForm.maxSum}
type="number"
name="sum"
/>
<input
required
type="password"
name="password"
pattern={paymentForm.passwordPattern}
minLength="5"
maxLength="12"
/>
<input
required
type="password"
name="passwordCopy"
// используем хак
// в качестве паттерна подставим введенный пароль
pattern={paymentForm.password}
// если пароль не валиден, дисейблим это поле
disabled={paymentForm.errors.has('password')}
/>
</form>
)
}
В текущем виде форма уже работоспособна, но отсутствует динамическая проверка. Мы узнаём о некорректно заполненных полях только при отправке (submit). Давайте это исправим.
Для реализации динамической проверки создадим класс FormValidator, который будет:
отслеживать состояние формы
генерировать событие invalid в соответствующий момент
Класс принимает необязательный параметр schema. Эта схема будет использоваться для:
настройки пользовательских текстов подсказок
изменения интерфейса ValidityState (с некоторыми корректировками):
Удалим свойства valid и customError
Изменим типы значений с boolean на string
Сделаем все свойства необязательными
Такой подход позволяет задавать кастомные тексты ошибок только для нужных нам проверок.
type ValidationErrors = Record<keyof Omit<ValidityState, 'valid' | 'customError'>, string>
type ValidationSchema = Record<string, Partial<ValidationErrors>>
Логику динамической проверки мы вынесем в отдельный класс FormController, а публичный API сделаем в виде единственного метода validate
.
Особенности работы метода:
Принимает в качестве аргумента любой HTMLElement (это позволяет обойтись без оборачивания элементов в тег <form>
, хотя это не рекомендуется)
При вызове без аргументов выполняет очистку ресурсов (этот сценарий предполагает, что форма больше не существует).
class FormValidator {
#controller = new FormController();
constructor(schema?: ValidationSchema) {
this.#controller.schema = schema || {};
}
validate = (element: HTMLElement | null) => {
if (!element) {
this.#controller.form?.removeEventListener('change', this.#controller);
this.#controller.form?.removeEventListener('input', this.#controller)
this.#controller.form = null;
} else {
this.#controller.form = element;
element.addEventListener('change', this.#controller);
element.addEventListener('input', this.#controller)
}
};
}
Теперь реализуем FormController. Поскольку он реализует также интерфейс EventHandler, в коде выше мы передали экземпляр FormController вторым аргументом в метод addEventListener
, что легально )).
class FormController {
form: HTMLElement | null = null;
schema: ValidationSchema = {};
#debounce: any
handleEvent() {
clearTimeout(this.#debounce);
this.#debounce = setTimeout(this.#checkValidity, 50);
}
#checkValidity = () => {
if (!this.form) return;
this.form.querySelectorAll("*:invalid")
.forEach((element: HTMLInputElement) => {
this.#setValidationMessage(element);
element.checkValidity();
});
};
#setValidationMessage(target: HTMLInputElement) {
const { validity, name } = target;
if (validity.valid) return target.setCustomValidity("");
for (const type in validity) {
if (type === "valid" || type === "customError") {
target.setCustomValidity("");
break;
}
if (validity[type] === true) {
const schema = this.schema[name];
if (schema) {
const message = schema[type];
if (message) {
target.setCustomValidity(message);
} else {
target.setCustomValidity("");
}
} else {
target.setCustomValidity("");
}
break;
}
}
}
}
Разберём его функциональность по порядку:
Метод handleEvent
Вызывается для событий input
и change
С некоторой задержкой вызывает метод #checkValidity
В vanilla-реализации задержку можно было бы убрать, но в React-реализации задержка необходима для гарантии обновления состояния
Метод #checkValidity
Находит невалидные элементы select
и input
внутри формы
Для каждого невалидного элемента:
Вызывает #setValidationMessage
Вызывает метод checkValidity()
элемента, что приводит к генерации события invalid
Метод #setValidationMessage
Анализирует состояние ValidityState
элемента
Находит возникшую ошибку
Если для данного типа ошибки в схеме задан текст:
Устанавливает его как validationMessage
В противном случае:
Оставляет сообщение по умолчанию
Для интеграции формы и FormValidator в React оптимально использовать ref
. Чтобы продемонстрировать работу с текстами ошибок, добавим простую схему валидации.
const schema = {
email: {
patternMismatch: 'Введите адрес электронной почты на хабре'
},
sum: {
rangeUnderflow: 'Слишком маленькая сумма, нам нужно больше',
rangeOverflow: 'Слишком много денег, мы столько не потратим'
},
password: {
// Динамически подставляем в текст ошибки нужный символ
get patternMismatch() {
return `Добавьте символ "${paymentForm.currency}" в пароль, это обязательно`
}
}
}
const validator = new FormValidator(schema);
function Form() {
return (
<form ref={validator.validate}>
// ...
</form>
)
}
Осталось реализовать отображение сообщений об ошибках. Для этого добавим в PaymentForm метод onInvalid
.
Важно учитывать особенность события invalid
- оно не всплывает, поэтому обработчики нужно навешивать на каждое поле ввода. Однако благодаря семантически правильной разметке (все поля имеют атрибут name
) мы сможем повторно использовать один метод для всех полей.
class PaymentForm {
errors = new Map;
onInvalid(event) {
this.errors.set(
event.target.name, // название поля
event.target.validationMessage // текст ошибки
)
}
}
Также реализуем метод onChange
:
class PaymentForm {
onChange(event) {
let value = event.target.value; // достаем значение
// valueAsNumber будет NaN для всех текстовых полей ввода,
// и валидным числом для type number, date[-*] и range
if (!isNaN(event.target.valueAsNumber)) {
// так в sum мы запишем число
value = event.target.valueAsNumber;
}
this[event.target.name] = value;
}
}
Я опущу этап добавления контейнеров для отображения ошибок рядом с полями ввода и подключения обработчиков событий change
и invalid
. Полную реализацию можно посмотреть в песочнице (sandbox).
Готово! Мы реализовали достаточно сложную валидацию формы с использованием Constraint Validation API. В текущей реализации текст ошибки отображается рядом с полем ввода, но мы также можем использовать встроенные всплывающие подсказки. Они будут:
динамически обновляться
автоматически показываться при каждом вводе
срабатывать при невалидных значениях
Для этого в классе FormController нужно заменить вызов checkValidity()
на reportValidity()
в методе #checkValidity
.
#checkValidity = () => {
if (!this.form) return;
this.form.querySelectorAll("*:invalid")
.forEach((element: HTMLInputElement) => {
this.#setValidationMessage(element);
// покажет встроенную подсказку
element.reportValidity();
});
};
Форма тут: https://stackblitz.com/edit/vitejs-vite-xyuagd8v
Если хотите поэкспериментировать с этой реализацией в песочнице, FormValidator опубликован в npm, чтобы не копировать код вручную
Выводы
Constraint Validation API надёжное решение с полной поддержкой во всех современных браузерах.