Files

8 lines
23 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 314,
"slug": "editorial-2019-04-mechanism-forms-validation",
"title": "Валидация формы: кто принимает решение и как не показать старую ошибку",
"excerpt": "Клиент быстро проверяет формат, сервер принимает окончательное решение, а асинхронный ответ должен относиться к текущему значению поля. Разбираем состояния формы, гонку запросов и проверяемый путь от input до submit.",
"contentHtml": "<p>Пользователь вводит <code>ivan</code>, получает сообщение «логин занят», меняет значение на <code>ivanka</code>, а через мгновение видит ту же ошибку. Другой вариант: поле подсвечено зелёным, кнопка отправки активна, но API отклоняет запрос. Цена ошибки — потерянное время, повторные попытки и недоверие к форме. Для команды это ещё и лишние флаги: <code>isValid</code>, <code>isLoading</code> и <code>error</code> начинают описывать разные значения одного поля.</p>\n<p><strong>Тезис:</strong> валидация формы — это договор между браузером, клиентским состоянием и сервером. Браузер быстро проверяет ограничения HTML. Клиент показывает понятный результат и связывает его с полем. Сервер проверяет вход снова и решает, можно ли сохранить данные. Каждый асинхронный результат должен нести версию значения, для которого он получен. Поздний ответ старой версии нужно отбросить.</p>\n<h2>Три уровня проверки</h2>\n<p>Встроенная проверка ограничений (constraint validation) отвечает на локальный вопрос: заполнено ли обязательное поле, соответствует ли значение типу, проходит ли длину или <code>pattern</code>. Эти правила доступны через Constraint Validation API. Они удобны для ранней обратной связи и не требуют запроса.</p>\n<p>Клиентская логика добавляет состояние интерфейса. Она решает, когда показывать ошибку, куда поставить фокус и как отличить проверку от отправки. Она также принимает ответы API и очищает старую ошибку после нового ввода.</p>\n<p>Сервер отвечает за окончательный контракт. Он знает права пользователя, занятость логина, состояние базы, лимиты и правила, которых может не быть в браузере. Проверка на клиенте не защищает API: запрос можно отправить напрямую или изменить в инструментах разработчика.</p>\n<p>Эти уровни не заменяют друг друга. Если клиент скопировал серверную регулярку, это не делает значение разрешённым. Если браузер считает поле корректным, сервер всё ещё может отказать. Если API вернул ошибку, её нужно показать рядом с тем полем, которое пользователь может исправить.</p>\n<h2>Состояние поля вместо одного boolean</h2>\n<p>Для примера возьмём поле логина. Его состояние содержит <code>value</code>, номер версии <code>version</code>, локальную ошибку <code>localError</code>, ошибку API <code>remoteError</code> и фазу. Фаза может быть <code>editing</code>, <code>client-invalid</code>, <code>checking</code>, <code>remote-invalid</code> или <code>ready</code>.</p>\n<p>Версия растёт на каждом изменении значения. Ответ проверки сохраняет номер версии при отправке запроса. При завершении обработчик сравнивает этот номер с текущим. Такое сравнение защищает состояние от гонки: скорость ответа больше не определяет, какая ошибка останется на экране.</p>\n<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><code>editing</code></td><td>Значение меняется или ещё не проверено удалённо</td><td>Текущее значение и подсказка</td><td>input → локальная проверка</td></tr><tr><td><code>client-invalid</code></td><td>Нарушено локальное правило</td><td>Текст ошибки у поля</td><td>input → editing или новая проверка</td></tr><tr><td><code>checking</code></td><td>Запрос относится к текущей версии</td><td>«Проверяем…» без старой ошибки</td><td>ответ той же версии → ready или remote-invalid</td></tr><tr><td><code>remote-invalid</code></td><td>API отклонил текущую версию</td><td>Ответ API у поля</td><td>input → editing</td></tr><tr><td><code>ready</code></td><td>Известные проверки пройдены</td><td>Разрешение продолжить</td><td>input → editing; submit → отправка</td></tr></tbody></table>\n<p>Таблица нужна не для отображения названий фаз. Она запрещает противоречия. Поле не должно одновременно быть <code>ready</code> и хранить ошибку для старого значения. <code>checking</code> не означает, что сервер уже разрешил отправку. После нового <code>input</code> старая ошибка API больше не описывает экран.</p>\n<h2>Локальная проверка и граница сервера</h2>\n<p>Нативные ограничения задают на элементе формы. Пример ниже учебный: регулярное выражение показывает простое правило для логина и не утверждает, что так устроен production API. Реальный контракт может разрешать Unicode, нормализовать регистр или применять дополнительные ограничения.</p>\n<pre><code>const initialState = {\n value: '',\n version: 0,\n phase: 'editing',\n localError: '',\n remoteError: '',\n};\n\nfunction localLoginError(value) {\n if (!value) return 'Введите логин';\n if (!/^[a-z0-9_]{3,20}$/i.test(value)) {\n return 'От 3 до 20 букв, цифр или _';\n }\n return '';\n}\n\nfunction onInput(state, nextValue) {\n const localError = localLoginError(nextValue);\n\n return {\n value: nextValue,\n version: state.version + 1,\n phase: localError ? 'client-invalid' : 'editing',\n localError,\n remoteError: '',\n };\n}\n\nfunction beginRemoteCheck(state) {\n return {\n nextState: { ...state, phase: 'checking', remoteError: '' },\n request: { value: state.value, checkedVersion: state.version },\n };\n}</code></pre>\n<p>Обработчик увеличивает версию и очищает <code>remoteError</code> в одном переходе. Нельзя оставить сообщение «логин занят» рядом с новым значением, а потом надеяться, что следующий ответ его исправит. Экран должен перестать утверждать старый факт сразу после ввода.</p>\n<p>Не каждое локально корректное значение нужно проверять сетью на каждую букву. Сначала примените дешёвые ограничения, затем выберите момент удалённой проверки: потеря фокуса, пауза после ввода или отправка формы. Debounce уменьшает число запросов, но не решает гонку. Даже один запрос может завершиться после следующего значения.</p>\n<h2>Защита от устаревшего ответа</h2>\n<p>При старте запроса сохраните <code>checkedVersion</code>. В момент ответа получите актуальное состояние из владельца формы и сравните номера. Нельзя сравнивать ответ со старым объектом из замыкания: такой объект всегда может совпасть сам с собой.</p>\n<pre><code>function applyRemoteAnswer(current, result) {\n if (current.version !== result.checkedVersion) {\n return current; // учебный пример: ответ устарел\n }\n\n return {\n ...current,\n phase: result.available ? 'ready' : 'remote-invalid',\n remoteError: result.available ? '' : 'Этот логин уже занят',\n };\n}\n\n// Учебный порядок ответов:\n// request 1: value=ivan, version=1, available=false\n// request 2: value=ivanka, version=2, available=true\n// request 2 может прийти первым; request 1 не меняет finalState.</code></pre>\n<p>Если первый запрос пришёл после второго, обработчик не должен показывать ошибку. Это не отказ API и не исключение сети. Результат устарел. Его можно учесть в диагностике транспорта, но нельзя применять к текущему полю.</p>\n<p>Сетевой сбой — отдельный результат, а не <code>remote-invalid</code>. Сохраните его как <code>transportError</code> или состояние общего блока формы, предложите повторить запрос и не стирайте текущее значение. Иначе пользователь увидит «логин занят», хотя сервер этого не сообщал.</p>\n<p><code>AbortController</code> полезен, когда транспорт умеет отменять ненужный запрос. Он экономит ресурсы, но не заменяет сравнение версий: отмена может прийти поздно, сервер может уже обработать запрос, а другой адаптер может игнорировать сигнал.</p>\n<figure><img src=\"/assets/editorial/2019/forms-validation-state-machine-2019.svg\" alt=\"Состояния поля формы и граница между актуальным и устаревшим ответом\" loading=\"lazy\" /><figcaption>Новый input увеличивает версию. Ответ применяется только тогда, когда его версия совпадает с текущей.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<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>Старая ошибка возвращается после нового ввода</td><td>Ответ не связан с версией значения</td><td>Замедлить первый запрос и проверить порядок ответов</td><td>Добавить <code>version</code> и отбрасывать устаревший ответ</td></tr><tr><td>Кнопка активна до окончания проверки</td><td><code>isValid</code> учитывает только локальный формат</td><td>Отправить форму в фазе <code>checking</code></td><td>Ждать проверку или валидировать условие на submit</td></tr><tr><td>API отказал зелёному полю</td><td>Клиент принял локальное правило за серверный контракт</td><td>Сравнить тело запроса и код/ключ ошибки API</td><td>Показать серверную ошибку и оставить сервер источником истины</td></tr><tr><td>Ошибка видна только рамкой</td><td>Нет текста и связи сообщения с контролом</td><td>Пройти поле клавиатурой и проверить дерево доступности</td><td>Добавить <code>label</code>, видимый текст и связь по ID</td></tr><tr><td>Неизвестная ошибка исчезает</td><td>Клиент пытается приклеить неизвестный ключ к случайному полю</td><td>Вернуть от API ошибку без известного имени поля</td><td>Показать общий блок формы и сохранить техническую диагностику</td></tr></tbody></table>\n<h2>Submit — отдельный переход</h2>\n<p>Отправка формы не равна проверке поля. Сначала браузер может выполнить встроенную проверку ограничений (constraint validation). Затем клиент решает, есть ли локальные ошибки и незавершённые проверки. После этого запрос сохранения уходит на сервер, который снова проверяет весь вход.</p>\n<p>Для фазы <code>checking</code> выберите одну политику. Можно дождаться текущей проверки. Можно разрешить submit и принять окончательный ответ API. Можно временно отключить кнопку, если интерфейс объясняет причину и не блокирует исправление. Нельзя показывать готовность только потому, что регулярное выражение прошло.</p>\n<p>Для уникального логина окончательная проверка должна быть связана с сохранением: ограничение уникальности в базе данных должно отклонить конфликт. Предварительный запрос «логин свободен» не резервирует логин. Другой пользователь может занять его до submit. Ответ сохранения имеет приоритет над предварительным ответом.</p>\n<h2>Ошибка должна быть доступна</h2>\n<p>У поля есть видимое имя через <code>label</code>. Подсказка и сообщение об ошибке получают устойчивые ID. При ошибке контрол получает <code>aria-invalid=\"true\"</code>, а связь с сообщением задаётся атрибутом описания или сообщения об ошибке. Если используется <code>aria-errormessage</code>, при актуальной ошибке сообщение должно быть видимым; после исправления его нужно скрыть или убрать этот атрибут. Цвет рамки остаётся дополнительным сигналом.</p>\n<pre><code>&lt;label for=\"login\"&gt;Логин&lt;/label&gt;\n&lt;input id=\"login\"\n name=\"login\"\n aria-invalid=\"true\"\n aria-errormessage=\"login-error\"\n aria-describedby=\"login-hint\" /&gt;\n&lt;div id=\"login-hint\"&gt;От 3 до 20 символов.&lt;/div&gt;\n&lt;div id=\"login-error\"&gt;Этот логин уже занят.&lt;/div&gt;</code></pre>\n<p>Это учебный фрагмент разметки. После интеграции проверьте, что сообщение действительно отображается, связь не дублируется и фокус остаётся понятным после submit. Не добавляйте <code>role=\"alert\"</code> на всю форму: длинное сообщение создаёт шум. Срочное изменение статуса должно быть коротким и уместным.</p>\n<h2>Порядок внедрения и проверки</h2>\n<ol><li>Выписать поля формы и разделить правила на локальные, серверные и общие для формы.</li><li>Назвать владельца состояния. Хранить <code>value</code>, фазу, ошибки и версию в одном согласованном месте.</li><li>Добавить нативные ограничения, если они честно описывают контракт: <code>required</code>, тип, длину или <code>pattern</code>.</li><li>На каждом <code>input</code> увеличивать версию, пересчитывать локальную ошибку и очищать старую ошибку API.</li><li>Перед сетевой проверкой сохранить версию. В ответе сравнить её с актуальным состоянием до изменения UI.</li><li>Явно решить поведение submit в фазе <code>checking</code>. Не считать незавершённую проверку готовностью.</li><li>Связать label, подсказку и ошибку с контролом. Проверить клавиатуру, фокус и текст, а не только цвет.</li><li>Проверить отрицательные случаи: старый ответ после нового ввода, неизвестный ключ API, ошибка сети и отказ сохранения при предварительно свободном значении.</li><li>Записать критерий готовности и границу возврата. Если команда не может повторить проверку, форма не готова.</li></ol>\n<h2>Ограничения</h2>\n<p>Версия защищает состояние интерфейса, но не делает запрос идемпотентным и не защищает базу от конкурирующей записи. Серверная проверка остаётся обязательной.</p>\n<p>Нативный текст браузерной ошибки может различаться. Если нужен единый текст, добавьте собственное видимое сообщение, но не удаляйте полезную семантику HTML без причины.</p>\n<p>Сложная форма может иметь автомат формы и автоматы отдельных полей. Это не отменяет явной связи: submit должен знать, какие поля ещё проверяются и кто возвращает окончательный отказ.</p>\n<p>Учебные логины, задержки и результаты в коде не являются production-измерениями. Они показывают порядок переходов и отрицательный путь. Перед выпуском нужны реальные ответы API, браузерная проверка и проверка доступности.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Форма готова, если для каждого поля можно назвать владельца значения, локальное правило, серверное условие, фазу, номер версии и место сообщения. При двух ответах в обратном порядке старый ответ не меняет новое значение. При отказе API ошибка появляется у правильного поля или в общем блоке, если ключ неизвестен. При клавиатурной проверке поле имеет имя, текст ошибки доступен и фокус не теряется.</p>\n<p>Достаточное доказательство — воспроизводимый сценарий с пустым полем, неверным форматом, текущей серверной ошибкой, устаревшим ответом и отказом submit. Если хотя бы один сценарий оставляет на экране вердикт для другого значения, правило актуальности не внедрено.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api\" target=\"_blank\" rel=\"noopener noreferrer\">HTML Standard: The constraint validation API</a> — официальное описание <code>validity</code>, <code>checkValidity()</code>, <code>reportValidity()</code> и <code>setCustomValidity()</code>.</li><li><a href=\"https://html.spec.whatwg.org/multipage/forms.html#form-submission-algorithm\" target=\"_blank\" rel=\"noopener noreferrer\">HTML Standard: Form submission algorithm</a> — официальный алгоритм отправки формы и граница интерактивной проверки.</li><li><a href=\"https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage\" target=\"_blank\" rel=\"noopener noreferrer\">WAI-ARIA 1.2: aria-errormessage</a> — нормативное описание связи контрола с актуальным сообщением об ошибке и условия использования вместе с <code>aria-invalid</code>.</li></ul>"
}