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

Пользователь нажимает «Оформить» один раз, но в Network появляются два одинаковых POST. Иногда второй запрос создаёт двойную заявку. Иногда сервер отвечает ошибкой, а интерфейс всё равно показывает успех. В другом варианте после timeout кнопка остаётся выключенной, и человек обновляет страницу, не зная, выполнилась ли операция. Цена ошибки зависит от домена: это может быть лишняя запись, повторное списание или потерянный заказ.

\n

Обычно проблема начинается не с Ajax. Обработчик повторно подключили после замены разметки, форма слушает и click, и submit, либо защита стоит только на кнопке. Вызов с клавиатуры обходит такую защиту. Отдельная ошибка возникает, когда кнопку разблокируют только в ветке успеха.

\n

Тезис: форма должна иметь явный контракт

\n

У текущего DOM-экземпляра формы может быть не больше одного активного запроса. Обработчик должен ловить событие submit. До вызова $.ajax() он записывает локальный маркер и выключает кнопку. После этого код различает подтверждённый ответ, ошибку транспорта и завершение запроса. Снятие маркера и возврат кнопки живут в общей ветке always.

\n

Этот контракт решает одну конкретную задачу: фронтенд не запускает второй Ajax до завершения первого. Он не делает операцию идемпотентной на сервере. После обновления страницы, из другой вкладки или из ручного HTTP-клиента сервер всё ещё может получить повтор. Для денег, заказов и других критичных действий нужна серверная защита по правилам предметной области.

\n

Механизм по состояниям

\n
СостояниеДанные формыПоведение
ГотоваМаркер отсутствуетsubmit может создать один запрос
Запрос идётВ .data() лежит маркер или jqXHRПовторный submit сразу завершается
Подтверждённый успехОтвет соответствует контракту APIПоказываем результат, затем освобождаем форму
Ошибка или timeoutjqXHR отклонён или ответ невалиденПоказываем ошибку, затем освобождаем форму
\n

Маркер хранится на форме, а не в глобальной переменной. Поэтому две независимые формы на одной странице не блокируют друг друга. Значение true можно записать до старта Ajax, а после успешного создания запроса заменить на сам jqXHR. Для блокировки интерфейса используйте .prop('disabled', true): это динамическое свойство DOM, а не обычный HTML-атрибут.

\n

Что именно сериализуется

\n

Перед правкой откройте вкладку Network и сравните фактический запрос с договором API. $(form).serialize() кодирует успешные элементы формы. Поле без name не попадёт в строку. Выключенный контрол, неотмеченный checkbox и невыбранный radio тоже не попадут. Файлы через этот метод не передаются. Если выбрать одновременно форму и её дочерние поля, значения могут продублироваться.

\n

Поэтому отправляйте саму форму и заранее проверьте её разметку. Скрытый 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
\"Состояния
Кнопка меняет состояние вместе с запросом, но окончательное освобождение формы не зависит от успеха.
\n

Рабочая граница обработчика

\n

Обработчик должен быть привязан к форме через пространство имён события. Это позволяет снять именно старую подписку и не задеть чужие обработчики. Вызов off().on() важен после частичной перерисовки страницы, когда один и тот же DOM-узел снова и снова инициализируется.

\n
(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 возвращает форму в доступное состояние.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Два одинаковых 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 статусаСначала узнать статус; для критичных операций добавить идемпотентный ключ
\n

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

\n
  1. Зафиксировать исходный сценарий: URL, метод, payload, ответ и число запросов в Network.
  2. Найти все подписки на форму и кнопку. Удалить дублирующую логику, не снимая чужие события без namespace.
  3. Проверить поля: name, состояние checkbox/radio, CSRF-токен и способ загрузки файлов.
  4. Перенести решение на submit, хранить маркер на конкретной форме и создать один jqXHR.
  5. Развести бизнес-успех, HTTP/сетевую ошибку и общий cleanup. Не считать любой JSON успешным ответом.
  6. Проверить два submit подряд, Enter, timeout, HTTP 500, невалидный JSON и успешный ответ.
  7. Для операции с ценой повтора проверить серверную идемпотентность и сценарий «timeout после принятия запроса».
\n

Ограничения

\n

Маркер живёт только в текущем DOM. Он не защищает вторую вкладку, перезагрузку, повтор из мобильного клиента и прямой HTTP-запрос. Клиентская блокировка также не отменяет уже принятую сервером операцию. После timeout нельзя обещать безопасный повтор без проверки статуса или идемпотентного ключа.

\n

serialize() подходит для URL-кодированных полей, но не для файлов. Для multipart-данных нужен согласованный FormData и серверный разбор. Формат response.ok и response.orderNumber в коде учебный. В рабочем проекте его заменяет фактическая схема ответа. Примеры кода ограничены одной формой и не доказывают корректность всего приложения.

\n

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

\n

Решение готово, если два события submit подряд до ответа создают ровно один сетевой запрос; Enter проходит тем же путём; payload совпадает с контрактом API; подтверждённый ответ показывает результат; timeout, 4xx, 5xx и ошибка разбора показывают отказ; после каждого сценария маркер снят и кнопка доступна. Для операции с риском повтора отдельно зафиксирован серверный способ узнать, была ли она принята.

\n

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

" }