AbortController: отмена fetch без устаревших результатов

AbortController: отмена fetch без устаревших результатов
Содержание статьи

Чтобы отменить fetch, создайте AbortController, передайте его signal в запрос и вызовите abort(), когда результат больше не нужен. В поисковой строке этого мало: обновлять данные, ошибку и индикатор загрузки должен только последний активный запрос.

Разбор Mentoring.digital посвящён именно этой гонке. Код использует обычный JavaScript и не зависит от React или другого фреймворка.

Почему ответы могут прийти не по порядку

Пользователь ввёл первый поисковый запрос, затем сразу уточнил его. Второй ответ может прийти раньше первого. Если оба обработчика без проверки записывают данные в интерфейс, старый ответ заменит более актуальный.

Отмена помогает прекратить ненужное ожидание и чтение ответа на клиенте. Но она не гарантирует отмену уже начавшейся работы сервера. Поэтому такой приём подходит для загрузки данных, а для операции вроде создания заказа нужны отдельные правила обработки повторов и результата операции.

Есть ещё менее заметная ошибка: старый запрос завершает свой блок finally и скрывает индикатор загрузки нового. Защищать нужно все изменения состояния, а не только отрисовку списка.

Свяжите запрос с сигналом отмены

const controller = new AbortController();
const response = await fetch("/api/search", {
  signal: controller.signal
});
// Из другого обработчика, пока запрос выполняется:
// controller.abort();

/api/search здесь обозначает маршрут вашего приложения, который нужно заменить существующим адресом. Вызов abort() при отмене по умолчанию приводит к отклонению связанной операции с AbortError. Отмена также может произойти во время чтения тела ответа, поэтому response.json() тоже должен находиться внутри try.

Один сигнал нельзя «включить обратно». Новый запрос получает новый контроллер. Если передать уже отменённый сигнал повторно, запрос сразу отклонится.

Оставьте право обновления последнему запросу

Ниже фабрика возвращает функцию search. Обработчики onData, onError и onLoading предоставляются приложением: они обновляют интерфейс. Параметр fetchImpl по умолчанию использует встроенный fetch; при тестировании его можно заменить.

export function createSearch({
  fetchImpl = fetch, onData, onError, onLoading
}) {
  let active = null;

  return async function search(url) {
    active?.abort();
    const controller = new AbortController();
    active = controller;
    onLoading(true);

    try {
      const response = await fetchImpl(url, {
        signal: controller.signal
      });
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      const data = await response.json();
      if (active === controller) onData(data);
    } catch (error) {
      if (!controller.signal.aborted &&
          active === controller) {
        onError(error);
      }
    } finally {
      if (active === controller) {
        active = null;
        onLoading(false);
      }
    }
  };
}

Переменная active хранит контроллер последнего вызова. Перед новым запросом старый контроллер получает отмену. Проверка active === controller не позволяет старому вызову перезаписать данные или закончить загрузку за новый.

В catch отдельно проверяется сигнал данного запроса. Ожидаемая отмена не показывается как ошибка пользователю. При этом HTTP 500 не считается успешным ответом: fetch сам по себе не отклоняет Promise из-за такого статуса, поэтому код явно проверяет response.ok.

Подключите функцию к интерфейсу

Создавайте функцию один раз для одного независимого блока результатов. В следующем фрагменте renderResults, showError и setLoading обозначают ваши функции отображения:

const search = createSearch({
  onData: renderResults,
  onError: showError,
  onLoading: setLoading
});

function onSearchInput(query) {
  const params = new URLSearchParams({ q: query });
  void search("/api/search?" + params);
}

Если создавать новую фабрику на каждый ввод символа, её active каждый раз будет пустым и предыдущий запрос не отменится. Если же использовать один экземпляр для двух независимых виджетов, они начнут отменять запросы друг друга.

Этот пример решает замену поискового запроса. Для компонента, который может исчезнуть со страницы, дополнительно нужен доступный извне метод отмены и его вызов при очистке компонента. Не считайте этот фрагмент готовой реализацией всего жизненного цикла приложения.

Проверьте гонку, ошибку и индикатор

Мы запустили пример в Node.js 24.19.0 со встроенным fetch и локальным HTTP-сервером. Сервер задерживал ответ на первый запрос и быстро отвечал на второй. Проверки подтвердили:

  • В обработчик данных попал только второй результат.
  • Старый запрос не выключил индикатор загрузки нового.
  • Ответ HTTP 500 дошёл до обработчика ошибки.
  • Повторное использование уже отменённого сигнала завершилось AbortError.
  • Серверный обработчик первого запроса всё равно завершил свою работу после клиентской отмены.

Это локальные проверки поведения, а не замер скорости браузера. В вашем интерфейсе дополнительно проверьте последовательность «ввод, новый ввод, очистка поля», исчезновение компонента и реальный формат JSON. В обработчиках отображения не должно быть своих необработанных исключений.

Отделяйте отмену от других механизмов

Debounce задерживает начало нового запроса и помогает уменьшить их число. AbortController отменяет уже начатую операцию. Проверка текущего контроллера определяет, чей результат можно применять. У каждого механизма своя задача; ни один из них не делает серверную операцию автоматически обратимой.

Не скрывайте все ошибки общим пустым catch: тогда вместе с отменой исчезнут сетевые сбои и неверный JSON. И не добавляйте бесконечный повтор запроса при отмене, иначе код сам будет создавать ненужную работу.

Для разбора похожей гонки удобно принести ментору по JavaScript небольшой пример с двумя запросами и журналом порядка событий. Так можно проверить не только синтаксис API, но и то, кому принадлежит состояние интерфейса.

Похожие статьи по данной теме