{ "index": 315, "slug": "editorial-2019-04-practice-forms-validation", "title": "Валидация формы без ложного успеха: поле, API и устаревший ответ", "excerpt": "Браузер считает поле корректным, а API отклоняет его или возвращает ошибку уже для старого значения. Разбираем границы проверки, контракт ошибок, доступную разметку и защиту от гонки ответов.", "contentHtml": "

Форма показывает зелёную почту, пользователь нажимает «Сохранить», а API отвечает 422. В другом варианте человек быстро меняет логин: новый ответ говорит «свободен», затем поздний ответ для старого значения рисует ошибку под новым. Сообщение иногда попадает в общий баннер и не объясняет, какое поле исправлять. Цена ошибки — лишний запрос, потерянное введённое значение и неверное решение пользователя. Для регистрации или платежа это может означать отказ в корректной операции.

\n

Тезис простой: клиентская валидация ускоряет обратную связь, но не принимает бизнес-решение. Сервер проверяет данные снова. Интерфейс должен знать, к какому полю относится отказ и к какой версии значения он относится. Если эти границы не зафиксированы, новая регулярка не исправит расхождение.

\n

Что именно проверяет каждый слой

\n

HTML отсекает очевидное: пустое обязательное поле, неверный тип, длину и pattern. У контрола есть объект validity. Методы checkValidity() и reportValidity() помогают проверить нативные ограничения формы. Это полезный ранний фильтр. Он не знает, занят ли логин, есть ли у пользователя право на действие или изменилось ли состояние записи на сервере.

\n

Клиентский код собирает состояния и решает, где показать результат. Он может очистить старую серверную ошибку после ввода, дождаться проверки доступности и не применить ответ старой версии. Но он не должен объявлять значение принятым только потому, что локальная проверка прошла. Запрос можно отправить вне страницы, а правила базы меняются независимо от JavaScript.

\n

API владеет нормализацией и бизнес-условиями. Ему не следует возвращать только строку «что-то не так»: экрану будет некуда её привязать. У ошибки нужен стабильный ключ поля и код. Человеческий текст остаётся текстом, а не идентификатором маршрутизации.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Поле зелёное, API отвечает 422Клиент проверил формат, сервер — бизнес-условиеСравнить локальные ограничения с контрактом ответаПоказать серверную ошибку у поля и оставить сервер источником истины
Ошибка относится к соседнему полюКод ищет текст или использует неизвестный ключПроверить карту field → error и список полейИзвестные ключи привязать к input, неизвестные оставить общей ошибкой
Старый ответ стирает новый вводОбработчик применяет любой завершившийся запросЗамедлить первый ответ и быстро изменить значениеСравнивать номер запроса или версию значения до render
Ошибка видна только красной рамкойНет текста, label или связи с контроломПроверить клавиатуру и accessibility treeДобавить видимое сообщение, aria-invalid и связь по ID
\n

Контракт ошибки должен указывать поле

\n

Для учебного примера представим ответ API при отправке формы. Это локальный договор приложения, а не встроенный формат браузера:

\n
{\n  \"code\": \"VALIDATION_FAILED\",\n  \"fields\": {\n    \"email\": [\n      { \"code\": \"email_taken\", \"message\": \"Этот адрес уже используется\" }\n    ]\n  },\n  \"form\": []\n}
\n

Клиенту нужен адаптер. Он принимает только известные имена полей и отделяет ошибку формы от ошибки input. Не ищите слово «занят» в сообщении. Не приклеивайте неизвестный ключ к первому полю: так ошибка API превращается в ложную подсказку.

\n
function 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

Ошибка должна принадлежать текущему input

\n

У поля есть видимый label, постоянная подсказка и контейнер ошибки. aria-describedby связывает input с описанием по ID. Когда значение не прошло проверку, интерфейс добавляет aria-invalid=\"true\" и показывает сообщение. Если проект применяет aria-errormessage, его связывают с видимым элементом ошибки и используют только при невалидном состоянии.

\n
<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\" применяют к короткому динамическому статусу, а не ко всей форме.

\n

Поздний ответ проверяет не то значение

\n

Рассмотрим учебный сценарий без настоящей сети. Пользователь вводит ivan, запрос получает задержку 30 мс. Затем ввод меняется на ivanka, второй запрос получает задержку 5 мс. Если первый ответ означает «занято», он придёт позже. Обработчик должен знать, что его запрос устарел.

\n
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 или другом фреймворке принцип не меняется: обработчик сравнивает свой номер с актуальным состоянием владельца.

\n

AbortController может отменить сетевую работу и сэкономить ресурсы. Он не заменяет проверку номера. Ответ мог уже разрешиться, транспорт может не поддерживать сигнал, а причина ошибки может быть локальной. Сначала защищают состояние, затем добавляют отмену как оптимизацию.

\n
\"Схема
Граница проходит между локальным ограничением, контрактом API и отображением ошибки конкретного поля. Поздний ответ не должен менять состояние нового значения.
\n

Порядок внедрения

\n
  1. Выпишите поля формы и разделите для каждого локальное ограничение, серверное условие и общий сбой формы.
  2. Согласуйте с API стабильные ключи полей и коды ошибок. Для неизвестного ключа выберите общий контейнер, а не случайный input.
  3. Добавьте честные нативные ограничения: required, тип, длину и pattern. Не копируйте всю бизнес-логику в браузер.
  4. Сделайте у каждого поля label, устойчивые ID подсказки и ошибки. Показывайте текст, а не только цвет; при ошибке обновляйте aria-invalid.
  5. При каждом изменении значения очищайте старую серверную ошибку и увеличивайте версию. При старте async-проверки сохраните эту версию.
  6. В обработчике ответа сравните версию с текущим состоянием до любого изменения UI. Отмену запроса добавляйте только после этой защиты.
  7. Проверьте submit для пустого значения, неверного формата, известной ошибки поля, неизвестного ключа, сетевой ошибки и ответов в обратном порядке.
\n

Ограничения

\n

Проверка доступности логина до сохранения не резервирует логин. Другой запрос может занять его между двумя операциями. Сервер обязан проверить условие в момент записи и вернуть ошибку поля. Нативный текст браузера может отличаться; единый текст можно показать самостоятельно, сохранив полезные ограничения HTML.

\n

Проверка версии защищает состояние интерфейса, но не делает операцию сохранения идемпотентной и не отменяет транзакцию. Для зависимых полей нужно решить, очищает ли изменение одного поля результат другого. Для нескольких сообщений нужно определить порядок и способ показа. ARIA не исправляет отсутствие label, понятного текста или корректного фокуса.

\n

Примеры выше учебные. Они не измеряют задержки, поддержку конкретного браузера или поведение реального API. Сценарий с задержками проверяет только порядок применения ответов. Разметку нужно проверить в целевом браузере, с клавиатурой и используемым скринридером.

\n

Проверяемый критерий готовности

\n

Форма готова, если для каждого отказа можно назвать владельца правила, ключ поля и видимое место сообщения. При изменении значения старая ошибка исчезает или помечается устаревшей. При обратном порядке ответов финальное состояние содержит новое значение и только его результат. При серверном отказе пользователь видит текст у правильного input, может перейти к нему с клавиатуры и исправить значение. Сервер повторяет все критичные проверки независимо от браузера.

\n

Проверяемые источники

" }