{ "index": 315, "slug": "editorial-2019-04-practice-forms-validation", "title": "Валидация формы без ложного успеха: поле, API и устаревший ответ", "excerpt": "Браузер считает поле корректным, а API отклоняет его или возвращает ошибку уже для старого значения. Разбираем границы проверки, контракт ошибок, доступную разметку и защиту от гонки ответов.", "contentHtml": "
Форма показывает зелёную почту, пользователь нажимает «Сохранить», а API отвечает 422. В другом варианте человек быстро меняет логин: новый ответ говорит «свободен», затем поздний ответ для старого значения рисует ошибку под новым. Сообщение иногда попадает в общий баннер и не объясняет, какое поле исправлять. Цена ошибки — лишний запрос, потерянное введённое значение и неверное решение пользователя. Для регистрации или платежа это может означать отказ в корректной операции.
\nТезис простой: клиентская валидация ускоряет обратную связь, но не принимает бизнес-решение. Сервер проверяет данные снова. Интерфейс должен знать, к какому полю относится отказ и к какой версии значения он относится. Если эти границы не зафиксированы, новая регулярка не исправит расхождение.
\nHTML отсекает очевидное: пустое обязательное поле, неверный тип, длину и pattern. У контрола есть объект validity. Методы checkValidity() и reportValidity() помогают проверить нативные ограничения формы. Это полезный ранний фильтр. Он не знает, занят ли логин, есть ли у пользователя право на действие или изменилось ли состояние записи на сервере.
Клиентский код собирает состояния и решает, где показать результат. Он может очистить старую серверную ошибку после ввода, дождаться проверки доступности и не применить ответ старой версии. Но он не должен объявлять значение принятым только потому, что локальная проверка прошла. Запрос можно отправить вне страницы, а правила базы меняются независимо от JavaScript.
\nAPI владеет нормализацией и бизнес-условиями. Ему не следует возвращать только строку «что-то не так»: экрану будет некуда её привязать. У ошибки нужен стабильный ключ поля и код. Человеческий текст остаётся текстом, а не идентификатором маршрутизации.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Поле зелёное, API отвечает 422 | Клиент проверил формат, сервер — бизнес-условие | Сравнить локальные ограничения с контрактом ответа | Показать серверную ошибку у поля и оставить сервер источником истины |
| Ошибка относится к соседнему полю | Код ищет текст или использует неизвестный ключ | Проверить карту field → error и список полей | Известные ключи привязать к input, неизвестные оставить общей ошибкой |
| Старый ответ стирает новый ввод | Обработчик применяет любой завершившийся запрос | Замедлить первый ответ и быстро изменить значение | Сравнивать номер запроса или версию значения до render |
| Ошибка видна только красной рамкой | Нет текста, label или связи с контролом | Проверить клавиатуру и accessibility tree | Добавить видимое сообщение, aria-invalid и связь по ID |
Для учебного примера представим ответ API при отправке формы. Это локальный договор приложения, а не встроенный формат браузера:
\n{\n \"code\": \"VALIDATION_FAILED\",\n \"fields\": {\n \"email\": [\n { \"code\": \"email_taken\", \"message\": \"Этот адрес уже используется\" }\n ]\n },\n \"form\": []\n}\nКлиенту нужен адаптер. Он принимает только известные имена полей и отделяет ошибку формы от ошибки input. Не ищите слово «занят» в сообщении. Не приклеивайте неизвестный ключ к первому полю: так ошибка API превращается в ложную подсказку.
\nfunction mapServerErrors(payload, knownFields) {\n var result = { fields: {}, form: [] };\n var fields = payload && payload.fields ? payload.fields : {};\n\n Object.keys(fields).forEach(function (name) {\n var item = fields[name] && fields[name][0];\n if (knownFields.indexOf(name) === -1 || !item || !item.message) {\n result.form.push('Сервер вернул ошибку без известного поля');\n return;\n }\n result.fields[name] = item.message;\n });\n\n return result;\n}\n\nvar errors = mapServerErrors(apiPayload, ['email', 'password']);\nКод учебный. Он выбирает первое сообщение и не решает локализацию, вложенные массивы или несколько ошибок на одном поле. В рабочей форме эти решения фиксируют отдельно. Текст сообщения вставляют как текст. Нельзя принимать его за HTML без явной, проверенной причины.
\nУ поля есть видимый label, постоянная подсказка и контейнер ошибки. aria-describedby связывает input с описанием по ID. Когда значение не прошло проверку, интерфейс добавляет aria-invalid=\"true\" и показывает сообщение. Если проект применяет aria-errormessage, его связывают с видимым элементом ошибки и используют только при невалидном состоянии.
<label for=\"email\">Почта</label>\n<input id=\"email\" name=\"email\" type=\"email\"\n required autocomplete=\"email\"\n aria-describedby=\"email-hint email-error\"\n aria-errormessage=\"email-error\"\n aria-invalid=\"true\">\n<p id=\"email-hint\">Укажем адрес для входа.</p>\n<p id=\"email-error\">Этот адрес уже используется</p>\nВ валидном состоянии ошибку скрывают способом, который не оставляет устаревший текст доступным как актуальное сообщение, а aria-invalid убирают или ставят в false. Красный цвет не заменяет текст. Фокус после submit можно перевести на первое проблемное поле, но при каждом вводе не нужно превращать сообщение в срочное объявление. role=\"alert\" применяют к короткому динамическому статусу, а не ко всей форме.
Рассмотрим учебный сценарий без настоящей сети. Пользователь вводит ivan, запрос получает задержку 30 мс. Затем ввод меняется на ivanka, второй запрос получает задержку 5 мс. Если первый ответ означает «занято», он придёт позже. Обработчик должен знать, что его запрос устарел.
var latestRequestId = 0;\n\nfunction checkLogin(value, checkAvailability) {\n var requestId = latestRequestId + 1;\n latestRequestId = requestId;\n render({ value: value, phase: 'checking', error: '' });\n\n return checkAvailability(value).then(function (answer) {\n if (requestId !== latestRequestId) {\n return { applied: false, ignored: 'stale-response' };\n }\n\n render({\n value: value,\n phase: answer.available ? 'valid' : 'invalid',\n error: answer.available ? '' : 'Этот логин уже занят'\n });\n return { applied: true };\n });\n}\nНомер запроса должен принадлежать конкретному экземпляру поля или форме. Глобальный счётчик всего сайта создаст взаимное влияние, если на странице появятся два независимых поля. В React, Vue или другом фреймворке принцип не меняется: обработчик сравнивает свой номер с актуальным состоянием владельца.
\nAbortController может отменить сетевую работу и сэкономить ресурсы. Он не заменяет проверку номера. Ответ мог уже разрешиться, транспорт может не поддерживать сигнал, а причина ошибки может быть локальной. Сначала защищают состояние, затем добавляют отмену как оптимизацию.
required, тип, длину и pattern. Не копируйте всю бизнес-логику в браузер.label, устойчивые ID подсказки и ошибки. Показывайте текст, а не только цвет; при ошибке обновляйте aria-invalid.Проверка доступности логина до сохранения не резервирует логин. Другой запрос может занять его между двумя операциями. Сервер обязан проверить условие в момент записи и вернуть ошибку поля. Нативный текст браузера может отличаться; единый текст можно показать самостоятельно, сохранив полезные ограничения HTML.
\nПроверка версии защищает состояние интерфейса, но не делает операцию сохранения идемпотентной и не отменяет транзакцию. Для зависимых полей нужно решить, очищает ли изменение одного поля результат другого. Для нескольких сообщений нужно определить порядок и способ показа. ARIA не исправляет отсутствие label, понятного текста или корректного фокуса.
\nПримеры выше учебные. Они не измеряют задержки, поддержку конкретного браузера или поведение реального API. Сценарий с задержками проверяет только порядок применения ответов. Разметку нужно проверить в целевом браузере, с клавиатурой и используемым скринридером.
\nФорма готова, если для каждого отказа можно назвать владельца правила, ключ поля и видимое место сообщения. При изменении значения старая ошибка исчезает или помечается устаревшей. При обратном порядке ответов финальное состояние содержит новое значение и только его результат. При серверном отказе пользователь видит текст у правильного input, может перейти к нему с клавиатуры и исправить значение. Сервер повторяет все критичные проверки независимо от браузера.
\nvalidity, checkValidity() и reportValidity().aria-invalid, описание ошибок и требования к взаимодействию с host language.