DarkRiDDeR10 мин

jQuery. Как не отправить legacy-форму дважды

JavaScriptjQueryAjax

Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области. Цена ошибки — дубль операции, платежа или заявки.

Главный вопрос здесь такой: как сделать Ajax-форму, которая допускает один активный запрос в текущем DOM-экземпляре, честно показывает ошибку и в любом исходе возвращает интерфейс в готовое состояние? Это не заменяет серверную защиту операции. Зато убирает повторную отправку, созданную именно фронтенд-кодом, и даёт понятную точку диагностики.

Сначала определим, что именно отправляет форма

Метод .serialize() строит URL-кодированную строку из успешных контролов формы. Практическое следствие простое: у поля должен быть name, выключенные поля не попадут в набор, неотмеченный checkbox тоже не попадёт, а файл через .serialize() не отправится. Поэтому перед переписыванием обработчика стоит открыть Network и сравнить фактические данные запроса с тем, что ожидает сервер.

Я предпочитаю сериализовать сам <form>, а не объединять вручную все input на странице. Так не появляется дубль, когда в выборку по ошибке попали и форма, и её дочерние поля. Этот нюанс прямо описан в документации jQuery.

Состояние формыЧто хранится на формеЧто видит пользовательСледующий переход
ГотоваКлюч orderRequest отсутствуетКнопка доступнаsubmit создаёт один jqXHR
Запрос идётВ .data() лежит маркер или jqXHRКнопка отключенаПовторный submit сразу выходит
УспехСервер вернул ожидаемый ответПоказываем подтверждённый результатalways освобождает интерфейс
Ошибка или timeoutjqXHR отклонёнПоказываем понятную ошибкуalways освобождает интерфейс
Состояния Ajax-формы: готова, запрос отправлен, успех или ошибка, затем обязательное освобождение интерфейса
Ветка успеха и ветка ошибки разные, а освобождение кнопки живёт в 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 только потому, что запрос завершился без сетевой ошибки.

Последовательность внедрения

  1. Открыть текущую форму в браузере и зафиксировать фактический URL, метод, поля и ожидаемый ответ API.
  2. Проверить, что необходимые поля имеют name; отдельно решить, как отправляются файлы, потому что .serialize() их не включает.
  3. Перевести обработку на submit и снять только прежнее событие формы через уникальное пространство имён.
  4. Записать маркер до отправки, выключить кнопку через .prop() и создать один jqXHR.
  5. Разделить подтверждённый бизнес-ответ, ошибку транспорта и общее освобождение интерфейса.
  6. Проверить два 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; подходит для освобождения интерфейса