Files
progcode/editorial/agent-rewrites/346.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
16 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 346,
"slug": "editorial-2018-05-field-legacy-jquery",
"title": "jQuery-форма без двойной отправки: состояние, jqXHR и честный отказ",
"excerpt": "Как защитить legacy Ajax-форму от повторного submit, не потерять данные запроса и не оставить кнопку заблокированной после timeout или ошибки сети.",
"contentHtml": "<p>Пользователь нажимает «Оформить» один раз, но в Network появляются два одинаковых POST. Иногда второй запрос создаёт двойную заявку. Иногда сервер отвечает ошибкой, а интерфейс всё равно показывает успех. В другом варианте после timeout кнопка остаётся выключенной, и человек обновляет страницу, не зная, выполнилась ли операция. Цена ошибки зависит от домена: это может быть лишняя запись, повторное списание или потерянный заказ.</p>\n<p>Обычно проблема начинается не с Ajax. Обработчик повторно подключили после замены разметки, форма слушает и <code>click</code>, и <code>submit</code>, либо защита стоит только на кнопке. Вызов с клавиатуры обходит такую защиту. Отдельная ошибка возникает, когда кнопку разблокируют только в ветке успеха.</p>\n<h2>Тезис: форма должна иметь явный контракт</h2>\n<p>У текущего DOM-экземпляра формы может быть не больше одного активного запроса. Обработчик должен ловить событие <code>submit</code>. До вызова <code>$.ajax()</code> он записывает локальный маркер и выключает кнопку. После этого код различает подтверждённый ответ, ошибку транспорта и завершение запроса. Снятие маркера и возврат кнопки живут в общей ветке <code>always</code>.</p>\n<p>Этот контракт решает одну конкретную задачу: фронтенд не запускает второй Ajax до завершения первого. Он не делает операцию идемпотентной на сервере. После обновления страницы, из другой вкладки или из ручного HTTP-клиента сервер всё ещё может получить повтор. Для денег, заказов и других критичных действий нужна серверная защита по правилам предметной области.</p>\n<h2>Механизм по состояниям</h2>\n<table><thead><tr><th scope=\"col\">Состояние</th><th scope=\"col\">Данные формы</th><th scope=\"col\">Поведение</th></tr></thead><tbody><tr><td>Готова</td><td>Маркер отсутствует</td><td><code>submit</code> может создать один запрос</td></tr><tr><td>Запрос идёт</td><td>В <code>.data()</code> лежит маркер или <code>jqXHR</code></td><td>Повторный <code>submit</code> сразу завершается</td></tr><tr><td>Подтверждённый успех</td><td>Ответ соответствует контракту API</td><td>Показываем результат, затем освобождаем форму</td></tr><tr><td>Ошибка или timeout</td><td><code>jqXHR</code> отклонён или ответ невалиден</td><td>Показываем ошибку, затем освобождаем форму</td></tr></tbody></table>\n<p>Маркер хранится на форме, а не в глобальной переменной. Поэтому две независимые формы на одной странице не блокируют друг друга. Значение <code>true</code> можно записать до старта Ajax, а после успешного создания запроса заменить на сам <code>jqXHR</code>. Для блокировки интерфейса используйте <code>.prop('disabled', true)</code>: это динамическое свойство DOM, а не обычный HTML-атрибут.</p>\n<h2>Что именно сериализуется</h2>\n<p>Перед правкой откройте вкладку Network и сравните фактический запрос с договором API. <code>$(form).serialize()</code> кодирует успешные элементы формы. Поле без <code>name</code> не попадёт в строку. Выключенный контрол, неотмеченный checkbox и невыбранный radio тоже не попадут. Файлы через этот метод не передаются. Если выбрать одновременно форму и её дочерние поля, значения могут продублироваться.</p>\n<p>Поэтому отправляйте саму форму и заранее проверьте её разметку. Скрытый CSRF-токен в примере обозначен как серверное значение. Это учебный фрагмент: endpoint, поля ответа и правила повтора нужно заменить реальным контрактом приложения.</p>\n<pre><code>&lt;form id=\"order-form\" action=\"/order/create\" method=\"post\"&gt;\n &lt;input type=\"hidden\" name=\"csrf_token\" value=\"серверное_значение\"&gt;\n &lt;label&gt;\n Почта\n &lt;input name=\"email\" type=\"email\" required&gt;\n &lt;/label&gt;\n &lt;label&gt;\n &lt;input name=\"agree\" type=\"checkbox\" value=\"Y\"&gt;\n Согласен с условиями\n &lt;/label&gt;\n &lt;button type=\"submit\"&gt;Оформить&lt;/button&gt;\n &lt;p class=\"js-order-message\" aria-live=\"polite\"&gt;&lt;/p&gt;\n&lt;/form&gt;</code></pre>\n<figure><img src=\"/assets/editorial/2018/jquery-ajax-form-contract.svg\" alt=\"Состояния Ajax-формы: готова, запрос отправлен, успех или ошибка, затем освобождение интерфейса\" /><figcaption>Кнопка меняет состояние вместе с запросом, но окончательное освобождение формы не зависит от успеха.</figcaption></figure>\n<h2>Рабочая граница обработчика</h2>\n<p>Обработчик должен быть привязан к форме через пространство имён события. Это позволяет снять именно старую подписку и не задеть чужие обработчики. Вызов <code>off().on()</code> важен после частичной перерисовки страницы, когда один и тот же DOM-узел снова и снова инициализируется.</p>\n<pre><code>(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));</code></pre>\n<p>Вызов <code>submit</code> покрывает клик по кнопке и нажатие Enter. Проверка маркера происходит до создания второго <code>jqXHR</code>. Кнопка даёт человеку видимый сигнал, но не служит единственной защитой: событие можно вызвать программно. Синхронный сбой старта обрабатывает <code>catch</code>. Сетевой отказ и timeout идут через <code>fail</code>. В любом из этих путей <code>always</code> возвращает форму в доступное состояние.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Два одинаковых POST</td><td>Повторная подписка или обработчики <code>click</code> и <code>submit</code></td><td>Поставить breakpoint в обработчик и посмотреть число вызовов <code>$.ajax</code></td><td>Оставить один <code>submit</code>, перед подпиской снять событие с namespace</td></tr><tr><td>Второй submit уходит во время ожидания</td><td>Нет локального маркера</td><td>Два раза вызвать <code>form.submit</code> до ответа и посчитать запросы</td><td>Записывать маркер до Ajax и сразу выходить при его наличии</td></tr><tr><td>Сервер получает неполные поля</td><td>Нет <code>name</code>, контрол выключен или выбран файл</td><td>Сравнить payload Network с DOM-формой</td><td>Исправить имена и выбрать <code>FormData</code> для файлов</td></tr><tr><td>Кнопка навсегда выключена</td><td>Разблокировка есть только в <code>done</code></td><td>Имитировать timeout, 500 и отказ сети</td><td>Вынести удаление маркера и <code>prop(false)</code> в <code>always</code></td></tr><tr><td>После timeout безопасно повторяют заказ</td><td>Timeout принят за доказательство отсутствия операции</td><td>Проверить журнал сервера или endpoint статуса</td><td>Сначала узнать статус; для критичных операций добавить идемпотентный ключ</td></tr></tbody></table>\n<h2>Порядок внедрения</h2>\n<ol><li>Зафиксировать исходный сценарий: URL, метод, payload, ответ и число запросов в Network.</li><li>Найти все подписки на форму и кнопку. Удалить дублирующую логику, не снимая чужие события без namespace.</li><li>Проверить поля: <code>name</code>, состояние checkbox/radio, CSRF-токен и способ загрузки файлов.</li><li>Перенести решение на <code>submit</code>, хранить маркер на конкретной форме и создать один <code>jqXHR</code>.</li><li>Развести бизнес-успех, HTTP/сетевую ошибку и общий cleanup. Не считать любой JSON успешным ответом.</li><li>Проверить два submit подряд, Enter, timeout, HTTP 500, невалидный JSON и успешный ответ.</li><li>Для операции с ценой повтора проверить серверную идемпотентность и сценарий «timeout после принятия запроса».</li></ol>\n<h2>Ограничения</h2>\n<p>Маркер живёт только в текущем DOM. Он не защищает вторую вкладку, перезагрузку, повтор из мобильного клиента и прямой HTTP-запрос. Клиентская блокировка также не отменяет уже принятую сервером операцию. После timeout нельзя обещать безопасный повтор без проверки статуса или идемпотентного ключа.</p>\n<p><code>serialize()</code> подходит для URL-кодированных полей, но не для файлов. Для multipart-данных нужен согласованный <code>FormData</code> и серверный разбор. Формат <code>response.ok</code> и <code>response.orderNumber</code> в коде учебный. В рабочем проекте его заменяет фактическая схема ответа. Примеры кода ограничены одной формой и не доказывают корректность всего приложения.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Решение готово, если два события <code>submit</code> подряд до ответа создают ровно один сетевой запрос; Enter проходит тем же путём; payload совпадает с контрактом API; подтверждённый ответ показывает результат; timeout, 4xx, 5xx и ошибка разбора показывают отказ; после каждого сценария маркер снят и кнопка доступна. Для операции с риском повтора отдельно зафиксирован серверный способ узнать, была ли она принята.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://api.jquery.com/serialize/\" target=\"_blank\" rel=\"noopener\">jQuery API: .serialize()</a></li><li><a href=\"https://api.jquery.com/jQuery.ajax/\" target=\"_blank\" rel=\"noopener\">jQuery API: jQuery.ajax()</a></li><li><a href=\"https://api.jquery.com/prop/\" target=\"_blank\" rel=\"noopener\">jQuery API: .prop()</a></li></ul>"
}