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

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

\n

Граница проходит между тремя задачами. Браузер быстро проверяет ограничения самого поля. Клиент связывает результат с текущим состоянием интерфейса. Сервер повторяет критичные проверки и решает, можно ли сохранить данные. Если ответ сервера не связан с именем поля и версией значения, одна новая регулярка расхождение не исправит.

\n

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

\n

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

\n

Например, type=\"email\" проверяет синтаксис, который браузер считает адресом электронной почты. Атрибут required запрещает пустое значение, а minlength, maxlength и pattern задают локальные ограничения. Ни один из них не знает, занят ли логин, имеет ли пользователь право на действие или изменилось ли значение записи на сервере.

\n

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

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

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

\n

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

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

Адаптер принимает только известные имена. Если сервер прислал неизвестный ключ, безопаснее показать нейтральную ошибку формы и сохранить технический код для диагностики, чем приклеить сообщение к первому input. По тексту «занят» нельзя надёжно определить, какое поле нужно исправить.

\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\n    if (knownFields.indexOf(name) === -1 || !item || !item.message) {\n      result.form.push('Сервер вернул ошибку без известного поля');\n      return;\n    }\n\n    result.fields[name] = item.message;\n  });\n\n  return result;\n}\n\nvar errors = mapServerErrors(apiPayload, ['email', 'password']);
\n

Пример выбирает первое сообщение и не решает локализацию, вложенные массивы или несколько ошибок на одном поле. Эти решения нужно закрепить отдельно. Сообщение вставляют как текст, а не как HTML: ответ API не получает права изменить разметку страницы.

\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, если поле больше не признано ошибочным. Затем запускайте следующую проверку. Красная рамка не заменяет текст, а постоянный role=\"alert\" на всей форме может превратить каждое изменение в шум. Фокус после submit можно перевести на первое проблемное поле; короткий динамический статус следует объявлять отдельно и умеренно.

\n

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

\n

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

\n
function delayResult(value, delayMs) {\n  return new Promise(function (resolve) {\n    setTimeout(function () {\n      resolve({ value: value, available: value !== 'ivan' });\n    }, delayMs);\n  });\n}\n\nvar state = { value: '', requestId: 0, phase: 'idle', error: '' };\n\nfunction checkLogin(value, delayMs) {\n  var requestId = state.requestId + 1;\n  state = { value: value, requestId: requestId, phase: 'checking', error: '' };\n\n  return delayResult(value, delayMs).then(function (answer) {\n    if (requestId !== state.requestId || value !== state.value) {\n      return { applied: false, reason: 'stale-response' };\n    }\n\n    state = {\n      value: value,\n      requestId: requestId,\n      phase: answer.available ? 'valid' : 'invalid',\n      error: answer.available ? '' : 'Этот логин уже занят'\n    };\n\n    return { applied: true, state: state };\n  });\n}\n\nvar first = checkLogin('ivan', 30);\nvar second = checkLogin('ivanka', 5);\n\nPromise.all([first, second]).then(function (results) {\n  console.assert(results[0].applied === false);\n  console.assert(results[1].applied === true);\n  console.assert(state.value === 'ivanka' && state.phase === 'valid');\n});
\n

Итоговый объект содержит ivanka, фазу valid и пустую ошибку. Первый promise возвращает stale-response и не вызывает изменение состояния. Код проверяет и номер запроса, и значение: проверка значения даёт дополнительную защиту от ошибки владельца, если счётчик был восстановлен неправильно.

\n

Счётчик должен принадлежать конкретному экземпляру поля или формы. Глобальный номер на всём сайте создаст взаимное влияние между двумя независимыми полями. В React, Vue или другом фреймворке правило то же: на input фиксируются новое значение и версия, а обработчик ответа получает актуальное состояние владельца перед render.

\n

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

\n
\"Схема
Валидация разделена между HTML, клиентским состоянием и API. Номер запроса не позволяет позднему ответу старого значения изменить текущий input.
\n

Submit — отдельный переход

\n

Проверка поля и сохранение — разные операции. При submit браузер может выполнить constraint validation. Затем клиент решает, есть ли локальная ошибка или незавершённая удалённая проверка. После этого запрос сохранения уходит на сервер, который снова проверяет весь вход.

\n

Для фазы checking заранее выберите политику: дождаться текущего результата, разрешить submit и принять окончательный ответ API или временно отключить кнопку с понятным объяснением. Нельзя объявлять форму готовой только потому, что регулярное выражение прошло. Предварительный запрос «логин свободен» не резервирует имя: другой клиент может занять его до сохранения.

\n

Ответ операции сохранения имеет приоритет над предварительной проверкой. Если сервер вернул конфликт, адаптер должен показать его у актуального отправленного поля. Если человек успел изменить поле до прихода ответа, старый результат снова нельзя рисовать над новым значением.

\n

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

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

Ограничения

\n

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

\n

Если API не возвращает имя поля, нельзя достоверно показать ошибку под конкретным input. Не угадывайте поле по тексту сообщения: покажите нейтральную ошибку формы, сохраните код и договоритесь об изменении контракта. Если компонент уничтожен, поздний ответ должен завершиться без render. setCustomValidity() полезен для собственной локальной ошибки, но его нужно очистить при новом вводе и не использовать как замену серверному правилу.

\n

Учебный код доказывает только порядок двух promise. Он не измеряет задержки реального API, поддержку конкретного браузера, доступность выбранного компонента или поведение базы. Эти свойства проверяют на целевом контуре: с клавиатурой, Accessibility tree, сетевой ошибкой и конкурентным сохранением.

\n

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

\n

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

\n

На реальном экране проверяющий должен пройти форму клавиатурой, увидеть метку и ошибку, открыть Accessibility tree и подтвердить связи по ID. Если этот прогон или серверный конфликт не проверены, готовность ограничивается локальным примером и не распространяется на весь пользовательский сценарий.

\n

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

" }