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>Представим форму заказа в legacy-приложении на jQuery в мае 2018 года. Клиент нажал «Оформить» один раз, разработчик открыл Network и увидел два одинаковых POST. Второй запрос создал двойную заявку, а после timeout кнопка осталась выключенной. Цена ошибки зависит от операции: это может быть лишняя запись, повторное списание или потерянный заказ.</p>\n<p>Сначала разработчик проверил серверный endpoint и решил, что проблема на стороне API. Затем он повторил действие с клавиатуры и сравнил число событий <code>submit</code> с числом вызовов <code>$.ajax()</code>. Проверка показала две отдельные задачи: форма не удерживала состояние запроса, а разблокировка интерфейса жила только в ветке успеха.</p>\n<h2>Сценарий: второй POST в Network</h2>\n<p>Это учебный сценарий, а не утверждение о конкретном проекте. На странице одна форма и один обработчик, но после частичной перерисовки разметки инициализация запускается повторно. В другом варианте один обработчик слушает <code>click</code> кнопки, а второй — <code>submit</code> формы. Клик даёт два пути отправки, а Enter обходит логику, которая стоит только на кнопке.</p>\n<p>Поворот расследования происходит, когда разработчик ставит breakpoint перед <code>$.ajax()</code>. Первый вызов записывает маркер слишком поздно или не записывает его вовсе. Если сеть отвечает ошибкой, код выходит через <code>fail</code>, но кнопка возвращается в исходное состояние только в <code>done</code>. Наблюдаемый результат зависит от жизненного цикла конкретного запроса, а не от одной строки.</p>\n<h2>Тезис: форма должна иметь явный контракт</h2>\n<p>У текущего DOM-экземпляра формы может быть не больше одного активного запроса. Обработчик должен ловить событие <code>submit</code>. До вызова <code>$.ajax()</code> он записывает локальный маркер и выключает кнопку. После этого код различает подтверждённый ответ, ошибку транспорта и общий cleanup. Снятие маркера и возврат кнопки живут в общей ветке <code>always</code>.</p>\n<p>Этот контракт решает одну задачу: фронтенд не запускает второй Ajax до завершения первого. Он не делает операцию идемпотентной на сервере. После обновления страницы, из другой вкладки или из ручного HTTP-клиента сервер всё ещё может получить повтор. Для денег, заказов и других критичных действий нужна серверная защита по правилам предметной области.</p>\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.</p>\n<h2>Что именно сериализуется</h2>\n<p>Перед правкой откройте вкладку Network и сравните фактический запрос с договором API. <code>$(form).serialize()</code> кодирует successful controls в URL-encoded строку. Поле без <code>name</code> не попадёт в payload. Выключенный контрол, неотмеченный 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>Почта <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></code></pre>\n<figure><img src='/assets/editorial/2018/jquery-ajax-form-contract.svg' alt='Состояния Ajax-формы: готова, запрос отправлен, успех или ошибка, затем освобождение интерфейса' /><figcaption>Кнопка меняет состояние вместе с запросом, но освобождение формы не зависит от успеха.</figcaption></figure>\n<h2>Рабочая граница обработчика</h2>\n<p>Привяжите обработчик к форме через пространство имён события. Тогда после повторной инициализации можно снять именно старую подписку. Маркер проверяется до создания запроса, а cleanup выполняется для каждого исхода.</p>\n<pre><code>(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));</code></pre>\n<p>Событие <code>submit</code> покрывает клик по кнопке и нажатие Enter. Проверка маркера происходит до создания второго <code>jqXHR</code>. Сетевой отказ, timeout и ошибка разбора JSON идут через <code>fail</code>. В любом из этих путей <code>always</code> возвращает форму в доступное состояние. Кнопка даёт человеку сигнал, но не служит защитой от программного события.</p>\n<h2>Версионная граница</h2>\n<p>Пример предполагает jQuery 1.7 или новее из-за <code>.on()</code> и <code>.off()</code>. Возврат <code>jqXHR</code> и методы Deferred, включая <code>.always()</code>, относятся к Ajax-контракту jQuery 1.5+. Свойство <code>type</code> оставлено вместо <code>method</code>, чтобы пример был совместим с версиями до jQuery 1.9. В более старом проекте сначала проверьте версию, затем отдельно протестируйте повторную инициализацию.</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).trigger('submit')</code> до ответа</td><td>Записать маркер до Ajax</td></tr><tr><td>Неполный payload</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>Перенести cleanup в <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>Найти подписки на форму и кнопку, затем удалить дублирующую логику.</li><li>Проверить <code>name</code>, checkbox/radio, CSRF-токен и загрузку файлов.</li><li>Перенести решение на <code>submit</code>, хранить маркер на форме и создать один <code>jqXHR</code>.</li><li>Развести бизнес-успех, HTTP/сетевую ошибку и cleanup.</li><li>Проверить два события через <code>trigger</code>, 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-кодированных полей, но не для файлов. Формат <code>response.ok</code> и <code>response.orderNumber</code> учебный; в рабочем проекте его заменяет фактическая схема ответа.</p>\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><li><a href=\"https://api.jquery.com/on/\" target=\"_blank\" rel=\"noopener\">jQuery API: .on()</a></li><li><a href=\"https://api.jquery.com/off/\" target=\"_blank\" rel=\"noopener\">jQuery API: .off()</a></li></ul>"
|
||
}
|