8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"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><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></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>"
|
||
}
|