Скасування застарілих завдань у React: AbortController для зміни стану відстеження
Дізнайтеся, чому пропущені треки та застарілі запити продовжують записувати дані у ваш інтерфейс користувача, як AbortController скасовує справжній запит та як він доповнює механізми debounce та throttle у React.
Коли користувач змінює своє рішення швидше, ніж мережа встигає відреагувати, будь-який запит, який ви вже розпочали, продовжує працювати та буде успішно виводити свої результати на екран. У полі пошуку це означає результати запиту, який користувач відмовився використовувати; у медіаплеєрі — короткий фрагмент пісні, яку він щойно пропустив. Очікування чи обмеження швидкості не вирішують цієї проблеми, оскільки робота вже триває. У цій статті показано, як 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 природним місцем для виклику abort є функція очищення ефекту. Коли значення, яке ініціює запит, походить з пропсів або стану, запит має існувати рівно стільки, скільки існує це значення:
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 у компоненті, якого більше не існує.
Під час розробки у режимі Strict Mode React навмисно монтує, очищує та знову запускає ефекти один раз. Завдяки цій схемі у панелі мережі ви побачите один скасований запит – це процедура очищення, яка виконує свою роботу, а механізм захисту AbortError не дозволяє йому відображатися як помилка.
Один і той самий сигнал може керувати більш ніж лише функцією fetch. Стріми його приймають, бібліотеки, які обгортають XHR, часто також це роблять, а функція addEventListener приймає параметр signal, який видаляє слухача, коли сигнал припиняється. Одна застереження щодо плеєрів: звичайний елемент <audio> не підтримує сигнали. Щоб зупинити буферування пропущеного файлу, необхідно також очистити або замінити його значення в атрибуті src під час очищення.
Debounce, throttle та abort вирішують різні проблеми
Ці три інструменти часто зустрічаються разом біля полів пошуку та елементів керування плеєром, тому їх легко сплутати. Кожен з них діє у різний момент:
- Debounce чекає, поки користувач зробить паузу, а потім виконує дію один раз. Швидке набирання слів „n-i-k-e“ може призвести до однієї запиту після останнього натискання клавіші.
Іншими словами, debounce та throttle визначають, коли дозволяється почати нову роботу, а abort вирішує, чи може продовжуватися робота, яка вже розпочалась.
Пошукове поле, яке здається реактивним, зазвичай використовує обидва ці механізми. Debounce запобігає надсиланню запиту з кожного натискання клавіші, а abort гарантує, що запит, який вже було надіслано, не зможе повернутися пізніше та перезаписати новіші результати. У спеціалізованій статті про ситуації конкуренції, які debounce не може вирішити у пошукових інтерфейсах детально розглядається цей випадок.
Гравець дотримується того самого принципу. Ви можете обмежити швидкість натискання кнопки „Наступний“, щоб занадто активний користувач не міг виконати двадцять завантажень протягом 200 мс, але таке обмеження нічого не скасовує — воно лише розтягує час виконання нових завдань. Вам все одно потрібно зупинити завантаження, яке вже розпочалося.
Кожен з цих методів сам по собі залишає прогалини: механізм debounce все одно дозволяє повільному запиту надійти пізніше, а просте скасування все одно завантажує сервер надмірними даними.
Аналіз ситуації під час швидкого прогляду списку треків
Розгляньмо, що відбувається в аудіоплеєрі, коли хтось прокручує список треків швидше, ніж мережа може встигати обробляти запити.
Користувач натискає на трек A. Додаток запитує метадані, можливо, підписаний URL до файлу, зображення обкладинки та форму хвилі, і починається буферизація аудіо. Перш ніж це все завершиться, він натискає на трек B, а потім на трек C.
Без скасування запитів усе, що було розпочато для трека A, продовжує надходити:
- Метадані трека A надходять та встановлюють його назву.
При скасуванні натискання на B припиняє все, що було запущено від імені A. Єдині елементи, яким дозволено впливати на аудіо-елемент, заголовок та графік хвиль, належать до тієї пісні, яка обрана на той момент. Якщо знову натиснути «Пропустити», робота B скасовується, а C продовжується. Якщо закрити плеєр, відбувається очищення, і нічого більше не записується у елемент, якого вже немає.
Розділіть один контролер між усіма запитами для вибору, і один abort() знищить усю групу.
Кінцевий ефект полягає у тому, що інтерфейс завжди відображає лише поточну намір користувача.
Той самий паттерн у більших додатках
У великих додатках ця ідея застосовується скрізь:
- Typeahead та фільтри. Новий запит скасовує попередній, що є аналогом конкуренції за список відтворення між результатами пошуку та самими піснями.
- Маршрутизація на стороні клієнта. Коли користувач залишає сторінку до повернення її даних, фреймворки та бібліотеки даних, такі як Next.js, Remix, TanStack Query та SWR, можуть скасувати поточні операції навігації; по суті, це все одно є сигналом про скасування. Ознайомтесь з документацією кожної бібліотеки, щоб дізнатися точно, коли вона скасовує операцію та чи передає сигнал вашому засобу отримання даних.
controller.abort(), яка знаходиться за цією кнопкою.Основні висновки
- Функції debounce та throttle контролюють момент початку виконання завдань; лише можливість скасування визначає, чи буде завершено почате завдання.
- Створюйте один об’єкт
AbortControllerна кожну задачу, передавайте йогоsignalу всі місця, де виконується робота, та замінюйте його замість повторного використання. - Розглядайте
AbortErrorяк звичайний вихід та перекидайте інші помилки знову.
Скасування саме по собі не зробить інтерфейс розумнішим, але воно гарантує, що запити від вчора не зможуть конкурувати з запитами сьогодні. Як тільки ви побачите, як ця конкуренція проявляється на екрані чи через динамік, створення сигналу для кожного запиту, який дозволений до оновлення UI, стане звичкою, яку варто підтримувати.