Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области. Цена ошибки — дубль операции, платежа или заявки.
Главный вопрос здесь такой: как сделать Ajax-форму, которая допускает один активный запрос в текущем DOM-экземпляре, честно показывает ошибку и в любом исходе возвращает интерфейс в готовое состояние? Это не заменяет серверную защиту операции. Зато убирает повторную отправку, созданную именно фронтенд-кодом, и даёт понятную точку диагностики.
Сначала определим, что именно отправляет форма
Метод .serialize() строит URL-кодированную строку из успешных контролов формы. Практическое следствие простое: у поля должен быть name, выключенные поля не попадут в набор, неотмеченный checkbox тоже не попадёт, а файл через .serialize() не отправится. Поэтому перед переписыванием обработчика стоит открыть Network и сравнить фактические данные запроса с тем, что ожидает сервер.
Я предпочитаю сериализовать сам <form>, а не объединять вручную все input на странице. Так не появляется дубль, когда в выборку по ошибке попали и форма, и её дочерние поля. Этот нюанс прямо описан в документации jQuery.
| Состояние формы | Что хранится на форме | Что видит пользователь | Следующий переход |
|---|---|---|---|
| Готова | Ключ orderRequest отсутствует | Кнопка доступна | submit создаёт один jqXHR |
| Запрос идёт | В .data() лежит маркер или jqXHR | Кнопка отключена | Повторный submit сразу выходит |
| Успех | Сервер вернул ожидаемый ответ | Показываем подтверждённый результат | always освобождает интерфейс |
| Ошибка или timeout | jqXHR отклонён | Показываем понятную ошибку | always освобождает интерфейс |
Минимальная разметка и граница обработчика
Пусть форма существует на странице постоянно. Скрытый токен и поля уже выдаёт сервер; пример не придумывает их значение. Важно только, чтобы каждое отправляемое поле имело имя, а кнопка была внутри формы.
<form id="order-form" action="/order/create" method="post">
<input type="hidden" name="csrf_token" value="серверное_значение">
<label>
Почта
<input name="email" type="email" required>
</label>
<label>
<input name="agree" type="checkbox" value="Y">
Согласен с условиями
</label>
<button type="submit">Оформить</button>
<p class="js-order-message" aria-live="polite"></p>
</form>
Клиентский замок я храню через .data() на самой форме. Это локально: на странице с двумя независимыми формами их состояния не смешаются. Для кнопки использую .prop("disabled", true), а не .attr, потому что disabled — динамическое свойство DOM; jQuery отдельно рекомендует .prop() для disabled и checked.
Рабочий обработчик
Пример написан для jQuery 3.x и использует done, fail и always у объекта jqXHR. Метод $.ajax() возвращает jqXHR с Promise-интерфейсом. В done мы разбираем ответ, в fail — транспортную ошибку, а в always выполняем действие, которому не нужны параметры ответа: освобождаем форму.
(function ($) {
var requestKey = 'orderRequest';
function showMessage($form, text, isError) {
$form.find('.js-order-message')
.toggleClass('is-error', isError)
.text(text);
}
function unlock($form, $button) {
$form.removeData(requestKey);
$button.prop('disabled', false);
}
function submitOrder(event) {
event.preventDefault();
var $form = $(this);
var $button = $form.find('[type="submit"]');
if ($form.data(requestKey)) {
return;
}
$form.data(requestKey, true);
$button.prop('disabled', true);
showMessage($form, 'Отправляем…', false);
var request;
try {
request = $.ajax({
url: $form.attr('action'),
type: $form.attr('method') || 'POST',
data: $form.serialize(),
dataType: 'json',
timeout: 10000
});
} catch (error) {
unlock($form, $button);
showMessage($form, 'Не удалось начать запрос', true);
return;
}
$form.data(requestKey, request);
request
.done(function (response) {
if (!response || response.ok !== true || typeof response.orderNumber === 'undefined') {
showMessage($form, 'Сервер не подтвердил оформление', true);
return;
}
showMessage($form, 'Заказ принят: ' + response.orderNumber, false);
})
.fail(function (xhr, status) {
var text = status === 'timeout'
? 'Сервер не ответил вовремя. Проверьте статус заказа перед повтором.'
: 'Не удалось отправить форму. Попробуйте позже.';
showMessage($form, text, true);
})
.always(function () {
unlock($form, $button);
});
}
$('#order-form')
.off('submit.orderForm')
.on('submit.orderForm', submitOrder);
}(jQuery));
Маркер true записывается до старта Ajax. После успешного создания jqXHR он заменяется на сам объект запроса: это удобно для отладки в консоли, но в примере не используется для отмены. Если $.ajax() не удалось начать синхронно, блок catch снимает маркер и возвращает кнопку. В обычном сетевом отказе код пойдёт через fail, а always всё равно вернёт форму к начальному состоянию.
Что именно проверяет этот код
Первая защита — обработчик submit, а не только click на кнопке. Поэтому Enter в поле проходит тем же путём. Вторая защита — состояние на форме. Если тот же submit придёт, пока есть маркер, функция выходит без второго $.ajax(). Третья — переключение кнопки. Оно даёт пользователю видимый сигнал и уменьшает шанс случайного повторного действия, но не является единственным условием корректности.
// Временный диагностический крючок для staging:
var sent = 0;
var originalAjax = $.ajax;
$.ajax = function () {
sent += 1;
return originalAjax.apply(this, arguments);
};
$('#order-form').trigger('submit');
$('#order-form').trigger('submit');
window.console.assert(sent === 1, 'Форма не должна запускать второй Ajax до завершения первого');
Такую подмену не надо оставлять в production. Она нужна, чтобы коротко воспроизвести контракт: два submit подряд должны создать один Ajax-вызов. Для реального теста вместо неё лучше замокать endpoint или проверять запросы в браузерном тесте. Но если счётчик сразу показывает два вызова, искать ошибку на сервере ещё рано.
Почему success не равен завершению интерфейса
Иногда старый код разблокирует кнопку только в callback успеха. Тогда при timeout, 500 или ошибке сети пользователь остаётся с выключенной формой и обновляет страницу. У jqXHR есть done, fail и always; документация jQuery рекомендует не анализировать аргументы в always, потому что при resolve и reject они различаются. Это как раз подходящее место для одинакового действия: убрать локальный маркер и вернуть кнопку.
Успешный HTTP-ответ тоже не обязательно означает, что операция готова. В примере договор сервера требует response.ok === true и номер заказа. Если API проекта отвечает иначе, нужно описать именно его контракт: какие поля обязательны, где лежит текст ошибки, можно ли повторить запрос и когда результат считается подтверждённым. Не стоит считать успехом любой JSON только потому, что запрос завершился без сетевой ошибки.
Последовательность внедрения
- Открыть текущую форму в браузере и зафиксировать фактический URL, метод, поля и ожидаемый ответ API.
- Проверить, что необходимые поля имеют
name; отдельно решить, как отправляются файлы, потому что.serialize()их не включает. - Перевести обработку на
submitи снять только прежнее событие формы через уникальное пространство имён. - Записать маркер до отправки, выключить кнопку через
.prop()и создать один jqXHR. - Разделить подтверждённый бизнес-ответ, ошибку транспорта и общее освобождение интерфейса.
- Проверить два submit подряд, timeout и ответ API с ошибкой; после каждого сценария форма должна либо показать результат, либо снова стать доступной.
Ограничения
- Клиентский маркер существует только в текущем DOM. Обновление страницы, второй браузер, ручный HTTP-запрос или повтор после timeout могут создать новый запрос. Критичная операция должна быть защищена на сервере по правилам конкретного домена.
- В примере нет загрузки файлов. Документация jQuery указывает, что file input не сериализуется через
.serialize(); для него нужен отдельный согласованный транспорт. - Не показываем номер заказа из любого произвольного ответа. Формат
okиorderNumber— пример контракта, который сервер должен подтвердить. - Timeout — это отсутствие ответа за выбранный интервал, а не доказательство, что сервер ничего не сделал. Поэтому текст ошибки не обещает безопасный повтор, пока проект не определил проверку статуса операции.
Итог
У legacy Ajax-формы должно быть немного состояний и ни одного скрытого перехода: формы нет в запросе, форма ждёт один jqXHR, затем показывает подтверждённый результат или ошибку и в любом случае освобождает интерфейс. Такой код не решает серверную идемпотентность, но перестаёт создавать собственные дубли и даёт читабельную точку для следующей диагностики.
Проверяемые источники
- jQuery API: jQuery.ajax() — jqXHR, обработчики done/fail/always, timeout и порядок завершения запроса
- jQuery API: .serialize() — какие поля формы попадают в URL-кодированную строку и почему файлы в неё не входят
- jQuery API: .prop() — динамические свойства disabled и checked в jQuery 1.6+
- jQuery API: .data() — хранение состояния рядом с DOM-узлом
- jQuery API: .removeData() — удаление ранее сохранённого значения из внутреннего хранилища jQuery
- jQuery API: deferred.always() — обработчик, который вызывается и после resolve, и после reject; подходит для освобождения интерфейса