{ "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(). Проверка показала две отдельные задачи: форма не удерживала состояние запроса, а разблокировка интерфейса жила только в ветке успеха.

\n

Сценарий: второй POST в Network

\n

Это учебный сценарий, а не утверждение о конкретном проекте. На странице одна форма и один обработчик, но после частичной перерисовки разметки инициализация запускается повторно. В другом варианте один обработчик слушает click кнопки, а второй — submit формы. Клик даёт два пути отправки, а Enter обходит логику, которая стоит только на кнопке.

\n

Поворот расследования происходит, когда разработчик ставит breakpoint перед $.ajax(). Первый вызов записывает маркер слишком поздно или не записывает его вовсе. Если сеть отвечает ошибкой, код выходит через fail, но кнопка возвращается в исходное состояние только в done. Наблюдаемый результат зависит от жизненного цикла конкретного запроса, а не от одной строки.

\n

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

\n

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

\n

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

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

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

\n

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

\n

Перед правкой откройте вкладку Network и сравните фактический запрос с договором API. $(form).serialize() кодирует successful controls в URL-encoded строку. Поле без name не попадёт в payload. Выключенный контрол, неотмеченный 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>Почта <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
Состояния Ajax-формы: готова, запрос отправлен, успех или ошибка, затем освобождение интерфейса
Кнопка меняет состояние вместе с запросом, но освобождение формы не зависит от успеха.
\n

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

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

\n

Версионная граница

\n

Пример предполагает jQuery 1.7 или новее из-за .on() и .off(). Возврат jqXHR и методы Deferred, включая .always(), относятся к Ajax-контракту jQuery 1.5+. Свойство type оставлено вместо method, чтобы пример был совместим с версиями до jQuery 1.9. В более старом проекте сначала проверьте версию, затем отдельно протестируйте повторную инициализацию.

\n

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

\n
СимптомПричинаПроверкаДействие
Два одинаковых POSTПовторная подписка или click вместе с submitBreakpoint и число вызовов $.ajaxОставить один submit и namespace
Второй submit во время ожиданияНет локального маркераДважды вызвать $(form).trigger('submit') до ответаЗаписать маркер до Ajax
Неполный payloadНет name, контрол выключен или выбран файлСравнить payload Network с DOMИсправить имена или выбрать FormData
Кнопка навсегда выключенаРазблокировка только в doneИмитировать timeout, 500 и отказ сетиПеренести cleanup в always
Повтор после timeoutTimeout принят за доказательство отсутствия операцииПроверить журнал или endpoint статусаСначала узнать статус; для критичных операций добавить идемпотентный ключ
\n

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

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

Ограничения и критерий готовности

\n

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

\n

serialize() подходит для URL-кодированных полей, но не для файлов. Формат response.ok и response.orderNumber учебный; в рабочем проекте его заменяет фактическая схема ответа.

\n

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

\n

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

" }