{ "index": 346, "slug": "editorial-2018-05-field-legacy-jquery", "title": "jQuery-форма без двойной отправки: состояние, jqXHR и честный отказ", "excerpt": "Как защитить legacy Ajax-форму от повторного submit, не потерять данные запроса и не оставить кнопку заблокированной после timeout или ошибки сети.", "contentHtml": "
Представим форму заказа в 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 и ошибка разбора показывают отказ; после каждого сценария маркер снят и кнопка доступна.