Главная / Статьи / Отмена устаревших операций в React: AbortController для смены треков

Отмена устаревших операций в React: AbortController для смены треков

Узнайте, почему пропущенные треки и устаревшие запросы продолжают влиять на интерфейс пользователя, как AbortController отменяет реальный запрос и как он дополняет механизмы debounce и throttle в React.

1747 слов

Когда пользователь меняет свое решение быстрее, чем сеть успевает отреагировать, любой запрос, который вы уже запустили, продолжает выполняться и с удовольствием выводит свой результат на экран. В поисковом поле это означает результаты по запросу, от которого пользователь отказался; в медиаплеере — короткую демонстрацию песни, которую он только что пропустил. Ожидание или ограничение скорости не решают эту проблему, поскольку работа уже в процессе выполнения. В этой статье показано, как AbortController действительно отменяет такую работу, как его интегрировать в эффект React и как он дополняет методы debounce и throttle, а не заменяет их.

Гонка: ответы приходят в неправильном порядке

Возьмём поле поиска. Пользователь вводит «ni», и отправляется запрос. Затем он вводит «ke», и следует второй запрос на поиск «nike». Если сервер работает медленнее с первым, более коротким запросом, его ответ приходит позже второго и заменяет правильные результаты устаревшими.

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

Метод дебаунсинга здесь не поможет. Он откладывает начало выполнения действий до тех пор, пока ввод не стабилизируется; он ничего не делает с уже запущенным запросом. Что не хватает, — это возможность отмены: указание уже начавшемуся процессу на прекращение работы.

Что идет не так при отсутствии отмены

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

  • Устаревшие данные берут верх. Старый ответ обрабатывается после нового и заменяет то, что отображается на экране или в плеере, либо начинает воспроизведение.
  • Расход ресурсов впустую. Ширина полосы пропускания, заряд батареи, мощность процессора сервера и запросы к CDN тратятся на контент, который никто не увидит и не услышит.
  • Проблемы с интерфейсом. Индикаторы загрузки, которые не останавливаются, график волны предыдущей песни, названия, которые меняются дважды подряд.

Классическим решением является использование проверки вроде if (requestId !== latestId) return внутри каждого обратного вызова или молчаливое игнорирование результата в методе .then(). Это сработает только если каждый обратный вызов будет помнить о такой проверке, а запрос всё равно завершится и загрузит свои данные. AbortController позволяет делать больше, отменяя саму операцию, а не просто отказываясь от ожидания её результата.

Основной шаблон использования AbortController

Контроллер предоставляет объект signal. Этот сигнал передаётся в любой API, принимающий его, а вызов метода abort() у контроллера указывает всем владельцам сигнала прекратить работу:

const controller = new AbortController();

fetch("/api/tracks/123", { signal: controller.signal })
  .then((res) => res.json())
  .then((track) => loadIntoPlayer(track))
  .catch((err) => {
    if (err.name === "AbortError") {
      // expected. they picked a different song.
      return;
    }
    throw err;
  });

// they skipped, or left the page, or closed the player
controller.abort();

Три момента заслуживают внимания. Во-первых, сигнал передается в параметрах fetch, благодаря чему функция fetch узнаёт о возможности отмены операции. Во-вторых, при отмене обещание отклоняется с ошибкой типа AbortError, даже если заголовки уже пришли, а функция res.json() всё ещё читает тело запроса. В-третьих, блок catch рассматривает эту ошибку как нормальный и ожидаемый исход и перебрасывает все остальные ошибки, поэтому настоящие сбои не игнорируются.

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

Если вы вызываете abort(reason) с пользовательским обоснованием, функция fetch отклоняет запрос с этим обоснованием вместо стандартного AbortError. В таком случае проверка значения controller.signal.aborted является более надежным способом отличить умышленное прерывание от настоящей ошибки.

Связь прерывания с эффектом React

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

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/tracks/${trackId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setTrack)
    .catch((err) => {
      if (err.name === "AbortError") return;
      setError(err);
    });

  return () => controller.abort();
}, [trackId]);

Когда меняется trackId, React выполняет процедуру очистки с предыдущей отрисовки перед запуском следующего эффекта, в результате чего запрос к старому треку прерывается, и только тогда начинается запрос к новому. Когда плеер демонтируется, выполняется та же процедура очистки, что означает, что поздний ответ никогда не сможет вызвать метод setTrack у компонента, который уже не существует.

В режиме разработки со строгим контролем React намеренно монтирует компонент, выполняет очистку и снова запускает эффекты один раз. С помощью этого подхода в панели сети будет виден один отменённый запрос — это и есть процедура очистки, выполняющая свою функцию, а механизм защиты от AbortError предотвращает его появление в качестве ошибки.

Один и тот же сигнал может управлять не только функцией fetch. Его принимают потоки, библиотеки, оборачивающие XHR, а также метод addEventListener, который принимает параметр signal, позволяющий удалять обработчик при прерывании сигнала. Однако у элементов воспроизведения есть ограничение: обычный элемент <audio> не поддерживает сигналы. Чтобы предотвратить буферизацию пропущенного файла, необходимо также очистить или заменить его параметр src в процессе очистки.

Debounce, throttle и abort решают разные проблемы

Эти три инструмента часто встречаются вместе рядом с полями поиска и элементами управления воспроизведением, поэтому их легко путать. Каждый из них действует в разный момент:

  • Debounce ждет, пока пользователь сделает паузу, а затем выполняет действие один раз. Быстрое введение текста "n-i-k-e" может привести к отправке одного запроса после последнего нажатия клавиши.
  • Throttle позволяет выполнять действие не чаще одного раза в определённом временном окне. Он подходит для прокрутки, изменения размера и многократных нажатий кнопки «пропустить вперёд»: работа всё равно выполняется, просто не при каждом событии.
  • Abort останавливает работу, которая уже началась.
  • Другими словами, функции debounce и throttle определяют, когда можно начать новую работу, а функция abort решает, может ли уже запущенная работа продолжаться.

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

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

    Каждый из этих методов сам по себе создаёт проблемы: механизм debounce всё ещё позволяет медленному раннему запросу поступить с опозданием, а простая прерывание всё равно перегружает сервер.

    Анализ ситуации при быстром прослушивании плейлиста

    Рассмотрим, что происходит в аудиоплеере, когда кто-то быстро пролистывает плейлист, быстрее, чем сеть может справиться.

    Пользователь нажимает на трек A. Приложение запрашивает метаданные, возможно, подписанный URL к файлу, изображение обложки и форму волны звука, после чего начинается буферизация аудио. Прежде чем всё это закончится, пользователь нажимает на трек B, затем на трек C.

    Без прерывания все запросы, связанные с треком A, продолжают поступать:

    • Метаданные трека A приходят и устанавливают название.
  • Аудио трека A присоединяется к элементу, в результате чего раздается краткий фрагмент неправильной песни.
  • Отображаемая продолжительность резко меняется с длительности трека A на длительность треков B и C.
  • На мобильных устройствах загружаются два полных файла, которые никто так и не воспроизведёт.
  • Система аналитики может зарегистрировать воспроизведение трека A, поскольку запрос был успешным, хотя песня так и не прозвучала.
  • Если пользователь уходит в другое место во время загрузки, обновления состояния всё равно направляются к неразмонтированному плееру.
  • При отмене действий нажатие на трек B прерывает всё, что было запущено от имени трека A. Единственные элементы, которые могут влиять на аудиоэлемент, название и график колебаний, соответствуют выбранному в данный момент треку. Если снова нажать «Пропустить», действия трека B отменяются, а трек C продолжает воспроизведение. При закрытии плеера происходит очистка, и ничего не остаётся для записи в уже уничтоженный компонент.

    Один контроллер используется для обработки каждого запроса на выбор, и один вызов abort() прерывает работу всей группы.

    В итоге интерфейс всегда отражает текущие намерения пользователя.

    Тот же принцип в более крупных приложениях

    В крупных приложениях эта идея применяется повсюду:

    • Предварительный ввод и фильтры. Новый запрос отменяет предыдущий, что приводит к конкуренции за результаты поиска вместо треков в списке воспроизведения.
    • Маршрутизация на стороне клиента. Когда пользователь покидает страницу до возврата её данных, фреймворки и библиотеки вроде Next.js, Remix, TanStack Query и SWR могут прервать выполнение задачи навигации; по сути, это тоже сигнал прерывания. Ознакомьтесь с документацией каждой библиотеки, чтобы узнать, когда именно происходит отмена и передаётся ли сигнал вашему механизму запросов.
  • Панели управления. Изменение диапазона дат может запустить повторную загрузку данных в десяти инструментах. Если изменить его снова через две секунды, не прерывая первую загрузку, на экране могут появиться данные из двух разных диапазонов.
  • Любые элементы с кнопкой «Отмена». Загрузка файлов, экспорт данных и функция «остановить генерацию» в интерфейсе чата — всё это на самом деле вызов метода controller.abort(), расположенного за этой кнопкой.
  • Основные выводы

    • Функции дебаунсинга и треддинга контролируют момент начала работы; только отмена определяет, будет ли завершена уже начатая работа.
    • Создавайте по одному объекту AbortController на каждую задачу, передавайте его сигнал signal во все места, где выполняется работа, и заменяйте объект при необходимости, вместо того чтобы использовать его повторно.
    • Рассматривайте ошибку AbortError как нормальный способ завершения работы, а другие ошибки — как ситуации, требующие повторной обработки.
  • В React необходимо прерывать выполнение в функции cleanup эффекта, чтобы изменения вводных данных или снятие компонента автоматически отменяли запланированные запросы.
  • Помните о том, до чего не достигает сигнал, например, к процессу загрузки самого медиа-элемента, и явно управляйте этим процессом.
  • Отмена запросов сама по себе не сделает интерфейс умнее, но она гарантирует, что запросы прошлого дня не смогут вступить в конфликт с запросами текущего дня. Как только вы увидите, как такой конфликт проявляется на экране или через динамик, применение сигнала к каждому запросу, разрешённому к обновлению интерфейса, станет привычкой, которую стоит сохранить.