{ "index": 315, "slug": "editorial-2019-04-practice-forms-validation", "title": "Валидация формы без ложного успеха: поле, API и устаревший ответ", "excerpt": "Браузер проверяет формат, сервер принимает бизнес-решение, а асинхронный ответ должен относиться к текущему значению. Разбираем контракт ошибки, доступную разметку и воспроизводимую защиту от гонки запросов.", "contentHtml": "
Форма показывает зелёную почту, пользователь нажимает «Сохранить», а API отвечает 422. В другом варианте человек быстро меняет логин: проверка нового значения возвращает «свободен», затем поздний ответ для старого рисует ошибку под новым. Цена ошибки — повторные попытки и неверное решение пользователя. Для регистрации или платежа это может закончиться отказом в корректной операции.
\nГраница проходит между тремя задачами. Браузер быстро проверяет ограничения самого поля. Клиент связывает результат с текущим состоянием интерфейса. Сервер повторяет критичные проверки и решает, можно ли сохранить данные. Если ответ сервера не связан с именем поля и версией значения, одна новая регулярка расхождение не исправит.
\nHTML отсекает локальные нарушения: пустое обязательное поле, неподходящий тип, длину и шаблон. У элемента формы есть объект validity. Методы checkValidity() и reportValidity() проверяют ограничения формы; второй дополнительно просит браузер показать пользователю найденные проблемы. Это ранний фильтр, а не проверка состояния базы.
Например, type=\"email\" проверяет синтаксис, который браузер считает адресом электронной почты. Атрибут required запрещает пустое значение, а minlength, maxlength и pattern задают локальные ограничения. Ни один из них не знает, занят ли логин, имеет ли пользователь право на действие или изменилось ли значение записи на сервере.
Клиентский код владеет отображением и переходами состояния. После ввода он должен убрать ошибку, относящуюся к прежнему значению, а после ответа — проверить, что ответ всё ещё принадлежит этому полю и этой версии. Сервер владеет нормализацией, правами, ограничениями данных и окончательным результатом сохранения. Запрос можно отправить напрямую, поэтому браузерные ограничения не заменяют серверную проверку.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Поле зелёное, API отвечает 422 | Клиент проверил формат, сервер — бизнес-условие | Сравнить локальные ограничения с кодом и телом ответа | Показать серверную ошибку у поля и оставить сервер источником истины |
| Ошибка относится к соседнему полю | Код ищет слово в тексте или принимает неизвестный ключ | Проверить карту field → error | Известный ключ связать с input, неизвестный отправить в общий блок формы |
| Старый ответ стирает новый ввод | Обработчик применяет любой завершившийся запрос | Замедлить первый ответ и изменить значение до его завершения | Сравнить номер запроса или версию перед изменением состояния |
| Ошибка видна только рамкой | Нет видимого текста и связи с контролом | Пройти форму клавиатурой и проверить accessibility tree | Добавить label, текст ошибки и ID-связь |
Формат ошибки ниже — договор конкретного приложения, а не встроенный формат браузера. Важны стабильные имена полей и машинные коды; сообщение предназначено для показа человеку и не должно использоваться как идентификатор маршрутизации.
\n{\n \"code\": \"VALIDATION_FAILED\",\n \"fields\": {\n \"email\": [\n { \"code\": \"email_taken\", \"message\": \"Этот адрес уже используется\" }\n ]\n },\n \"form\": []\n}\nАдаптер принимает только известные имена. Если сервер прислал неизвестный ключ, безопаснее показать нейтральную ошибку формы и сохранить технический код для диагностики, чем приклеить сообщение к первому input. По тексту «занят» нельзя надёжно определить, какое поле нужно исправить.
\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\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У поля есть видимый 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, если поле больше не признано ошибочным. Затем запускайте следующую проверку. Красная рамка не заменяет текст, а постоянный role=\"alert\" на всей форме может превратить каждое изменение в шум. Фокус после submit можно перевести на первое проблемное поле; короткий динамический статус следует объявлять отдельно и умеренно.
Рассмотрим автономный сценарий без настоящей сети. Пользователь вводит ivan, для него задана задержка 30 мс. Затем ввод меняется на ivanka, второй запрос задерживается на 5 мс. Второй ответ придёт первым и должен примениться. Первый ответ означает «логин занят», но к моменту его прихода уже относится к старому значению.
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 и не вызывает изменение состояния. Код проверяет и номер запроса, и значение: проверка значения даёт дополнительную защиту от ошибки владельца, если счётчик был восстановлен неправильно.
Счётчик должен принадлежать конкретному экземпляру поля или формы. Глобальный номер на всём сайте создаст взаимное влияние между двумя независимыми полями. В React, Vue или другом фреймворке правило то же: на input фиксируются новое значение и версия, а обработчик ответа получает актуальное состояние владельца перед render.
AbortController может отменить поддерживаемую сетевую операцию и сэкономить ресурсы. Он не заменяет проверку версии: ответ мог уже разрешиться, сервер мог обработать запрос до отмены, а другой транспорт может игнорировать сигнал. Сначала защищают право менять состояние, затем добавляют отмену как оптимизацию.
Проверка поля и сохранение — разные операции. При submit браузер может выполнить constraint validation. Затем клиент решает, есть ли локальная ошибка или незавершённая удалённая проверка. После этого запрос сохранения уходит на сервер, который снова проверяет весь вход.
\nДля фазы checking заранее выберите политику: дождаться текущего результата, разрешить submit и принять окончательный ответ API или временно отключить кнопку с понятным объяснением. Нельзя объявлять форму готовой только потому, что регулярное выражение прошло. Предварительный запрос «логин свободен» не резервирует имя: другой клиент может занять его до сохранения.
Ответ операции сохранения имеет приоритет над предварительной проверкой. Если сервер вернул конфликт, адаптер должен показать его у актуального отправленного поля. Если человек успел изменить поле до прихода ответа, старый результат снова нельзя рисовать над новым значением.
\nrequired, тип, длину и pattern. Не копируйте в браузер всю бизнес-логику.label, устойчивые ID подсказки и ошибки. Показывайте текст, а не только цвет.input очищайте старую серверную ошибку и увеличивайте версию. При старте async-проверки сохраните эту версию.aria-invalid и ID-связи должны описывать одно значение.Проверка доступности логина до сохранения не резервирует логин. Сервер обязан проверить условие в момент записи. Проверка версии защищает состояние интерфейса, но не делает сохранение идемпотентным, не отменяет транзакцию и не решает авторизацию. Для зависимых полей отдельно определите, очищает ли изменение одного поля результат другого.
\nЕсли API не возвращает имя поля, нельзя достоверно показать ошибку под конкретным input. Не угадывайте поле по тексту сообщения: покажите нейтральную ошибку формы, сохраните код и договоритесь об изменении контракта. Если компонент уничтожен, поздний ответ должен завершиться без render. setCustomValidity() полезен для собственной локальной ошибки, но его нужно очистить при новом вводе и не использовать как замену серверному правилу.
Учебный код доказывает только порядок двух promise. Он не измеряет задержки реального API, поддержку конкретного браузера, доступность выбранного компонента или поведение базы. Эти свойства проверяют на целевом контуре: с клавиатурой, Accessibility tree, сетевой ошибкой и конкурентным сохранением.
\nФорма готова, если тест с двумя обратными задержками оставляет последнее значение, его номер запроса, корректную фазу и пустую старую ошибку. Ответ для прежнего значения не вызывает render. После изменения input сообщение очищается. Известная ошибка API появляется у нужного поля, неизвестная — в общем блоке, а сервер отклоняет конфликт даже после положительной предварительной проверки.
На реальном экране проверяющий должен пройти форму клавиатурой, увидеть метку и ошибку, открыть Accessibility tree и подтвердить связи по ID. Если этот прогон или серверный конфликт не проверены, готовность ограничивается локальным примером и не распространяется на весь пользовательский сценарий.
\nvalidity и методы checkValidity()/reportValidity().aria-invalid, aria-describedby и aria-errormessage и их связь с текущим состоянием.