DarkRiDDeR12 мин

Форма без расхождения правил: клиентская проверка, серверная ошибка и поле

JavaScriptHTMLUXПрактика

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

В апреле 2019 я бы не пытался строить «универсальный валидатор». Для одной формы достаточно зафиксировать короткий контракт: какие ограничения браузер проверяет сразу, какие условия знает только сервер, в каком виде сервер возвращает ошибки и кто имеет право менять состояние поля. Ниже учебный вариант без привязки к фреймворку. Он показывает маршрут проверки; он не является результатом запуска на чужом API или браузерной трассой.

Сначала разделяем три вида проверки

Клиентская проверка нужна, чтобы не отправлять пустую почту или строку с очевидно неверной формой. HTML уже знает часть ограничений: required, type="email", minlength, pattern. У контрола есть validity, а checkValidity() отвечает на конкретный вопрос: проходит ли элемент его ограничения. Это удобный ранний фильтр, но не источник истины о пользователе, правах, занятости логина или правилах, которые меняются на сервере.

Серверная проверка владеет данными и бизнес-условием. Если API считает, что адрес уже связан с другим аккаунтом, браузер не может опровергнуть это своим input[type=email]. Представление, в свою очередь, владеет тем, где сообщение видно: у поля, у формы или в статусе отправки. Ошибка возникает именно на этой границе, когда JSON от сервера складывают в один текст «Не удалось сохранить», а компонент уже не знает, какой input пометить.

Короткая карта ответственности для формы регистрации
СлойЧто проверяетЧего не обещаетПроверяемый результат
HTML-контролrequired, формат email, длину и patternЗанятость адреса, права, транзакциюinput.validity.valid и понятная локальная подсказка
Клиентский кодПорядок состояний, актуальность ответа, привязку поля к ошибкеДостоверность данных в базеУ ошибки есть ключ поля, а старый ответ не меняет новый ввод
APIНормализацию, занятость, права и правила сохраненияКак экран озвучит текстСтруктурированный ответ с кодом и ключом поля
РазметкаLabel, описание, видимость ошибки, семантику invalidПроверку бизнес-правилаОшибка доступна зрительно и связана с нужным контролом

Такое деление сразу уменьшает расхождение. Не нужно копировать серверное правило в JavaScript, если клиенту достаточно проверить непустое значение и форму строки. Но имя поля и код ошибки должны быть стабильны. Сервер может изменить человеческий текст по локали, а email_taken и ключ email остаются данными, по которым интерфейс выбирает место вывода.

Минимальный договор между формой и API

Практичный ответ ошибки не обязан повторять спецификацию целиком. Важно, чтобы в нём не смешивались поле и общий сбой. В этом примере массив fields хранит ошибки, которые можно показать рядом с input, а form — ошибку, не принадлежащую конкретному полю: например, конфликт состояния формы. HTTP-статус и общая структура ответа — договор API; показанный ключ fields — локальное решение команды, а не поле, навязанное браузером.

// Пример ответа API при POST /api/account.
// Это контракт приложения, а не встроенный формат браузера.
{
  "code": "VALIDATION_FAILED",
  "fields": {
    "email": [
      { "code": "email_taken", "message": "Этот адрес уже используется" }
    ]
  },
  "form": []
}

На клиенте не стоит искать текстом «адрес» или «занят». Нужен небольшой адаптер, который принимает этот контракт и отдаёт одну карту ошибок. Если сервер прислал неизвестный ключ, адаптер не должен silently приклеивать его к первому полю. Его лучше оставить в form, записать в диагностический лог проекта и добавить явную обработку после согласования контракта. Так опечатка e-mail вместо email не превратится в ложное зелёное состояние.

function mapServerErrors(payload, knownFields) {
  var mapped = { fields: {}, form: [] };
  var fieldErrors = payload && payload.fields ? payload.fields : {};

  Object.keys(fieldErrors).forEach(function (name) {
    var first = fieldErrors[name] && fieldErrors[name][0];
    var message = first && first.message;

    if (knownFields.indexOf(name) === -1 || !message) {
      mapped.form.push('Сервер вернул ошибку без известного поля');
      return;
    }

    mapped.fields[name] = message;
  });

  return mapped;
}

var errors = mapServerErrors(apiPayload, ['email', 'password']);
// errors.fields.email === 'Этот адрес уже используется'

У этого кода есть намеренное ограничение: он не решает локализацию, несколько сообщений на поле или вложенные массивы. Для конкретной формы сначала договоритесь, нужен ли один первый текст или список. Если API всегда отдаёт список, не обрезайте его случайно; если продукту нужен один короткий совет, пусть сервер или отдельный formatter выбирает его явно. Главное — не передавать сырое сообщение в HTML как разметку: текст ошибки должен остаться текстом.

Разметка: ошибка должна принадлежать полю

Красная рамка сама по себе не объясняет проблему. У поля должен быть видимый label, постоянная подсказка и отдельный контейнер ошибки. aria-describedby связывает input с описывающими элементами по ID. Когда состояние невалидно, добавляем aria-invalid="true" и ссылку aria-errormessage на видимый текст. В WAI-ARIA эти атрибуты работают вместе: сообщение не нужно прятать от человека, который пользуется ассистивной технологией.

<label for="email">Почта</label>
<input
  id="email"
  name="email"
  type="email"
  required
  autocomplete="email"
  aria-describedby="email-hint email-error"
  aria-errormessage="email-error"
  aria-invalid="true"
  value="user@example.test"
>
<p id="email-hint">Укажем адрес для входа.</p>
<p id="email-error" role="alert">Этот адрес уже используется</p>

В валидном состоянии aria-invalid убираем или ставим в false, а контейнер ошибки не оставляем с пустой ролью alert. Если строка ошибки меняется динамически, короткое уведомление может быть живой областью, но не надо превращать каждое нажатие клавиши в срочное объявление. Для проверки формы на отправке достаточно показать текст у поля и, при необходимости, дать краткий общий статус. Фокус переносим только по осознанному правилу интерфейса, обычно на первое невалидное поле после submit.

Ниже не снимок Accessibility tree из браузера. Это ожидаемая семантическая структура той разметки, которую нужно проверить в DevTools и реальным скринридером проекта. Такой список полезен до запуска: он показывает, какое доказательство искать, и не выдаёт ожидание за измерение.

Ожидаемая семантика разметки при серверной ошибке
УзелИмя или состояниеОткуда берётсяЧто проверять в реальном браузере
Текстовое полеИмя «Почта», значение user@example.test, invalidlabel и aria-invalidПоле доступно по Tab и имеет имя label
Описание«Укажем адрес для входа»aria-describedbyПодсказка связана с тем же ID, что указан у input
Ошибка«Этот адрес уже используется»aria-errormessage и видимый контейнерТекст не скрыт и относится к email, а не к соседнему input
Кнопка«Сохранить» и её доступное состояниеНативный buttonКлавиатурная отправка не блокирует возможность исправить поле

Поздний ответ не имеет права менять новый ввод

Другая частая причина «прыгающей» ошибки — асинхронная проверка. Пользователь ввёл ivan, запрос ушёл на сервер; затем он быстро исправил на ivanka. Если первый ответ, «логин занят», возвращается последним, наивный then() запишет красную ошибку поверх нового значения. Скорость сети не даёт порядка, на который можно опереться. Владельцем актуальности должен быть идентификатор версии поля или запроса.

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

var lastRequestId = 0;

function checkLogin(value, checkAvailability) {
  var requestId = lastRequestId + 1;
  lastRequestId = requestId;
  render({ value: value, phase: 'checking', error: '' });

  return checkAvailability(value).then(function (answer) {
    if (requestId !== lastRequestId) return; // ответ относится к старому вводу

    render({
      value: value,
      phase: answer.available ? 'valid' : 'invalid',
      error: answer.available ? '' : 'Этот логин уже занят',
    });
  });
}

Номер запроса должен жить рядом с состоянием конкретного поля или формы, а не в глобальной переменной всего сайта. Для нескольких строк таблицы, двух вкладок редактора или нескольких экземпляров компонента глобальный счётчик снова создаст чужое влияние. В простом модуле это замыкание; во фреймворке — состояние экземпляра. Критерий тот же: обработчик ответа проверяет, что он всё ещё работает с текущей версией значения.

Схема договора валидации формы: HTML даёт раннюю проверку, API возвращает ошибку с ключом поля, а клиент привязывает её к доступной разметке и отвергает устаревший ответ
Граница проходит не между «клиентом и сервером вообще», а между ограничением, контрактом ошибки и отображением конкретного поля.

Маршрут внедрения на одной форме

  1. Выписать поля формы и отдельно назвать: проверка браузера, проверка API, общий сбой формы. Не начинаем с копирования всей серверной логики в JavaScript.
  2. Согласовать с API стабильные ключи полей и коды ошибок. Для каждого ключа выбрать место в UI; неизвестный ключ не приклеивать к произвольному input.
  3. Добавить нативные ограничения там, где они честны: required, тип, длина, pattern. Проверить, что disabled не исключает нужное поле из constraint validation по ошибке.
  4. Сделать у каждого поля label, описание и контейнер ошибки с устойчивыми ID. На невалидном состоянии выставлять aria-invalid и показывать текст, а не только менять цвет.
  5. Для асинхронной проверки хранить номер актуального запроса. Создать fixture, где старый ответ приходит позже нового, и ожидать, что он не меняет состояние.
  6. На submit проверить сначала HTML-ограничения, затем ответ API, затем фокус и текст первого поля с ошибкой. Готовность — ошибка видна у правильного поля и исчезает только после новой валидной версии значения.

Что проверить до выпуска

Нужно проверить не один счастливый submit, а границы. Отправьте пустое поле, неверный формат, ошибку API с известным ключом, ошибку API с неизвестным ключом и два ответа в обратном порядке. Проверьте клавиатуру: label не потерян, ошибка не видна только по цвету, а после submit понятно, что именно требует исправления. Если форма живёт в модальном окне, дополнительно проверьте, что фокус не уходит под него при появлении текста ошибки.

  • HTML-валидация может отличаться между браузерами в тексте встроенного сообщения. Если нужен единый текст, используйте свою видимую ошибку, но не отменяйте полезные нативные ограничения без причины.
  • Сервер всё равно проверяет вход. Нельзя считать checkValidity() защитой API: запрос можно составить вне вашей страницы.
  • Асинхронную проверку не стоит запускать на каждую букву без порога и задержки. Сначала убедитесь, что локальный формат уже проходит; затем используйте debounce и защиту версии.
  • Не ставьте role="alert" на целую форму или длинный список. Это даст шум вместо понятного сообщения; у поля нужен конкретный текст.
  • Ответ API с несколькими ошибками требует решения о порядке. Полезно сохранить порядок полей формы, а не полагаться на порядок ключей объекта.

Итог

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

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

  • HTML Standard: Constraints and Constraint Validation API — модель ограничений формы, validatable-контролы, validity, setCustomValidity(), checkValidity() и reportValidity()
  • HTML Standard: Form submission — отправка формы — отдельный алгоритм браузера; клиентские ограничения не подменяют проверку на сервере
  • WAI-ARIA 1.1: aria-describedby — связь контрола с одним или несколькими элементами описания по ID
  • WAI-ARIA 1.1: aria-errormessagearia-errormessage связан с aria-invalid; актуальный текст ошибки должен быть доступен пользователю
  • WAI-ARIA 1.1: alert role — семантика срочного, но не переносящего фокус сообщения; применять только к краткому изменению статуса
  • DOM Standard: AbortController — контроллер создаёт AbortSignal и посылает ему abort; это отмена транспорта, а не проверка актуальности состояния сама по себе
  • Fetch Standard — модель запроса, ответа и интеграция с abort signal для клиентов, поддерживающих Fetch