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

Поздний ответ не должен перезаписывать свежийПоздний ответ не должен перезаписывать свежийВремя идёт слева направоА: предыдущий вводпоздний ответБ: новый вводответ ББ → показатьА → уже устарелОтложенный запуск ограничивает число запросов. Номер защищает результат.
Ответ старого запроса может прийти позже нового. Перед выводом сверяем, относится ли результат к последнему вводу; задержка запуска решает другую задачу.

Воспроизведите два ответа в обратном порядке

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

Мы специально создадим такой источник: ответ для «ко» придёт через 1400 мс, для остальных строк — через 150 мс. Задержка дебаунса будет 300 мс. Это условия учебного примера, а не рекомендуемые универсальные значения для любого продукта.

Понадобятся JavaScript с промисами и модулями, базовый DOM, локальный Node.js и браузер. Создайте index.html, api.mjs, state.mjs, app.js, server.mjs и check.mjs в отдельной папке. Пакетов и настоящего API не потребуется.

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

Сохраните index.html:

<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Поиск со свежими ответами</title>
<style>
body { font: 1.1rem/1.5 system-ui; max-width: 42rem; margin: 2rem auto; padding: 1rem; }
input, button { font: inherit; max-width: 100%; }
:focus-visible { outline: 3px solid #164bbb; outline-offset: 3px; }
</style>
<h1>Поиск учебных материалов</h1>
<label for="query">Название, минимум две буквы</label>
<input id="query" type="search" autocomplete="off" maxlength="80" aria-describedby="hint">
<p id="hint">Наберите «ко», подождите полсекунды и допишите «д».</p>
<p id="status" role="status"></p>
<ul id="results"></ul>
<button type="button" id="retry" hidden>Повторить поиск</button>
<script type="module" src="app.js"></script>
</html>

Сделайте источник с управляемыми задержками

В api.mjs поместите:

const titles = ['Код и события', 'Кот и CSS', 'Коллекции JavaScript'];

// Имитируем API с разными задержками. Сигнал намеренно не принимается.
export function search(query) {
  const delay = query === 'ко' ? 1400 : 150;
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (query === 'ошибка') {
        reject(new Error('Учебный сбой поиска'));
        return;
      }
      resolve(titles.filter((title) =>
        title.toLocaleLowerCase('ru').includes(query.toLocaleLowerCase('ru'))));
    }, delay);
  });
}

Для строки «ко» источник найдёт все три материала. Для «код» — только «Код и события». Если эти ответы придут в обратном порядке, разницу будет видно по количеству строк. Запрос «ошибка» завершится отклонённым промисом, а запрос «рыба» даст пустой массив.

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

Увидьте ограничение одного таймера

Сохраните ошибочный контрольный вариант в broken.js. После создания остальных файлов запустите сервер из раздела ниже, в index.html временно замените src="app.js" на src="broken.js" и обновите страницу. Подключайте только один из этих скриптов: оба назначают обработчики одному полю. Контрольный вариант полностью объявляет элементы и обновляет список, но намеренно не проверяет свежесть ответа.

import { search } from './api.mjs';

const input = document.querySelector('#query');
const status = document.querySelector('#status');
const list = document.querySelector('#results');
let timer;

input.addEventListener('input', () => {
  clearTimeout(timer);
  const query = input.value.trim();
  list.replaceChildren();
  if (query.length < 2) {
    status.textContent = 'Введите минимум две буквы.';
    return;
  }
  status.textContent = 'Ожидаем паузу…';
  timer = setTimeout(async () => {
    status.textContent = 'Ищем…';
    try {
      const items = await search(query);
      list.replaceChildren(...items.map((title) => {
        const li = document.createElement('li');
        li.textContent = title;
        return li;
      }));
      status.textContent = `Для «${query}» найдено: ${items.length}.`;
    } catch {
      status.textContent = 'Поиск не удался.';
    }
  }, 300);
});

После опыта верните src="app.js" и обновите страницу: далее используется исправленный контроллер.

clearTimeout отменяет ещё не выполненный обработчик таймера. Когда обработчик уже вызвал search, очистка прежнего таймера не возвращает запрос обратно. Оба промиса продолжают выполняться.

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

Если увеличить задержку дебаунса, сбой может встречаться реже, но останется возможным: пользователь способен сделать ещё более длинную паузу, а сеть — задержать первый ответ ещё сильнее. Следовательно, менять следует правило записи результата.

Дайте каждому изменению ввода номер

Создайте state.mjs:

export function createFreshness() {
  let version = 0;
  return {
    invalidate() { version += 1; return version; },
    isCurrent(ticket) { return ticket === version; },
  };
}

export function createSearchController({ request, render, wait = 300 }) {
  const freshness = createFreshness();
  let timer;
  let query = '';
  let composing = false;

  function update(raw) {
    clearTimeout(timer);
    // Отзываем право старого ответа менять экран уже при изменении ввода.
    const ticket = freshness.invalidate();
    query = raw.trim().slice(0, 80);
    if (composing) {
      render({ type: 'idle', query, message: 'Завершите ввод символа.' });
      return;
    }
    if (query.length < 2) {
      render({ type: 'idle', query, message: 'Введите минимум две буквы.' });
      return;
    }
    render({ type: 'waiting', query, message: 'Ожидаем паузу при вводе…' });
    timer = setTimeout(async () => {
      if (!freshness.isCurrent(ticket)) return;
      render({ type: 'loading', query, message: 'Ищем…' });
      const requestedQuery = query;
      try {
        const items = await request(requestedQuery);
        if (!freshness.isCurrent(ticket)) return;
        render({ type: 'success', query: requestedQuery, items });
      } catch (error) {
        if (!freshness.isCurrent(ticket)) return;
        render({ type: 'error', query: requestedQuery, message: 'Поиск не удался. Повторите попытку.' });
      }
    }, wait);
  }

  return {
    update,
    retry() { update(query); },
    compositionStart() { composing = true; update(query); },
    compositionEnd(value) { composing = false; update(value); },
  };
}

createFreshness хранит счётчик. Каждый вызов update увеличивает его и получает собственный номер, который мы назвали ticket. После ожидания сравниваем сохранённый номер с текущим. Если появился новый ввод, прежняя операция теряет право менять экран.

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

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

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

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

Значение requestedQuery фиксируется при старте операции. Оно используется в сообщении результата, поэтому пользователь видит, к какому тексту относится выдача. Это полезная страховка даже при корректной проверке номера: разные стадии операции становятся явными.

Подключите поле, статус и повтор

Сохраните app.js:

import { search } from './api.mjs';
import { createSearchController } from './state.mjs';

const input = document.querySelector('#query');
const status = document.querySelector('#status');
const list = document.querySelector('#results');
const retry = document.querySelector('#retry');
const controller = createSearchController({ request: search, render });

function render(state) {
  retry.hidden = state.type !== 'error';
  // Чужая выдача исчезает уже при изменении ввода.
  list.replaceChildren();
  if (state.type === 'success') {
    list.append(...state.items.map((title) => {
      const li = document.createElement('li');
      li.textContent = title;
      return li;
    }));
    status.textContent = state.items.length
      ? `Для «${state.query}» найдено: ${state.items.length}.`
      : `Для «${state.query}» ничего не найдено.`;
    return;
  }
  status.textContent = state.message;
}

input.addEventListener('input', () => controller.update(input.value));
input.addEventListener('compositionstart', () => controller.compositionStart());
input.addEventListener('compositionend', () => controller.compositionEnd(input.value));
retry.addEventListener('click', () => controller.retry());
controller.update('');

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

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

Кнопка повтора вызывает update с последней нормализованной строкой. Возникает новая попытка с новым номером. В стенде строка «ошибка» падает всегда; повтор не обещает искусственного успеха. Чтобы проверить путь восстановления, измените текст на «код» и дождитесь выдачи.

Обработчики compositionstart и compositionend учитывают ввод символа в несколько этапов, например через редактор ввода. Пока составление продолжается, мы обновляем актуальность, но запрос не запускаем. После завершения используется итоговое значение поля. Если браузер затем отправит ещё один input, таймер будет переназначен для той же строки; лишний запрос не успеет стартовать.

Фокус мы программно не перемещаем. Человек продолжает набирать в поле, а role="status" сообщает текущее состояние. Кнопка повтора доступна через Tab.

Откройте страницу через HTTP

Сохраните server.mjs:

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const files = new Map([
  ['/', ['index.html', 'text/html; charset=utf-8']],
  ['/api.mjs', ['api.mjs', 'text/javascript; charset=utf-8']],
  ['/broken.js', ['broken.js', 'text/javascript; charset=utf-8']],
  ['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
  ['/state.mjs', ['state.mjs', 'text/javascript; charset=utf-8']],
]);
const server = createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  const file = files.get(url.pathname);
  if (!file) { res.writeHead(404).end('Not found'); return; }
  try {
    const body = await readFile(new URL(file[0], import.meta.url));
    res.writeHead(200, { 'Content-Type': file[1] });
    res.end(body);
  } catch {
    res.writeHead(500).end('Cannot read file');
  }
});
server.listen(4173, '127.0.0.1', () => {
  console.log('Open http://127.0.0.1:4173');
});

В папке примера запустите:

node server.mjs

Откройте http://127.0.0.1:4173. Если порт занят другим учебным примером, остановите тот процесс через Ctrl+C. Сервер предназначен для локального упражнения и не является готовым сервером рабочего приложения.

Введите «ко», дождитесь надписи «Ищем…» и допишите «д». Останется «Код и события». Дождитесь завершения старого запроса: число строк и подпись должны остаться прежними.

Снова наберите «ко» и, увидев надпись «Ищем…», удалите весь текст из поля до получения ответа. После любой задержки список должен оставаться пустым, а статус — просить минимум две буквы. Так мы проверяем, что прежняя операция теряет актуальность без запуска нового запроса. Этот случай стоит рассмотреть отдельно от успешной выдачи.

Добавляйте отмену как отдельный механизм

Если транспорт — Fetch, контроллер отмены можно создавать на каждую попытку и передавать его signal в запрос. При следующем вводе прежний контроллер вызывают через abort(). Подробнее об этом механизме — в материале «Отменяемый Fetch».

Номер проверяем отдельно. Через abort() мы просим поддерживающую сигнал операцию прекратить работу. Номер определяет, разрешено ли её завершению менять текущий интерфейс. Некоторые этапы обработки или другие источники могут не поддерживать сигнал; ответ также мог уже успеть прийти до вызова отмены. Поэтому правило свежести остаётся полезным и с контроллером.

В примере номер относится ко всему пользовательскому намерению, включая ожидание перед стартом. Если вместо имитации подключите fetch, сохраните увеличение номера в начале update и обе проверки после ожидания. Транспортную отмену добавляйте рядом, не перенося актуальность на более поздний момент.

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

Сохраните таблицу проверки гонок

Сценарий Ошибка без защиты Критерий исправления
Старый ответ пришёл последним Старые строки затёрли новые Последний ввод сохраняет свою выдачу
Ответ пришёл во время новой паузы Под новым текстом появились старые данные Номер меняется на input, до таймера
Поле очищено при загрузке Список снова заполнился Очистка отозвала актуальность старой попытки
Текст стал короче минимума Старый ответ проигнорировал ограничение Короткий ввод тоже меняет номер
Старый запрос завершился ошибкой Ошибка скрыла успешный новый результат Проверка свежести есть в catch
Старый finally убирает индикатор Новый запрос выглядит завершённым Любая запись в интерфейс проверяет номер
Пустой успешный ответ Состояние похоже на ошибку Показано «ничего не найдено» для названного запроса
Символ ещё составляется Ушёл запрос с промежуточным текстом До compositionend запросы не стартуют

Проверьте порядок без случайных задержек

Создайте check.mjs:

import assert from 'node:assert/strict';
import { createSearchController } from './state.mjs';
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const pending = new Map();
const states = [];
const controller = createSearchController({
  wait: 0,
  request(query) { return new Promise((resolve, reject) => pending.set(query, { resolve, reject })); },
  render(state) { states.push(state); },
});
controller.update('ко');
await pause(5);
controller.update('код');
await pause(5);
pending.get('код').resolve(['Код и события']);
await pause(0);
pending.get('ко').resolve(['Кот и CSS']);
await pause(0);
assert.equal(states.at(-1).query, 'код');
assert.deepEqual(states.at(-1).items, ['Код и события']);
controller.update('ошибка');
await pause(5);
controller.update('');
pending.get('ошибка').reject(new Error('old failure'));
await pause(0);
assert.equal(states.at(-1).type, 'idle');
controller.update('новый');
await pause(5);
controller.update('н');
pending.get('новый').resolve(['Старое']);
await pause(0);
assert.equal(states.at(-1).query, 'н');
assert.equal(states.at(-1).type, 'idle');
controller.compositionStart();
controller.update('язык');
await pause(5);
assert.equal(pending.has('язык'), false);
controller.compositionEnd('язык');
await pause(5);
pending.get('язык').resolve([]);
await pause(0);
assert.equal(states.at(-1).type, 'success');
assert.deepEqual(states.at(-1).items, []);
console.log('PASS: reverse responses, stale failure, short query, composition, empty');

Оставьте сервер работать в первом терминале. Во втором терминале перейдите в папку примера и запустите node check.mjs. Проверка сама управляет завершением промисов: сначала разрешает новый, затем старый. Поэтому ей не требуется надеяться на скорость сети. Дополнительно она отклоняет устаревшую попытку, очищает ввод и проверяет составление символов.

Для запуска таймеров оставили короткие ожидания. Они не имитируют реальные сетевые задержки и не проверяют внешний вид страницы. Момент появления статуса и работу клавиатуры проверьте вручную на локальной странице.

Для первого упражнения временно увеличивайте номер только внутри таймера и попытайтесь получить старый ответ в новой паузе. Верните увеличение в начало update. Затем самостоятельно добавьте переключатель темы поиска: смена темы должна отзывать актуальность предыдущего запроса, даже если текст поля не изменился. Состояние запроса тогда включает и строку, и тему.

За следующими задачами на события и асинхронный интерфейс можно перейти к курсу JavaScript в HTML Academy. А этот стенд сохраните с контролируемым обратным порядком ответов: он проверяет именно тот сбой, который трудно поймать обычным набором текста.