diff --git a/editorial/agent-rewrites/315.json b/editorial/agent-rewrites/315.json index a9516eb..529b951 100644 --- a/editorial/agent-rewrites/315.json +++ b/editorial/agent-rewrites/315.json @@ -2,6 +2,6 @@ "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.Форма показывает зелёную почту, пользователь нажимает «Сохранить», а 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 и их связь с текущим состоянием.