diff --git a/editorial/agent-rewrites/346.json b/editorial/agent-rewrites/346.json index 5159d6b..c0f24ab 100644 --- a/editorial/agent-rewrites/346.json +++ b/editorial/agent-rewrites/346.json @@ -3,5 +3,5 @@ "slug": "editorial-2018-05-field-legacy-jquery", "title": "jQuery-форма без двойной отправки: состояние, jqXHR и честный отказ", "excerpt": "Как защитить legacy Ajax-форму от повторного submit, не потерять данные запроса и не оставить кнопку заблокированной после timeout или ошибки сети.", - "contentHtml": "
Пользователь нажимает «Оформить» один раз, но в Network появляются два одинаковых POST. Иногда второй запрос создаёт двойную заявку. Иногда сервер отвечает ошибкой, а интерфейс всё равно показывает успех. В другом варианте после timeout кнопка остаётся выключенной, и человек обновляет страницу, не зная, выполнилась ли операция. Цена ошибки зависит от домена: это может быть лишняя запись, повторное списание или потерянный заказ.
\nОбычно проблема начинается не с Ajax. Обработчик повторно подключили после замены разметки, форма слушает и click, и submit, либо защита стоит только на кнопке. Вызов с клавиатуры обходит такую защиту. Отдельная ошибка возникает, когда кнопку разблокируют только в ветке успеха.
У текущего DOM-экземпляра формы может быть не больше одного активного запроса. Обработчик должен ловить событие submit. До вызова $.ajax() он записывает локальный маркер и выключает кнопку. После этого код различает подтверждённый ответ, ошибку транспорта и завершение запроса. Снятие маркера и возврат кнопки живут в общей ветке always.
Этот контракт решает одну конкретную задачу: фронтенд не запускает второй Ajax до завершения первого. Он не делает операцию идемпотентной на сервере. После обновления страницы, из другой вкладки или из ручного HTTP-клиента сервер всё ещё может получить повтор. Для денег, заказов и других критичных действий нужна серверная защита по правилам предметной области.
\n| Состояние | Данные формы | Поведение |
|---|---|---|
| Готова | Маркер отсутствует | submit может создать один запрос |
| Запрос идёт | В .data() лежит маркер или jqXHR | Повторный submit сразу завершается |
| Подтверждённый успех | Ответ соответствует контракту API | Показываем результат, затем освобождаем форму |
| Ошибка или timeout | jqXHR отклонён или ответ невалиден | Показываем ошибку, затем освобождаем форму |
Маркер хранится на форме, а не в глобальной переменной. Поэтому две независимые формы на одной странице не блокируют друг друга. Значение true можно записать до старта Ajax, а после успешного создания запроса заменить на сам jqXHR. Для блокировки интерфейса используйте .prop('disabled', true): это динамическое свойство DOM, а не обычный HTML-атрибут.
Перед правкой откройте вкладку Network и сравните фактический запрос с договором API. $(form).serialize() кодирует успешные элементы формы. Поле без name не попадёт в строку. Выключенный контрол, неотмеченный checkbox и невыбранный radio тоже не попадут. Файлы через этот метод не передаются. Если выбрать одновременно форму и её дочерние поля, значения могут продублироваться.
Поэтому отправляйте саму форму и заранее проверьте её разметку. Скрытый CSRF-токен в примере обозначен как серверное значение. Это учебный фрагмент: endpoint, поля ответа и правила повтора нужно заменить реальным контрактом приложения.
\n<form id=\"order-form\" action=\"/order/create\" method=\"post\">\n <input type=\"hidden\" name=\"csrf_token\" value=\"серверное_значение\">\n <label>\n Почта\n <input name=\"email\" type=\"email\" required>\n </label>\n <label>\n <input name=\"agree\" type=\"checkbox\" value=\"Y\">\n Согласен с условиями\n </label>\n <button type=\"submit\">Оформить</button>\n <p class=\"js-order-message\" aria-live=\"polite\"></p>\n</form>\nОбработчик должен быть привязан к форме через пространство имён события. Это позволяет снять именно старую подписку и не задеть чужие обработчики. Вызов off().on() важен после частичной перерисовки страницы, когда один и тот же DOM-узел снова и снова инициализируется.
(function ($) {\n var requestKey = 'orderRequest';\n\n function showMessage($form, text, isError) {\n $form.find('.js-order-message')\n .toggleClass('is-error', isError)\n .text(text);\n }\n\n function unlock($form, $button) {\n $form.removeData(requestKey);\n $button.prop('disabled', false);\n }\n\n function submitOrder(event) {\n event.preventDefault();\n\n var $form = $(this);\n var $button = $form.find('[type=\"submit\"]');\n\n if ($form.data(requestKey)) {\n return;\n }\n\n $form.data(requestKey, true);\n $button.prop('disabled', true);\n showMessage($form, 'Отправляем…', false);\n\n var request;\n\n try {\n request = $.ajax({\n url: $form.attr('action'),\n type: $form.attr('method') || 'POST',\n data: $form.serialize(),\n dataType: 'json',\n timeout: 10000\n });\n } catch (error) {\n unlock($form, $button);\n showMessage($form, 'Не удалось начать запрос', true);\n return;\n }\n\n $form.data(requestKey, request);\n\n request\n .done(function (response) {\n if (!response || response.ok !== true ||\n typeof response.orderNumber === 'undefined') {\n showMessage($form, 'Сервер не подтвердил оформление', true);\n return;\n }\n\n showMessage($form, 'Заказ принят: ' + response.orderNumber, false);\n })\n .fail(function (xhr, status) {\n var text = status === 'timeout'\n ? 'Нет ответа вовремя. Проверьте статус заказа перед повтором.'\n : 'Не удалось отправить форму. Попробуйте позже.';\n\n showMessage($form, text, true);\n })\n .always(function () {\n unlock($form, $button);\n });\n }\n\n $('#order-form')\n .off('submit.orderForm')\n .on('submit.orderForm', submitOrder);\n}(jQuery));\nВызов submit покрывает клик по кнопке и нажатие Enter. Проверка маркера происходит до создания второго jqXHR. Кнопка даёт человеку видимый сигнал, но не служит единственной защитой: событие можно вызвать программно. Синхронный сбой старта обрабатывает catch. Сетевой отказ и timeout идут через fail. В любом из этих путей always возвращает форму в доступное состояние.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Два одинаковых POST | Повторная подписка или обработчики click и submit | Поставить breakpoint в обработчик и посмотреть число вызовов $.ajax | Оставить один submit, перед подпиской снять событие с namespace |
| Второй submit уходит во время ожидания | Нет локального маркера | Два раза вызвать form.submit до ответа и посчитать запросы | Записывать маркер до Ajax и сразу выходить при его наличии |
| Сервер получает неполные поля | Нет name, контрол выключен или выбран файл | Сравнить payload Network с DOM-формой | Исправить имена и выбрать FormData для файлов |
| Кнопка навсегда выключена | Разблокировка есть только в done | Имитировать timeout, 500 и отказ сети | Вынести удаление маркера и prop(false) в always |
| После timeout безопасно повторяют заказ | Timeout принят за доказательство отсутствия операции | Проверить журнал сервера или endpoint статуса | Сначала узнать статус; для критичных операций добавить идемпотентный ключ |
name, состояние checkbox/radio, CSRF-токен и способ загрузки файлов.submit, хранить маркер на конкретной форме и создать один jqXHR.Маркер живёт только в текущем DOM. Он не защищает вторую вкладку, перезагрузку, повтор из мобильного клиента и прямой HTTP-запрос. Клиентская блокировка также не отменяет уже принятую сервером операцию. После timeout нельзя обещать безопасный повтор без проверки статуса или идемпотентного ключа.
\nserialize() подходит для URL-кодированных полей, но не для файлов. Для multipart-данных нужен согласованный FormData и серверный разбор. Формат response.ok и response.orderNumber в коде учебный. В рабочем проекте его заменяет фактическая схема ответа. Примеры кода ограничены одной формой и не доказывают корректность всего приложения.
Решение готово, если два события submit подряд до ответа создают ровно один сетевой запрос; Enter проходит тем же путём; payload совпадает с контрактом API; подтверждённый ответ показывает результат; timeout, 4xx, 5xx и ошибка разбора показывают отказ; после каждого сценария маркер снят и кнопка доступна. Для операции с риском повтора отдельно зафиксирован серверный способ узнать, была ли она принята.
Представим форму заказа в legacy-приложении на jQuery в мае 2018 года. Клиент нажал «Оформить» один раз, разработчик открыл Network и увидел два одинаковых POST. Второй запрос создал двойную заявку, а после timeout кнопка осталась выключенной. Цена ошибки зависит от операции: это может быть лишняя запись, повторное списание или потерянный заказ.
\nСначала разработчик проверил серверный endpoint и решил, что проблема на стороне API. Затем он повторил действие с клавиатуры и сравнил число событий submit с числом вызовов $.ajax(). Проверка показала две отдельные задачи: форма не удерживала состояние запроса, а разблокировка интерфейса жила только в ветке успеха.
Это учебный сценарий, а не утверждение о конкретном проекте. На странице одна форма и один обработчик, но после частичной перерисовки разметки инициализация запускается повторно. В другом варианте один обработчик слушает click кнопки, а второй — submit формы. Клик даёт два пути отправки, а Enter обходит логику, которая стоит только на кнопке.
Поворот расследования происходит, когда разработчик ставит breakpoint перед $.ajax(). Первый вызов записывает маркер слишком поздно или не записывает его вовсе. Если сеть отвечает ошибкой, код выходит через fail, но кнопка возвращается в исходное состояние только в done. Наблюдаемый результат зависит от жизненного цикла конкретного запроса, а не от одной строки.
У текущего DOM-экземпляра формы может быть не больше одного активного запроса. Обработчик должен ловить событие submit. До вызова $.ajax() он записывает локальный маркер и выключает кнопку. После этого код различает подтверждённый ответ, ошибку транспорта и общий cleanup. Снятие маркера и возврат кнопки живут в общей ветке always.
Этот контракт решает одну задачу: фронтенд не запускает второй Ajax до завершения первого. Он не делает операцию идемпотентной на сервере. После обновления страницы, из другой вкладки или из ручного HTTP-клиента сервер всё ещё может получить повтор. Для денег, заказов и других критичных действий нужна серверная защита по правилам предметной области.
\n| Состояние | Данные формы | Поведение |
|---|---|---|
| Готова | Маркер отсутствует | submit может создать один запрос |
| Запрос идёт | В .data() лежит маркер или jqXHR | Повторный submit сразу завершается |
| Успех | Ответ соответствует контракту API | Показываем результат и освобождаем форму |
| Ошибка или timeout | jqXHR отклонён или ответ невалиден | Показываем отказ и освобождаем форму |
Маркер хранится на форме, а не в глобальной переменной. Поэтому две независимые формы не блокируют друг друга. Значение true можно записать до старта Ajax, а после создания запроса заменить на сам jqXHR. Для кнопки используйте .prop('disabled', true): это динамическое свойство DOM.
Перед правкой откройте вкладку Network и сравните фактический запрос с договором API. $(form).serialize() кодирует successful controls в URL-encoded строку. Поле без name не попадёт в payload. Выключенный контрол, неотмеченный checkbox и невыбранный radio тоже не попадут. Файлы через этот метод не передаются. Если выбрать форму вместе с её дочерними полями, значения могут продублироваться.
Отправляйте саму форму и заранее проверьте её разметку. Скрытый CSRF-токен ниже обозначен как серверное значение. Endpoint, поля ответа и правила повтора нужно заменить реальным контрактом приложения.
\n<form id='order-form' action='/order/create' method='post'>\n <input type='hidden' name='csrf_token' value='серверное_значение'>\n <label>Почта <input name='email' type='email' required></label>\n <label><input name='agree' type='checkbox' value='Y'> Согласен</label>\n <button type='submit'>Оформить</button>\n <p class='js-order-message' aria-live='polite'></p>\n</form>\nПривяжите обработчик к форме через пространство имён события. Тогда после повторной инициализации можно снять именно старую подписку. Маркер проверяется до создания запроса, а cleanup выполняется для каждого исхода.
\n(function ($) {\n var requestKey = 'orderRequest';\n\n function showMessage($form, text, isError) {\n $form.find('.js-order-message').toggleClass('is-error', isError).text(text);\n }\n\n function unlock($form, $button) {\n $form.removeData(requestKey);\n $button.prop('disabled', false);\n }\n\n function submitOrder(event) {\n event.preventDefault();\n var $form = $(this);\n var $button = $form.find('[type=submit]');\n if ($form.data(requestKey)) return;\n\n $form.data(requestKey, true);\n $button.prop('disabled', true);\n showMessage($form, 'Отправляем…', false);\n\n var request;\n try {\n request = $.ajax({\n url: $form.attr('action'),\n type: $form.attr('method') || 'POST',\n data: $form.serialize(),\n dataType: 'json',\n timeout: 10000\n });\n } catch (error) {\n unlock($form, $button);\n showMessage($form, 'Не удалось начать запрос', true);\n return;\n }\n\n $form.data(requestKey, request);\n request.done(function (response) {\n if (!response || response.ok !== true ||\n typeof response.orderNumber === 'undefined') {\n showMessage($form, 'Сервер не подтвердил оформление', true);\n return;\n }\n showMessage($form, 'Заказ принят: ' + response.orderNumber, false);\n }).fail(function (xhr, status) {\n var text = status === 'timeout'\n ? 'Нет ответа вовремя. Проверьте статус заказа перед повтором.'\n : 'Не удалось отправить форму. Попробуйте позже.';\n showMessage($form, text, true);\n }).always(function () {\n unlock($form, $button);\n });\n }\n\n $('#order-form').off('submit.orderForm').on('submit.orderForm', submitOrder);\n}(jQuery));\nСобытие submit покрывает клик по кнопке и нажатие Enter. Проверка маркера происходит до создания второго jqXHR. Сетевой отказ, timeout и ошибка разбора JSON идут через fail. В любом из этих путей always возвращает форму в доступное состояние. Кнопка даёт человеку сигнал, но не служит защитой от программного события.
Пример предполагает jQuery 1.7 или новее из-за .on() и .off(). Возврат jqXHR и методы Deferred, включая .always(), относятся к Ajax-контракту jQuery 1.5+. Свойство type оставлено вместо method, чтобы пример был совместим с версиями до jQuery 1.9. В более старом проекте сначала проверьте версию, затем отдельно протестируйте повторную инициализацию.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Два одинаковых POST | Повторная подписка или click вместе с submit | Breakpoint и число вызовов $.ajax | Оставить один submit и namespace |
| Второй submit во время ожидания | Нет локального маркера | Дважды вызвать $(form).trigger('submit') до ответа | Записать маркер до Ajax |
| Неполный payload | Нет name, контрол выключен или выбран файл | Сравнить payload Network с DOM | Исправить имена или выбрать FormData |
| Кнопка навсегда выключена | Разблокировка только в done | Имитировать timeout, 500 и отказ сети | Перенести cleanup в always |
| Повтор после timeout | Timeout принят за доказательство отсутствия операции | Проверить журнал или endpoint статуса | Сначала узнать статус; для критичных операций добавить идемпотентный ключ |
name, checkbox/radio, CSRF-токен и загрузку файлов.submit, хранить маркер на форме и создать один jqXHR.trigger, Enter, timeout, HTTP 500, невалидный JSON и успех.Маркер живёт только в текущем DOM. Он не защищает вторую вкладку, перезагрузку, мобильный клиент и прямой HTTP-запрос. Клиентская блокировка также не отменяет операцию, уже принятую сервером. После timeout нельзя обещать безопасный повтор без проверки статуса или идемпотентного ключа.
\nserialize() подходит для URL-кодированных полей, но не для файлов. Формат response.ok и response.orderNumber учебный; в рабочем проекте его заменяет фактическая схема ответа.
Решение готово, если два события submit до ответа создают ровно один сетевой запрос; Enter проходит тем же путём; payload совпадает с контрактом API; подтверждённый ответ показывает результат; timeout, 4xx, 5xx и ошибка разбора показывают отказ; после каждого сценария маркер снят и кнопка доступна.