Пошук уроків, статей та іншого контенту
Навчитеся скасовувати запити й інші операції за допомогою AbortController та коректно реагувати на сигнал скасування.
Скасування асинхронної операції — це сигнал до коду або API, що результат більше не потрібен і роботу слід припинити якомога швидше.
У JavaScript скасування зазвичай є кооперативним:
код, який виконує операцію, має підтримувати сигнал скасування;
AbortController не перериває довільну функцію примусово;
API сам вирішує, як реагувати на сигнал;
після скасування проміс зазвичай завершується помилкою.
Найчастіше скасування потрібне для:
запитів fetch;
пошуку з частою зміною тексту;
операцій, які більше не потрібні після переходу зі сторінки;
тайм-аутів;
паралельних операцій, коли достатньо першого результату;
власних асинхронних функцій.
AbortController — об'єкт, який дозволяє ініціювати скасування.
Він містить властивість signal, яку передають асинхронній операції:
const controller = new AbortController();
const { signal } = controller;
// Передаємо signal API або власній функції
someAsyncOperation({ signal });
// У будь-який момент просимо скасувати операцію
controller.abort();Основні властивості та методи:
controller.signal — сигнал, за яким стежать;
controller.abort() — ініціює скасування;
signal.aborted — true, якщо скасування вже відбулося;
signal.reason — причина скасування;
signal.addEventListener("abort", handler) — обробник події скасування.
Один контролер можна використати для кількох операцій:
const controller = new AbortController();
fetch("/api/user", { signal: controller.signal });
fetch("/api/notifications", { signal: controller.signal });
// Скасує обидва запити
controller.abort();Після виклику abort() сигнал залишається скасованим. Повторно активувати його неможливо, тому для нової операції потрібен новий AbortController.
fetch приймає signal у параметрах:
const controller = new AbortController();
try {
const response = await fetch("/api/products", {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
const products = await response.json();
console.log(products);
} catch (error) {
if (error.name === "AbortError") {
console.log("Запит скасовано");
} else {
console.error("Запит завершився помилкою:", error);
}
}
// Наприклад, викликається після натискання кнопки «Скасувати»
controller.abort();Якщо запит уже завершився, виклик abort() не скасує отриманий результат. Він впливає лише на операцію, яка ще триває.
Скасування — це очікуваний сценарій, а не обов'язково помилка програми:
try {
const response = await fetch("/api/data", {
signal: controller.signal
});
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
// Користувач або інша частина програми скасувала операцію
return null;
}
// Обробляємо справжню помилку
throw error;
}Не слід показувати користувачу повідомлення «Сервер недоступний», якщо операцію скасували навмисно.
Під час пошуку користувач може ввести кілька символів поспіль. Якщо для кожної зміни відправляти окремий запит, відповіді можуть прийти в іншому порядку:
запит для ja;
запит для jav;
запит для javascript;
відповідь для ja приходить останньою і перезаписує правильні результати.
Один зі способів розв'язати проблему — скасовувати попередній запит перед запуском нового.
let activeController = null;
async function searchUsers(query) {
const normalizedQuery = query.trim();
if (normalizedQuery.length < 2) {
return [];
}
// Скасовуємо попередній запит, якщо він ще виконується
activeController?.abort("Запущено новий пошук");
const controller = new AbortController();
activeController = controller;
try {
const response = await fetch(
`/api/users?search=${encodeURIComponent(normalizedQuery)}`,
{
signal: controller.signal
}
);
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
const users = await response.json();
// Перевірка потрібна, якщо сервер або інший API не реагує
// на скасування так, як очікується
if (controller.signal.aborted) {
return [];
}
return users;
} catch (error) {
if (controller.signal.aborted) {
return [];
}
throw error;
} finally {
// Не очищаємо контролер, який уже замінив новий запит
if (activeController === controller) {
activeController = null;
}
}
}
async function handleSearchInput(event) {
const resultsElement = document.querySelector("#results");
try {
const users = await searchUsers(event.target.value);
resultsElement.replaceChildren(
...users.map((user) => {
const item = document.createElement("li");
item.textContent = user.name;
return item;
})
);
} catch (error) {
resultsElement.textContent = "Не вдалося виконати пошук";
console.error(error);
}
}
document
.querySelector("#search")
.addEventListener("input", handleSearchInput);У цьому прикладі activeController зберігає контролер поточного запиту. Перед новим запитом попередній контролер скасовується.
Однак скасування саме по собі не завжди захищає від застарілих результатів. Додаткова перевірка або ідентифікатор операції корисні, якщо між завершенням запиту та оновленням стану є інша асинхронна логіка.
До abort() можна передати причину:
const controller = new AbortController();
controller.abort("Користувач закрив діалог");
console.log(controller.signal.aborted); // true
console.log(controller.signal.reason); // "Користувач закрив діалог"Також причиною може бути об'єкт помилки:
const controller = new AbortController();
controller.abort(new Error("Перевищено ліміт часу"));
console.log(controller.signal.reason.message);
// Перевищено ліміт часуЯкщо причину не передати, середовище зазвичай створює помилку типу DOMException з назвою AbortError.
Причина особливо корисна, коли одна й та сама операція може бути скасована з різних причин:
користувач натиснув кнопку;
почалася нова операція;
минув тайм-аут;
компонент було знищено.
Власна функція має:
приймати signal;
одразу перевіряти, чи сигнал уже скасовано;
підписуватися на подію abort;
звільняти ресурси після завершення;
завершуватися помилкою після скасування.
Ось приклад функції очікування, яка підтримує скасування:
function createAbortError(reason) {
if (reason instanceof Error) {
return reason;
}
return new DOMException(
reason ?? "Операцію скасовано",
"AbortError"
);
}
function wait(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(createAbortError(signal.reason));
return;
}
let timerId;
const cleanup = () => {
clearTimeout(timerId);
signal?.removeEventListener("abort", handleAbort);
};
const handleAbort = () => {
cleanup();
reject(createAbortError(signal.reason));
};
timerId = setTimeout(() => {
cleanup();
resolve();
}, ms);
signal?.addEventListener("abort", handleAbort, { once: true });
});
}
async function runTask(signal) {
console.log("Операція розпочалася");
await wait(2000, { signal });
console.log("Операція завершилася");
}
const controller = new AbortController();
runTask(controller.signal)
.then(() => {
console.log("Успішно виконано");
})
.catch((error) => {
if (controller.signal.aborted) {
console.log("Операцію скасовано:", controller.signal.reason);
return;
}
console.error("Непередбачена помилка:", error);
});
setTimeout(() => {
controller.abort("Користувач скасував операцію");
}, 500);Сигнал може бути скасований ще до запуску функції:
const controller = new AbortController();
controller.abort();
await wait(1000, { signal: controller.signal });Якщо функція перевіряє signal.aborted лише через обробник події, вона може зависнути: подія abort уже відбулася, коли обробник ще не був зареєстрований.
Тому правильний порядок такий:
перевірити signal.aborted;
створити ресурси;
додати обробник скасування.
Після завершення операції потрібно видалити все, що більше не використовується:
таймери;
обробники подій;
підписки;
відкриті з'єднання;
тимчасові об'єкти;
слухачі abort.
Для цього зручно використовувати функцію cleanup:
function subscribeToEvents({ signal, onMessage }) {
const handleMessage = (event) => {
onMessage(event.data);
};
window.addEventListener("message", handleMessage);
const cleanup = () => {
window.removeEventListener("message", handleMessage);
};
if (signal?.aborted) {
cleanup();
return;
}
signal?.addEventListener("abort", cleanup, { once: true });
}Якщо не видаляти слухачі, довготривалі об'єкти можуть залишатися в пам'яті, а обробники можуть викликатися вже після завершення операції.
У сучасних середовищах AbortSignal.timeout() створює сигнал, який автоматично скасовується після заданої кількості мілісекунд:
async function loadData() {
const response = await fetch("/api/data", {
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
return response.json();
}Під час тайм-ауту помилка може мати назву TimeoutError, а не AbortError. Тому обробляйте обидва випадки або перевіряйте сам сигнал:
try {
const data = await loadData();
console.log(data);
} catch (error) {
if (error.name === "TimeoutError") {
console.log("Сервер не відповів вчасно");
} else if (error.name === "AbortError") {
console.log("Операцію скасовано");
} else {
throw error;
}
}Якщо потрібна сумісність зі старішими середовищами, тайм-аут можна реалізувати через окремий контролер:
function createTimeoutController(ms) {
const controller = new AbortController();
const timerId = setTimeout(() => {
controller.abort(new Error("Перевищено час очікування"));
}, ms);
controller.signal.addEventListener(
"abort",
() => clearTimeout(timerId),
{ once: true }
);
return controller;
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const timeoutController = createTimeoutController(timeoutMs);
try {
return await fetch(url, {
...options,
signal: timeoutController.signal
});
} finally {
timeoutController.abort("Запит завершено");
}
}У цьому прикладі finally очищає таймер через скасування контролера. Якщо fetch завершився раніше, таймер більше не потрібен.
Іноді операцію потрібно скасувати, якщо спрацювала хоча б одна з умов:
користувач натиснув «Скасувати»;
настав тайм-аут;
компонент або сторінка більше не використовує результат.
Для цього в сучасних середовищах є AbortSignal.any():
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000);
const signal = AbortSignal.any([
userController.signal,
timeoutSignal
]);
try {
const response = await fetch("/api/report", { signal });
const report = await response.json();
console.log(report);
} catch (error) {
if (signal.aborted) {
console.log("Звіт більше не потрібен:", signal.reason);
} else {
throw error;
}
}
// Скасування з боку користувача
document
.querySelector("#cancel")
.addEventListener("click", () => {
userController.abort("Користувач скасував завантаження");
});AbortSignal.any() не об'єднує контролери. Він створює новий сигнал, який скасовується після скасування першого сигналу з масиву.
Якщо кілька запитів є частинами однієї операції, їх можна пов'язати одним сигналом:
async function loadDashboard(signal) {
const [profileResponse, messagesResponse] = await Promise.all([
fetch("/api/profile", { signal }),
fetch("/api/messages", { signal })
]);
if (!profileResponse.ok || !messagesResponse.ok) {
throw new Error("Не вдалося завантажити дані панелі");
}
return {
profile: await profileResponse.json(),
messages: await messagesResponse.json()
};
}
const controller = new AbortController();
loadDashboard(controller.signal)
.then((dashboard) => {
console.log(dashboard);
})
.catch((error) => {
if (controller.signal.aborted) {
console.log("Завантаження панелі скасовано");
return;
}
console.error(error);
});
// Скасовує всі запити, пов'язані з панеллю
controller.abort("Сторінку закрито");У Promise.all() скасування одного сигналу може спричинити відхилення всього результату. Інші операції також отримають сигнал скасування, якщо використовують той самий контролер.
Скасування корисне, коли запускається кілька альтернативних операцій, але потрібен лише один результат. Наприклад, запит можна відправити до кількох серверів і скасувати решту після першої успішної відповіді.
Для цього кожному запиту потрібен власний контролер, а зовнішній сигнал має скасовувати всю групу:
async function fetchFromAny(urls, { signal } = {}) {
const controllers = urls.map(() => new AbortController());
const abortAll = () => {
for (const controller of controllers) {
controller.abort(signal?.reason);
}
};
if (signal?.aborted) {
abortAll();
throw createAbortError(signal.reason);
}
signal?.addEventListener("abort", abortAll, { once: true });
try {
return await Promise.any(
urls.map(async (url, index) => {
const response = await fetch(url, {
signal: controllers[index].signal
});
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
const result = await response.json();
// Після успішної відповіді зупиняємо решту запитів
for (const controller of controllers) {
controller.abort("Отримано результат з іншого джерела");
}
return result;
})
);
} finally {
signal?.removeEventListener("abort", abortAll);
}
}Цей підхід потребує обережної обробки помилок. Якщо всі операції завершаться невдало, Promise.any() відхилиться з AggregateError.
Скасування fetch не завжди припиняє всю подальшу роботу автоматично. Наприклад, після отримання відповіді може тривати:
читання великого тіла відповіді;
перетворення даних;
обчислення;
оновлення стану.
Тому важливі етапи потрібно перевіряти окремо:
async function loadAndProcess(signal) {
const response = await fetch("/api/large-data", { signal });
const data = await response.json();
if (signal.aborted) {
return null;
}
const processedData = processData(data);
if (signal.aborted) {
return null;
}
return processedData;
}
function processData(data) {
return data.map((item) => ({
id: item.id,
label: item.name.toUpperCase()
}));
}Для дуже довгих обчислень перевірку потрібно виконувати в циклі:
function calculate(items, signal) {
const result = [];
for (const item of items) {
if (signal.aborted) {
throw createAbortError(signal.reason);
}
result.push(expensiveCalculation(item));
}
return result;
}
function expensiveCalculation(item) {
return item.value * 2;
}AbortController не може перервати синхронний JavaScript посеред виконання. Якщо функція виконує великий безперервний цикл без перевірок, скасування буде оброблено лише після завершення цього циклу.
У UI-фреймворках контролер часто пов'язують із життєвим циклом компонента:
function createDataLoader() {
const controller = new AbortController();
const promise = fetch("/api/data", {
signal: controller.signal
}).then((response) => response.json());
return {
promise,
dispose() {
controller.abort("Компонент більше не використовується");
}
};
}
const loader = createDataLoader();
loader.promise
.then((data) => {
console.log(data);
})
.catch((error) => {
if (!loader.signal?.aborted && error.name !== "AbortError") {
console.error(error);
}
});
// Викликається під час знищення компонента
loader.dispose();У реальному коді зручно повертати з функції і signal, якщо зовнішньому коду потрібно перевіряти стан:
function startLoading() {
const controller = new AbortController();
return {
signal: controller.signal,
promise: fetch("/api/data", {
signal: controller.signal
}).then((response) => response.json()),
cancel(reason = "Операцію скасовано") {
controller.abort(reason);
}
};
}const controller = new AbortController();
async function load() {
return fetch("/api/data", {
signal: controller.signal
});
}
controller.abort();
// Наступний виклик одразу отримає вже скасований сигнал
load();Контролер не можна повторно активувати. Створюйте новий контролер для кожної незалежної операції.
Якщо проміс відхиляється, його потрібно обробити. Інакше з'являються необроблені відхилені проміси.
try {
await fetch("/api/data", {
signal: controller.signal
});
} catch (error) {
if (controller.signal.aborted) {
return;
}
throw error;
}error.nameРізні середовища або власні функції можуть використовувати різні типи помилок. Надійніше перевіряти також сигнал:
catch (error) {
if (signal.aborted) {
// Це скасування саме цієї операції
return;
}
throw error;
}Обробник abort або таймер, який не видалили, може спричинити витік пам'яті чи зайві виклики. Завжди очищайте ресурси в обробнику завершення або в finally.
controller.abort();
heavySynchronousFunction();abort() не зупинить heavySynchronousFunction(). Функція має сама періодично перевіряти сигнал або використовувати API, яке його підтримує.
Навіть якщо запит скасовано, додаткові асинхронні кроки можуть завершитися пізніше. Для критичних сценаріїв:
перевіряйте signal.aborted перед оновленням стану;
використовуйте ідентифікатор поточної операції;
скасовуйте попередню операцію перед запуском нової.
AbortController ініціює скасування, а AbortSignal передається асинхронній операції.
fetch підтримує signal і зазвичай відхиляє проміс після скасування.
Скасування є кооперативним: власний код має перевіряти сигнал і реагувати на подію abort.
Один контролер можна використовувати для групи пов'язаних операцій.
Для нової операції потрібен новий контролер.
Перевіряйте signal.aborted до запуску роботи та перед важливими побічними ефектами.
Обробляйте скасування окремо від помилок мережі або сервера.
Завжди очищайте таймери, слухачі та інші ресурси.
AbortSignal.timeout() та AbortSignal.any() спрощують реалізацію тайм-аутів і складних правил скасування.
AbortController не перериває довільний синхронний код — підтримку скасування потрібно реалізувати явно.