Программирование13 сентября 2026 г.

Поиск по мере ввода: как не показать устаревший ответ fetch

Автор
Ненашев А.
На чтение
7 мин
Дата обновления
13.09.2026
Старт:С 24 августа
Срок обучения:14 месяцев
Fullstack-разработчик на Python
Практический онлайн-курс для тех, кто хочет: Создавать свои веб-проекты. Стать Python-разработчиком. Освоить программирование На странице курса на Училкин.ru: программа занятий, требования, отзывы и условия записи. На странице курса на Училкин.ru: программа занятий, требования, отзывы и условия записи.
Подробнее
103 896 ₽173 160 ₽
#COURSE##INNER#

Поиск по мере ввода: как не показать устаревший ответ fetch

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

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

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

Когда пользователь печатает, каждое изменение поля может запускать новый fetch(). Эти запросы независимы: один может уйти в сеть раньше, но завершиться позже. На задержку влияют кэш, длина ответа, работа сервера и просто текущая нагрузка. В итоге запрос для строки re может вернуться после запроса для react.

Если обработчик устроен так, что после каждого await fetch(...) и await response.json() он сразу вызывает render(), интерфейс верит последнему завершившемуся промису, а не последнему пользовательскому действию. Это и есть гонка: несколько асинхронных операций соревнуются за право обновить один и тот же участок состояния.

МоментДействиеЧто видит пользователь
0 мсВведено re, отправлен запрос AОжидание
120 мсВведено react, отправлен запрос BОжидание
320 мсЗапрос B завершился первымКорректные результаты для react
700 мсЗапрос A завершился позжеЭкран испорчен устаревшими результатами для re

Важный нюанс: сам по себе fetch() не считает ответ с кодом вроде 404 ошибкой уровня промиса. Промис отклоняется, например, при сетевой проблеме или при отмене через AbortController. Поэтому в рабочем коде нужно отдельно проверять response.ok, а отмену обрабатывать отдельно от обычных ошибок.

Как воспроизвести гонку на учебном примере

Учебный пример. Среда: обычный браузерный JavaScript без фреймворка. Представим строку поиска книг. Для короткого запроса сервер отвечает медленнее, потому что совпадений больше: re ждет 700 мс, а react — 200 мс. Пользователь быстро допечатывает слово, и более свежий ответ приходит раньше.

Упрощенный проблемный обработчик выглядит так:

const input = document.querySelector('#search');

input.addEventListener('input', async (event) => {
  const query = event.target.value.trim();

  if (!query) {
    render([]);
    return;
  }

  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const data = await response.json();
  render(data.items);
});

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

Проверяемый результат в учебном кейсе такой: сначала на экране появляются результаты для react, а затем они заменяются на результаты для re. Если вы можете добиться такого поведения искусственной задержкой, значит гонка воспроизведена и исправление будет видно сразу.

Почему одной отмены недостаточно

Первое естественное решение — отменять предыдущий запрос, когда начинается новый. Для этого у fetch() есть signal от AbortController. Это полезно: браузер перестает ждать уже неактуальный ответ, а ваш код получает AbortError и может молча завершиться без лишнего сообщения об ошибке.

Но отмена на клиенте не равна отмене работы сервера. Если запрос уже ушел, сервер мог начать обработку и даже закончить ее, несмотря на то что браузер больше не ждет ответ. Для фронтенда это означает простое правило: считать abort() способом остановить клиентскую цепочку ожидания и чтения ответа, а не универсальной кнопкой «отменить все везде».

Есть и второй практический нюанс. Даже когда вы вызываете abort(), у вас могут оставаться другие асинхронные шаги после fetch(): чтение тела ответа, разбор JSON, дополнительные await, локальная обработка данных. Если вы не проверяете актуальность после этих шагов, устаревший код все равно может дойти до render(). Поэтому надежная схема состоит из двух слоев защиты: отмена прежнего запроса плюс маркер актуальности текущего запроса.

Рабочая схема: AbortController плюс проверка актуальности

Удобный способ — держать счетчик запросов. Каждый новый ввод увеличивает номер, а локальная копия этого номера передается в конкретный запуск поиска. Перед обновлением интерфейса код сравнивает локальный номер с последним глобальным. Если номера не совпали, результат устарел и рендерить его нельзя.

const input = document.querySelector('#search');
let activeController = null;
let latestRequestId = 0;

input.addEventListener('input', () => {
  runSearch(input.value.trim());
});

async function runSearch(query) {
  latestRequestId += 1;
  const requestId = latestRequestId;

  activeController?.abort();

  if (!query) {
    render([]);
    setStatus('Введите запрос');
    return;
  }

  activeController = new AbortController();
  setStatus(`Ищем: ${query}`);

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: activeController.signal,
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const data = await response.json();

    if (requestId !== latestRequestId) {
      return;
    }

    render(data.items);
    setStatus(`Показаны результаты для: ${query}`);
  } catch (error) {
    if (error.name === 'AbortError') {
      return;
    }

    if (requestId !== latestRequestId) {
      return;
    }

    render([]);
    setStatus(`Ошибка: ${error.message}`);
  }
}

Что здесь происходит по шагам. Сначала новый ввод помечается как самый свежий через latestRequestId. Затем прежний контроллер отменяется, чтобы не тратить клиентские ресурсы на ненужный ответ. После получения данных есть явная проверка: если в процессе пользователь уже ввел другой запрос, этот запуск тихо завершается без перерисовки.

Практически полезный результат такой: в учебном кейсе с задержками 700 мс и 200 мс пользователь увидит только результаты для react. Старый ответ не попадет на экран ни при обычном завершении, ни при поздней ошибке. Это не гарантирует остановку серверной работы, но надежно защищает интерфейс от устаревшего состояния.

Как тестировать медленный ответ и что считать успехом

Если у вас еще нет удобного тестового бэкенда, для локальной проверки можно временно заменить реальный вызов на учебную функцию с тем же поведением, что и у fetch: она принимает signal, умеет завершаться с AbortError и возвращает объект с методом json(). Главное — задать разные задержки для разных запросов, чтобы порядок ответов стал обратным.

function mockSearchFetch(url, { signal } = {}) {
  const query = new URL(url, location.href).searchParams.get('q') ?? '';
  const delay = query.length <= 2 ? 700 : 200;

  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      resolve({
        ok: true,
        async json() {
          return {
            items: [
              `${query} — result 1`,
              `${query} — result 2`,
              `${query} — result 3`,
            ],
          };
        },
      });
    }, delay);

    signal?.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(new DOMException('Aborted', 'AbortError'));
    }, { once: true });
  });
}

Критерий успеха простой и проверяемый. Быстро введите короткий запрос, затем длинный. После исправления интерфейс должен показывать только длинный запрос и его результаты. В логах вы можете видеть отмены прежних запросов, но не должны видеть мигания списков назад. Отдельно проверьте очистку поля: при пустой строке список должен очищаться, а поздний ответ не должен возвращать старые данные на экран.

Если захотите закрепить тему глубже, в сохраненном описании каталога курса «Fullstack-разработчик на Python» упомянуты принципы SPA, Chrome DevTools, React и работа с сервером через Fetch API. Для этой задачи особенно полезно, когда в программе есть не только синтаксис, но и практика проверки поведения интерфейса при асинхронных запросах.

Курсы

147326
0,0
0 отзывов
Fullstack-разработчик на Python
  • С 24 августа
  • 14 месяцев
  • в группе с наставником
  • сертификат
103 896 ₽173 160 ₽
-40%
Перейти на сайт