From 3e5ccf28a32b23ffdcc54f9dd927c811e4cccf3f Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 4 Sep 2026 00:54:30 +0300 Subject: [PATCH] editorial: revise article 346 --- editorial/agent-rewrites/346.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, либо защита стоит только на кнопке. Вызов с клавиатуры обходит такую защиту. Отдельная ошибка возникает, когда кнопку разблокируют только в ветке успеха.

\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

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

" + "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

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

" }