Симптом знакомый: почта в форме стала зелёной, пользователь нажал «Сохранить», а сервер вернул ошибку формата или занятости. Ещё хуже, когда ответ приходит, но текст попадает в общий баннер, а не к полю. Человек исправляет значение наугад, повторяет запрос и может создать дубль. Причина обычно не в одном регулярном выражении: у клиента, сервера и представления ошибки разные правила и разные владельцы состояния. Цена ошибки — лишняя отправка формы и неверное решение пользователя.
В апреле 2019 я бы не пытался строить «универсальный валидатор». Для одной формы достаточно зафиксировать короткий контракт: какие ограничения браузер проверяет сразу, какие условия знает только сервер, в каком виде сервер возвращает ошибки и кто имеет право менять состояние поля. Ниже учебный вариант без привязки к фреймворку. Он показывает маршрут проверки; он не является результатом запуска на чужом API или браузерной трассой.
Сначала разделяем три вида проверки
Клиентская проверка нужна, чтобы не отправлять пустую почту или строку с очевидно неверной формой. HTML уже знает часть ограничений: required, type="email", minlength, pattern. У контрола есть validity, а checkValidity() отвечает на конкретный вопрос: проходит ли элемент его ограничения. Это удобный ранний фильтр, но не источник истины о пользователе, правах, занятости логина или правилах, которые меняются на сервере.
Серверная проверка владеет данными и бизнес-условием. Если API считает, что адрес уже связан с другим аккаунтом, браузер не может опровергнуть это своим input[type=email]. Представление, в свою очередь, владеет тем, где сообщение видно: у поля, у формы или в статусе отправки. Ошибка возникает именно на этой границе, когда JSON от сервера складывают в один текст «Не удалось сохранить», а компонент уже не знает, какой input пометить.
| Слой | Что проверяет | Чего не обещает | Проверяемый результат |
|---|---|---|---|
| HTML-контрол | required, формат email, длину и pattern | Занятость адреса, права, транзакцию | input.validity.valid и понятная локальная подсказка |
| Клиентский код | Порядок состояний, актуальность ответа, привязку поля к ошибке | Достоверность данных в базе | У ошибки есть ключ поля, а старый ответ не меняет новый ввод |
| API | Нормализацию, занятость, права и правила сохранения | Как экран озвучит текст | Структурированный ответ с кодом и ключом поля |
| Разметка | Label, описание, видимость ошибки, семантику invalid | Проверку бизнес-правила | Ошибка доступна зрительно и связана с нужным контролом |
Такое деление сразу уменьшает расхождение. Не нужно копировать серверное правило в JavaScript, если клиенту достаточно проверить непустое значение и форму строки. Но имя поля и код ошибки должны быть стабильны. Сервер может изменить человеческий текст по локали, а email_taken и ключ email остаются данными, по которым интерфейс выбирает место вывода.
Минимальный договор между формой и API
Практичный ответ ошибки не обязан повторять спецификацию целиком. Важно, чтобы в нём не смешивались поле и общий сбой. В этом примере массив fields хранит ошибки, которые можно показать рядом с input, а form — ошибку, не принадлежащую конкретному полю: например, конфликт состояния формы. HTTP-статус и общая структура ответа — договор API; показанный ключ fields — локальное решение команды, а не поле, навязанное браузером.
// Пример ответа API при POST /api/account.
// Это контракт приложения, а не встроенный формат браузера.
{
"code": "VALIDATION_FAILED",
"fields": {
"email": [
{ "code": "email_taken", "message": "Этот адрес уже используется" }
]
},
"form": []
}
На клиенте не стоит искать текстом «адрес» или «занят». Нужен небольшой адаптер, который принимает этот контракт и отдаёт одну карту ошибок. Если сервер прислал неизвестный ключ, адаптер не должен silently приклеивать его к первому полю. Его лучше оставить в form, записать в диагностический лог проекта и добавить явную обработку после согласования контракта. Так опечатка e-mail вместо email не превратится в ложное зелёное состояние.
function mapServerErrors(payload, knownFields) {
var mapped = { fields: {}, form: [] };
var fieldErrors = payload && payload.fields ? payload.fields : {};
Object.keys(fieldErrors).forEach(function (name) {
var first = fieldErrors[name] && fieldErrors[name][0];
var message = first && first.message;
if (knownFields.indexOf(name) === -1 || !message) {
mapped.form.push('Сервер вернул ошибку без известного поля');
return;
}
mapped.fields[name] = message;
});
return mapped;
}
var errors = mapServerErrors(apiPayload, ['email', 'password']);
// errors.fields.email === 'Этот адрес уже используется'
У этого кода есть намеренное ограничение: он не решает локализацию, несколько сообщений на поле или вложенные массивы. Для конкретной формы сначала договоритесь, нужен ли один первый текст или список. Если API всегда отдаёт список, не обрезайте его случайно; если продукту нужен один короткий совет, пусть сервер или отдельный formatter выбирает его явно. Главное — не передавать сырое сообщение в HTML как разметку: текст ошибки должен остаться текстом.
Разметка: ошибка должна принадлежать полю
Красная рамка сама по себе не объясняет проблему. У поля должен быть видимый label, постоянная подсказка и отдельный контейнер ошибки. aria-describedby связывает input с описывающими элементами по ID. Когда состояние невалидно, добавляем aria-invalid="true" и ссылку aria-errormessage на видимый текст. В WAI-ARIA эти атрибуты работают вместе: сообщение не нужно прятать от человека, который пользуется ассистивной технологией.
<label for="email">Почта</label>
<input
id="email"
name="email"
type="email"
required
autocomplete="email"
aria-describedby="email-hint email-error"
aria-errormessage="email-error"
aria-invalid="true"
value="user@example.test"
>
<p id="email-hint">Укажем адрес для входа.</p>
<p id="email-error" role="alert">Этот адрес уже используется</p>
В валидном состоянии aria-invalid убираем или ставим в false, а контейнер ошибки не оставляем с пустой ролью alert. Если строка ошибки меняется динамически, короткое уведомление может быть живой областью, но не надо превращать каждое нажатие клавиши в срочное объявление. Для проверки формы на отправке достаточно показать текст у поля и, при необходимости, дать краткий общий статус. Фокус переносим только по осознанному правилу интерфейса, обычно на первое невалидное поле после submit.
Ниже не снимок Accessibility tree из браузера. Это ожидаемая семантическая структура той разметки, которую нужно проверить в DevTools и реальным скринридером проекта. Такой список полезен до запуска: он показывает, какое доказательство искать, и не выдаёт ожидание за измерение.
| Узел | Имя или состояние | Откуда берётся | Что проверять в реальном браузере |
|---|---|---|---|
| Текстовое поле | Имя «Почта», значение user@example.test, invalid | label и aria-invalid | Поле доступно по Tab и имеет имя label |
| Описание | «Укажем адрес для входа» | aria-describedby | Подсказка связана с тем же ID, что указан у input |
| Ошибка | «Этот адрес уже используется» | aria-errormessage и видимый контейнер | Текст не скрыт и относится к email, а не к соседнему input |
| Кнопка | «Сохранить» и её доступное состояние | Нативный button | Клавиатурная отправка не блокирует возможность исправить поле |
Поздний ответ не имеет права менять новый ввод
Другая частая причина «прыгающей» ошибки — асинхронная проверка. Пользователь ввёл ivan, запрос ушёл на сервер; затем он быстро исправил на ivanka. Если первый ответ, «логин занят», возвращается последним, наивный then() запишет красную ошибку поверх нового значения. Скорость сети не даёт порядка, на который можно опереться. Владельцем актуальности должен быть идентификатор версии поля или запроса.
Отмена через AbortController полезна, чтобы не тратить работу, когда пользователь продолжил ввод. Но отмена транспорта не заменяет защиту состояния: к моменту abort ответ уже мог разрешиться, библиотека могла не использовать signal, а локальная проверка вообще не имеет сетевого запроса. Поэтому сначала сравниваем номер запроса, а затем при наличии Fetch добавляем abort как оптимизацию.
var lastRequestId = 0;
function checkLogin(value, checkAvailability) {
var requestId = lastRequestId + 1;
lastRequestId = requestId;
render({ value: value, phase: 'checking', error: '' });
return checkAvailability(value).then(function (answer) {
if (requestId !== lastRequestId) return; // ответ относится к старому вводу
render({
value: value,
phase: answer.available ? 'valid' : 'invalid',
error: answer.available ? '' : 'Этот логин уже занят',
});
});
}
Номер запроса должен жить рядом с состоянием конкретного поля или формы, а не в глобальной переменной всего сайта. Для нескольких строк таблицы, двух вкладок редактора или нескольких экземпляров компонента глобальный счётчик снова создаст чужое влияние. В простом модуле это замыкание; во фреймворке — состояние экземпляра. Критерий тот же: обработчик ответа проверяет, что он всё ещё работает с текущей версией значения.
Маршрут внедрения на одной форме
- Выписать поля формы и отдельно назвать: проверка браузера, проверка API, общий сбой формы. Не начинаем с копирования всей серверной логики в JavaScript.
- Согласовать с API стабильные ключи полей и коды ошибок. Для каждого ключа выбрать место в UI; неизвестный ключ не приклеивать к произвольному input.
- Добавить нативные ограничения там, где они честны:
required, тип, длина, pattern. Проверить, чтоdisabledне исключает нужное поле из constraint validation по ошибке. - Сделать у каждого поля label, описание и контейнер ошибки с устойчивыми ID. На невалидном состоянии выставлять
aria-invalidи показывать текст, а не только менять цвет. - Для асинхронной проверки хранить номер актуального запроса. Создать fixture, где старый ответ приходит позже нового, и ожидать, что он не меняет состояние.
- На submit проверить сначала HTML-ограничения, затем ответ API, затем фокус и текст первого поля с ошибкой. Готовность — ошибка видна у правильного поля и исчезает только после новой валидной версии значения.
Что проверить до выпуска
Нужно проверить не один счастливый submit, а границы. Отправьте пустое поле, неверный формат, ошибку API с известным ключом, ошибку API с неизвестным ключом и два ответа в обратном порядке. Проверьте клавиатуру: label не потерян, ошибка не видна только по цвету, а после submit понятно, что именно требует исправления. Если форма живёт в модальном окне, дополнительно проверьте, что фокус не уходит под него при появлении текста ошибки.
- HTML-валидация может отличаться между браузерами в тексте встроенного сообщения. Если нужен единый текст, используйте свою видимую ошибку, но не отменяйте полезные нативные ограничения без причины.
- Сервер всё равно проверяет вход. Нельзя считать
checkValidity()защитой API: запрос можно составить вне вашей страницы. - Асинхронную проверку не стоит запускать на каждую букву без порога и задержки. Сначала убедитесь, что локальный формат уже проходит; затем используйте debounce и защиту версии.
- Не ставьте
role="alert"на целую форму или длинный список. Это даст шум вместо понятного сообщения; у поля нужен конкретный текст. - Ответ API с несколькими ошибками требует решения о порядке. Полезно сохранить порядок полей формы, а не полагаться на порядок ключей объекта.
Итог
Если сервер отказывается сохранять значение, которое клиент уже сделал зелёным, проблема не лечится новой регуляркой. Сначала отделяем локальное ограничение от условия базы, затем фиксируем ключ поля в контракте ошибки, привязываем видимый текст к input и не даём старому ответу менять новый ввод. После этого у формы есть проверяемый результат: известно, кто владеет каждым правилом, и ошибка оказывается у того поля, которое пользователь действительно может исправить.
Проверяемые источники
- HTML Standard: Constraints and Constraint Validation API — модель ограничений формы, validatable-контролы,
validity,setCustomValidity(),checkValidity()иreportValidity() - HTML Standard: Form submission — отправка формы — отдельный алгоритм браузера; клиентские ограничения не подменяют проверку на сервере
- WAI-ARIA 1.1: aria-describedby — связь контрола с одним или несколькими элементами описания по ID
- WAI-ARIA 1.1: aria-errormessage —
aria-errormessageсвязан сaria-invalid; актуальный текст ошибки должен быть доступен пользователю - WAI-ARIA 1.1: alert role — семантика срочного, но не переносящего фокус сообщения; применять только к краткому изменению статуса
- DOM Standard: AbortController — контроллер создаёт
AbortSignalи посылает ему abort; это отмена транспорта, а не проверка актуальности состояния сама по себе - Fetch Standard — модель запроса, ответа и интеграция с abort signal для клиентов, поддерживающих Fetch