8 lines
17 KiB
JSON
8 lines
17 KiB
JSON
{
|
||
"index": 315,
|
||
"slug": "editorial-2019-04-practice-forms-validation",
|
||
"title": "Валидация формы без ложного успеха: поле, API и устаревший ответ",
|
||
"excerpt": "Браузер считает поле корректным, а API отклоняет его или возвращает ошибку уже для старого значения. Разбираем границы проверки, контракт ошибок, доступную разметку и защиту от гонки ответов.",
|
||
"contentHtml": "<p>Форма показывает зелёную почту, пользователь нажимает «Сохранить», а API отвечает 422. В другом варианте человек быстро меняет логин: новый ответ говорит «свободен», затем поздний ответ для старого значения рисует ошибку под новым. Сообщение иногда попадает в общий баннер и не объясняет, какое поле исправлять. Цена ошибки — лишний запрос, потерянное введённое значение и неверное решение пользователя. Для регистрации или платежа это может означать отказ в корректной операции.</p>\n<p>Тезис простой: клиентская валидация ускоряет обратную связь, но не принимает бизнес-решение. Сервер проверяет данные снова. Интерфейс должен знать, к какому полю относится отказ и к какой версии значения он относится. Если эти границы не зафиксированы, новая регулярка не исправит расхождение.</p>\n<h2>Что именно проверяет каждый слой</h2>\n<p>HTML отсекает очевидное: пустое обязательное поле, неверный тип, длину и pattern. У контрола есть объект <code>validity</code>. Методы <code>checkValidity()</code> и <code>reportValidity()</code> помогают проверить нативные ограничения формы. Это полезный ранний фильтр. Он не знает, занят ли логин, есть ли у пользователя право на действие или изменилось ли состояние записи на сервере.</p>\n<p>Клиентский код собирает состояния и решает, где показать результат. Он может очистить старую серверную ошибку после ввода, дождаться проверки доступности и не применить ответ старой версии. Но он не должен объявлять значение принятым только потому, что локальная проверка прошла. Запрос можно отправить вне страницы, а правила базы меняются независимо от JavaScript.</p>\n<p>API владеет нормализацией и бизнес-условиями. Ему не следует возвращать только строку «что-то не так»: экрану будет некуда её привязать. У ошибки нужен стабильный ключ поля и код. Человеческий текст остаётся текстом, а не идентификатором маршрутизации.</p>\n<div class=\"table-scroll\"><table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Поле зелёное, API отвечает 422</td><td>Клиент проверил формат, сервер — бизнес-условие</td><td>Сравнить локальные ограничения с контрактом ответа</td><td>Показать серверную ошибку у поля и оставить сервер источником истины</td></tr><tr><td>Ошибка относится к соседнему полю</td><td>Код ищет текст или использует неизвестный ключ</td><td>Проверить карту <code>field → error</code> и список полей</td><td>Известные ключи привязать к input, неизвестные оставить общей ошибкой</td></tr><tr><td>Старый ответ стирает новый ввод</td><td>Обработчик применяет любой завершившийся запрос</td><td>Замедлить первый ответ и быстро изменить значение</td><td>Сравнивать номер запроса или версию значения до render</td></tr><tr><td>Ошибка видна только красной рамкой</td><td>Нет текста, label или связи с контролом</td><td>Проверить клавиатуру и accessibility tree</td><td>Добавить видимое сообщение, <code>aria-invalid</code> и связь по ID</td></tr></tbody></table></div>\n<h2>Контракт ошибки должен указывать поле</h2>\n<p>Для учебного примера представим ответ API при отправке формы. Это локальный договор приложения, а не встроенный формат браузера:</p>\n<pre><code>{\n \"code\": \"VALIDATION_FAILED\",\n \"fields\": {\n \"email\": [\n { \"code\": \"email_taken\", \"message\": \"Этот адрес уже используется\" }\n ]\n },\n \"form\": []\n}</code></pre>\n<p>Клиенту нужен адаптер. Он принимает только известные имена полей и отделяет ошибку формы от ошибки input. Не ищите слово «занят» в сообщении. Не приклеивайте неизвестный ключ к первому полю: так ошибка API превращается в ложную подсказку.</p>\n<pre><code>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']);</code></pre>\n<p>Код учебный. Он выбирает первое сообщение и не решает локализацию, вложенные массивы или несколько ошибок на одном поле. В рабочей форме эти решения фиксируют отдельно. Текст сообщения вставляют как текст. Нельзя принимать его за HTML без явной, проверенной причины.</p>\n<h2>Ошибка должна принадлежать текущему input</h2>\n<p>У поля есть видимый <code>label</code>, постоянная подсказка и контейнер ошибки. <code>aria-describedby</code> связывает input с описанием по ID. Когда значение не прошло проверку, интерфейс добавляет <code>aria-invalid=\"true\"</code> и показывает сообщение. Если проект применяет <code>aria-errormessage</code>, его связывают с видимым элементом ошибки и используют только при невалидном состоянии.</p>\n<pre><code><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></code></pre>\n<p>В валидном состоянии ошибку скрывают способом, который не оставляет устаревший текст доступным как актуальное сообщение, а <code>aria-invalid</code> убирают или ставят в <code>false</code>. Красный цвет не заменяет текст. Фокус после submit можно перевести на первое проблемное поле, но при каждом вводе не нужно превращать сообщение в срочное объявление. <code>role=\"alert\"</code> применяют к короткому динамическому статусу, а не ко всей форме.</p>\n<h2>Поздний ответ проверяет не то значение</h2>\n<p>Рассмотрим учебный сценарий без настоящей сети. Пользователь вводит <code>ivan</code>, запрос получает задержку 30 мс. Затем ввод меняется на <code>ivanka</code>, второй запрос получает задержку 5 мс. Если первый ответ означает «занято», он придёт позже. Обработчик должен знать, что его запрос устарел.</p>\n<pre><code>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}</code></pre>\n<p>Номер запроса должен принадлежать конкретному экземпляру поля или форме. Глобальный счётчик всего сайта создаст взаимное влияние, если на странице появятся два независимых поля. В React, Vue или другом фреймворке принцип не меняется: обработчик сравнивает свой номер с актуальным состоянием владельца.</p>\n<p><code>AbortController</code> может отменить сетевую работу и сэкономить ресурсы. Он не заменяет проверку номера. Ответ мог уже разрешиться, транспорт может не поддерживать сигнал, а причина ошибки может быть локальной. Сначала защищают состояние, затем добавляют отмену как оптимизацию.</p>\n<figure><img src=\"/assets/editorial/2019/forms-validation-contract-2019.svg\" alt=\"Схема договора валидации формы: HTML проверяет ранние ограничения, API возвращает ошибку с ключом поля, клиент показывает её у input и отвергает устаревший ответ\" loading=\"lazy\" /><figcaption>Граница проходит между локальным ограничением, контрактом API и отображением ошибки конкретного поля. Поздний ответ не должен менять состояние нового значения.</figcaption></figure>\n<h2>Порядок внедрения</h2>\n<ol><li>Выпишите поля формы и разделите для каждого локальное ограничение, серверное условие и общий сбой формы.</li><li>Согласуйте с API стабильные ключи полей и коды ошибок. Для неизвестного ключа выберите общий контейнер, а не случайный input.</li><li>Добавьте честные нативные ограничения: <code>required</code>, тип, длину и <code>pattern</code>. Не копируйте всю бизнес-логику в браузер.</li><li>Сделайте у каждого поля <code>label</code>, устойчивые ID подсказки и ошибки. Показывайте текст, а не только цвет; при ошибке обновляйте <code>aria-invalid</code>.</li><li>При каждом изменении значения очищайте старую серверную ошибку и увеличивайте версию. При старте async-проверки сохраните эту версию.</li><li>В обработчике ответа сравните версию с текущим состоянием до любого изменения UI. Отмену запроса добавляйте только после этой защиты.</li><li>Проверьте submit для пустого значения, неверного формата, известной ошибки поля, неизвестного ключа, сетевой ошибки и ответов в обратном порядке.</li></ol>\n<h2>Ограничения</h2>\n<p>Проверка доступности логина до сохранения не резервирует логин. Другой запрос может занять его между двумя операциями. Сервер обязан проверить условие в момент записи и вернуть ошибку поля. Нативный текст браузера может отличаться; единый текст можно показать самостоятельно, сохранив полезные ограничения HTML.</p>\n<p>Проверка версии защищает состояние интерфейса, но не делает операцию сохранения идемпотентной и не отменяет транзакцию. Для зависимых полей нужно решить, очищает ли изменение одного поля результат другого. Для нескольких сообщений нужно определить порядок и способ показа. ARIA не исправляет отсутствие label, понятного текста или корректного фокуса.</p>\n<p>Примеры выше учебные. Они не измеряют задержки, поддержку конкретного браузера или поведение реального API. Сценарий с задержками проверяет только порядок применения ответов. Разметку нужно проверить в целевом браузере, с клавиатурой и используемым скринридером.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Форма готова, если для каждого отказа можно назвать владельца правила, ключ поля и видимое место сообщения. При изменении значения старая ошибка исчезает или помечается устаревшей. При обратном порядке ответов финальное состояние содержит новое значение и только его результат. При серверном отказе пользователь видит текст у правильного input, может перейти к нему с клавиатуры и исправить значение. Сервер повторяет все критичные проверки независимо от браузера.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://html.spec.whatwg.org/multipage/forms.html\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG HTML Standard: Forms</a> — нативные ограничения, <code>validity</code>, <code>checkValidity()</code> и <code>reportValidity()</code>.</li><li><a href=\"https://www.w3.org/TR/wai-aria/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C: WAI-ARIA 1.2</a> — состояния <code>aria-invalid</code>, описание ошибок и требования к взаимодействию с host language.</li></ul>"
|
||
}
|