From 6217aa47de87f4e3192de434d3d20dc0eeaa5988 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 23:45:17 +0300 Subject: [PATCH] Rewrite editorial article 315 --- editorial/agent-rewrites/315.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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

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

\n

Что именно проверяет каждый слой

\n

HTML отсекает очевидное: пустое обязательное поле, неверный тип, длину и pattern. У контрола есть объект validity. Методы checkValidity() и reportValidity() помогают проверить нативные ограничения формы. Это полезный ранний фильтр. Он не знает, занят ли логин, есть ли у пользователя право на действие или изменилось ли состояние записи на сервере.

\n

Клиентский код собирает состояния и решает, где показать результат. Он может очистить старую серверную ошибку после ввода, дождаться проверки доступности и не применить ответ старой версии. Но он не должен объявлять значение принятым только потому, что локальная проверка прошла. Запрос можно отправить вне страницы, а правила базы меняются независимо от JavaScript.

\n

API владеет нормализацией и бизнес-условиями. Ему не следует возвращать только строку «что-то не так»: экрану будет некуда её привязать. У ошибки нужен стабильный ключ поля и код. Человеческий текст остаётся текстом, а не идентификатором маршрутизации.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Поле зелёное, API отвечает 422Клиент проверил формат, сервер — бизнес-условиеСравнить локальные ограничения с контрактом ответаПоказать серверную ошибку у поля и оставить сервер источником истины
Ошибка относится к соседнему полюКод ищет текст или использует неизвестный ключПроверить карту field → error и список полейИзвестные ключи привязать к input, неизвестные оставить общей ошибкой
Старый ответ стирает новый вводОбработчик применяет любой завершившийся запросЗамедлить первый ответ и быстро изменить значениеСравнивать номер запроса или версию значения до render
Ошибка видна только красной рамкойНет текста, label или связи с контроломПроверить клавиатуру и accessibility treeДобавить видимое сообщение, aria-invalid и связь по ID
\n

Контракт ошибки должен указывать поле

\n

Для учебного примера представим ответ API при отправке формы. Это локальный договор приложения, а не встроенный формат браузера:

\n
{\n  \"code\": \"VALIDATION_FAILED\",\n  \"fields\": {\n    \"email\": [\n      { \"code\": \"email_taken\", \"message\": \"Этот адрес уже используется\" }\n    ]\n  },\n  \"form\": []\n}
\n

Клиенту нужен адаптер. Он принимает только известные имена полей и отделяет ошибку формы от ошибки input. Не ищите слово «занят» в сообщении. Не приклеивайте неизвестный ключ к первому полю: так ошибка API превращается в ложную подсказку.

\n
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']);
\n

Код учебный. Он выбирает первое сообщение и не решает локализацию, вложенные массивы или несколько ошибок на одном поле. В рабочей форме эти решения фиксируют отдельно. Текст сообщения вставляют как текст. Нельзя принимать его за HTML без явной, проверенной причины.

\n

Ошибка должна принадлежать текущему input

\n

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

\n
<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\" применяют к короткому динамическому статусу, а не ко всей форме.

\n

Поздний ответ проверяет не то значение

\n

Рассмотрим учебный сценарий без настоящей сети. Пользователь вводит ivan, запрос получает задержку 30 мс. Затем ввод меняется на ivanka, второй запрос получает задержку 5 мс. Если первый ответ означает «занято», он придёт позже. Обработчик должен знать, что его запрос устарел.

\n
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 или другом фреймворке принцип не меняется: обработчик сравнивает свой номер с актуальным состоянием владельца.

\n

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

\n
\"Схема
Граница проходит между локальным ограничением, контрактом API и отображением ошибки конкретного поля. Поздний ответ не должен менять состояние нового значения.
\n

Порядок внедрения

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

Ограничения

\n

Проверка доступности логина до сохранения не резервирует логин. Другой запрос может занять его между двумя операциями. Сервер обязан проверить условие в момент записи и вернуть ошибку поля. Нативный текст браузера может отличаться; единый текст можно показать самостоятельно, сохранив полезные ограничения HTML.

\n

Проверка версии защищает состояние интерфейса, но не делает операцию сохранения идемпотентной и не отменяет транзакцию. Для зависимых полей нужно решить, очищает ли изменение одного поля результат другого. Для нескольких сообщений нужно определить порядок и способ показа. ARIA не исправляет отсутствие label, понятного текста или корректного фокуса.

\n

Примеры выше учебные. Они не измеряют задержки, поддержку конкретного браузера или поведение реального API. Сценарий с задержками проверяет только порядок применения ответов. Разметку нужно проверить в целевом браузере, с клавиатурой и используемым скринридером.

\n

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

\n

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

\n

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

" + "excerpt": "Браузер проверяет формат, сервер принимает бизнес-решение, а асинхронный ответ должен относиться к текущему значению. Разбираем контракт ошибки, доступную разметку и воспроизводимую защиту от гонки запросов.", + "contentHtml": "

Форма показывает зелёную почту, пользователь нажимает «Сохранить», а API отвечает 422. В другом варианте человек быстро меняет логин: проверка нового значения возвращает «свободен», затем поздний ответ для старого рисует ошибку под новым. Цена ошибки — повторные попытки и неверное решение пользователя. Для регистрации или платежа это может закончиться отказом в корректной операции.

\n

Граница проходит между тремя задачами. Браузер быстро проверяет ограничения самого поля. Клиент связывает результат с текущим состоянием интерфейса. Сервер повторяет критичные проверки и решает, можно ли сохранить данные. Если ответ сервера не связан с именем поля и версией значения, одна новая регулярка расхождение не исправит.

\n

Что именно проверяет каждый слой

\n

HTML отсекает локальные нарушения: пустое обязательное поле, неподходящий тип, длину и шаблон. У элемента формы есть объект validity. Методы checkValidity() и reportValidity() проверяют ограничения формы; второй дополнительно просит браузер показать пользователю найденные проблемы. Это ранний фильтр, а не проверка состояния базы.

\n

Например, type=\"email\" проверяет синтаксис, который браузер считает адресом электронной почты. Атрибут required запрещает пустое значение, а minlength, maxlength и pattern задают локальные ограничения. Ни один из них не знает, занят ли логин, имеет ли пользователь право на действие или изменилось ли значение записи на сервере.

\n

Клиентский код владеет отображением и переходами состояния. После ввода он должен убрать ошибку, относящуюся к прежнему значению, а после ответа — проверить, что ответ всё ещё принадлежит этому полю и этой версии. Сервер владеет нормализацией, правами, ограничениями данных и окончательным результатом сохранения. Запрос можно отправить напрямую, поэтому браузерные ограничения не заменяют серверную проверку.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Поле зелёное, API отвечает 422Клиент проверил формат, сервер — бизнес-условиеСравнить локальные ограничения с кодом и телом ответаПоказать серверную ошибку у поля и оставить сервер источником истины
Ошибка относится к соседнему полюКод ищет слово в тексте или принимает неизвестный ключПроверить карту field → errorИзвестный ключ связать с input, неизвестный отправить в общий блок формы
Старый ответ стирает новый вводОбработчик применяет любой завершившийся запросЗамедлить первый ответ и изменить значение до его завершенияСравнить номер запроса или версию перед изменением состояния
Ошибка видна только рамкойНет видимого текста и связи с контроломПройти форму клавиатурой и проверить accessibility treeДобавить label, текст ошибки и ID-связь
\n

Контракт ответа должен указывать поле

\n

Формат ошибки ниже — договор конкретного приложения, а не встроенный формат браузера. Важны стабильные имена полей и машинные коды; сообщение предназначено для показа человеку и не должно использоваться как идентификатор маршрутизации.

\n
{\n  \"code\": \"VALIDATION_FAILED\",\n  \"fields\": {\n    \"email\": [\n      { \"code\": \"email_taken\", \"message\": \"Этот адрес уже используется\" }\n    ]\n  },\n  \"form\": []\n}
\n

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

\n
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\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

Ошибка должна принадлежать текущему input

\n

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

\n
<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 можно перевести на первое проблемное поле; короткий динамический статус следует объявлять отдельно и умеренно.

\n

Поздний ответ проверяет не то значение

\n

Рассмотрим автономный сценарий без настоящей сети. Пользователь вводит ivan, для него задана задержка 30 мс. Затем ввод меняется на ivanka, второй запрос задерживается на 5 мс. Второй ответ придёт первым и должен примениться. Первый ответ означает «логин занят», но к моменту его прихода уже относится к старому значению.

\n
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 и не вызывает изменение состояния. Код проверяет и номер запроса, и значение: проверка значения даёт дополнительную защиту от ошибки владельца, если счётчик был восстановлен неправильно.

\n

Счётчик должен принадлежать конкретному экземпляру поля или формы. Глобальный номер на всём сайте создаст взаимное влияние между двумя независимыми полями. В React, Vue или другом фреймворке правило то же: на input фиксируются новое значение и версия, а обработчик ответа получает актуальное состояние владельца перед render.

\n

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

\n
\"Схема
Валидация разделена между HTML, клиентским состоянием и API. Номер запроса не позволяет позднему ответу старого значения изменить текущий input.
\n

Submit — отдельный переход

\n

Проверка поля и сохранение — разные операции. При submit браузер может выполнить constraint validation. Затем клиент решает, есть ли локальная ошибка или незавершённая удалённая проверка. После этого запрос сохранения уходит на сервер, который снова проверяет весь вход.

\n

Для фазы checking заранее выберите политику: дождаться текущего результата, разрешить submit и принять окончательный ответ API или временно отключить кнопку с понятным объяснением. Нельзя объявлять форму готовой только потому, что регулярное выражение прошло. Предварительный запрос «логин свободен» не резервирует имя: другой клиент может занять его до сохранения.

\n

Ответ операции сохранения имеет приоритет над предварительной проверкой. Если сервер вернул конфликт, адаптер должен показать его у актуального отправленного поля. Если человек успел изменить поле до прихода ответа, старый результат снова нельзя рисовать над новым значением.

\n

Порядок внедрения и проверки

\n
  1. Выпишите поля формы и разделите для каждого локальное ограничение, серверное условие и общий сбой формы.
  2. Согласуйте с API стабильные ключи полей и коды ошибок. Неизвестный ключ направляйте в общий контейнер, а не к случайному input.
  3. Добавьте честные HTML-ограничения: required, тип, длину и pattern. Не копируйте в браузер всю бизнес-логику.
  4. Сделайте у каждого поля label, устойчивые ID подсказки и ошибки. Показывайте текст, а не только цвет.
  5. На каждом input очищайте старую серверную ошибку и увеличивайте версию. При старте async-проверки сохраните эту версию.
  6. В обработчике ответа сравните версию и, при необходимости, значение с актуальным состоянием до любого изменения UI.
  7. Проверьте submit для пустого значения, неверного формата, известной ошибки поля, неизвестного ключа, сетевой ошибки и ответов в обратном порядке.
  8. Пройдите сценарий клавиатурой и проверьте accessibility tree: имя поля, актуальный текст ошибки, aria-invalid и ID-связи должны описывать одно значение.
  9. Повторите критичную проверку перед сохранением на сервере и воспроизведите конфликт между положительной предварительной проверкой и фактическим POST.
\n

Ограничения

\n

Проверка доступности логина до сохранения не резервирует логин. Сервер обязан проверить условие в момент записи. Проверка версии защищает состояние интерфейса, но не делает сохранение идемпотентным, не отменяет транзакцию и не решает авторизацию. Для зависимых полей отдельно определите, очищает ли изменение одного поля результат другого.

\n

Если API не возвращает имя поля, нельзя достоверно показать ошибку под конкретным input. Не угадывайте поле по тексту сообщения: покажите нейтральную ошибку формы, сохраните код и договоритесь об изменении контракта. Если компонент уничтожен, поздний ответ должен завершиться без render. setCustomValidity() полезен для собственной локальной ошибки, но его нужно очистить при новом вводе и не использовать как замену серверному правилу.

\n

Учебный код доказывает только порядок двух promise. Он не измеряет задержки реального API, поддержку конкретного браузера, доступность выбранного компонента или поведение базы. Эти свойства проверяют на целевом контуре: с клавиатурой, Accessibility tree, сетевой ошибкой и конкурентным сохранением.

\n

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

\n

Форма готова, если тест с двумя обратными задержками оставляет последнее значение, его номер запроса, корректную фазу и пустую старую ошибку. Ответ для прежнего значения не вызывает render. После изменения input сообщение очищается. Известная ошибка API появляется у нужного поля, неизвестная — в общем блоке, а сервер отклоняет конфликт даже после положительной предварительной проверки.

\n

На реальном экране проверяющий должен пройти форму клавиатурой, увидеть метку и ошибку, открыть Accessibility tree и подтвердить связи по ID. Если этот прогон или серверный конфликт не проверены, готовность ограничивается локальным примером и не распространяется на весь пользовательский сценарий.

\n

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

" }