Пошук уроків, статей та іншого контенту
Скасуєте запит, встановите тайм-аут і коректно обробите AbortError через AbortController.
Запит fetch() може тривати довше, ніж очікує користувач:
користувач залишив сторінку або змінив пошуковий запит;
мережа працює повільно;
сервер не відповідає;
результат запиту вже не потрібен;
потрібно обмежити час очікування.
Для скасування асинхронних операцій у браузері використовується AbortController. Він створює сигнал (AbortSignal), який можна передати до fetch().
const controller = new AbortController();
fetch("/api/users", {
signal: controller.signal
});
// Скасувати запит
controller.abort();Після виклику abort() проміс fetch() буде відхилено з помилкою AbortError.
AbortController має два основні елементи:
controller.signal — сигнал, який передається операції;
controller.abort() — метод скасування операції.
Один контролер можна використовувати для кількох операцій. У такому випадку виклик abort() скасує їх усі.
const controller = new AbortController();
const usersRequest = fetch("/api/users", {
signal: controller.signal
});
const postsRequest = fetch("/api/posts", {
signal: controller.signal
});
// Скасовує обидва запити
controller.abort();Для окремих незалежних запитів зазвичай створюють окремий контролер.
Скасування запиту — очікувана ситуація, а не обов’язково помилка застосунку. Тому її варто обробляти окремо:
async function loadUsers() {
const controller = new AbortController();
try {
const response = await fetch("/api/users", {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
console.log("Запит скасовано");
return null;
}
console.error("Не вдалося завантажити користувачів:", error);
throw error;
}
}Перевірка response.ok потрібна тому, що fetch() не відхиляє проміс автоматично для HTTP-статусів 4xx або 5xx.
Розглянемо приклад пошуку. Користувач запускає запит кнопкою, а потім може його скасувати.
<button id="load-button">Завантажити користувачів</button>
<button id="cancel-button" disabled>Скасувати</button>
<p id="status"></p>
<ul id="users"></ul>
<script>
const loadButton = document.querySelector("#load-button");
const cancelButton = document.querySelector("#cancel-button");
const statusElement = document.querySelector("#status");
const usersElement = document.querySelector("#users");
let currentController = null;
loadButton.addEventListener("click", async () => {
// Якщо попередній запит ще триває, скасовуємо його
currentController?.abort();
const controller = new AbortController();
currentController = controller;
loadButton.disabled = true;
cancelButton.disabled = false;
statusElement.textContent = "Завантаження...";
usersElement.replaceChildren();
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users",
{
signal: controller.signal
}
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const users = await response.json();
// Перевіряємо, що цей контролер усе ще актуальний
if (currentController !== controller) {
return;
}
for (const user of users) {
const item = document.createElement("li");
item.textContent = `${user.name} — ${user.email}`;
usersElement.append(item);
}
statusElement.textContent = "Дані завантажено";
} catch (error) {
if (error.name === "AbortError") {
statusElement.textContent = "Запит скасовано";
} else {
statusElement.textContent = "Помилка завантаження";
console.error(error);
}
} finally {
// Не змінюємо стан кнопок для новішого запиту
if (currentController === controller) {
loadButton.disabled = false;
cancelButton.disabled = true;
currentController = null;
}
}
});
cancelButton.addEventListener("click", () => {
currentController?.abort();
});
</script>У цьому прикладі:
Для кожного нового запиту створюється новий AbortController.
Попередній запит скасовується перед запуском нового.
Сигнал передається до fetch().
AbortError обробляється окремо.
Блок finally виконується і після успіху, і після помилки, і після скасування.
Після виклику abort() сигнал залишається скасованим. Його не можна «відновити»:
const controller = new AbortController();
controller.abort();
fetch("/api/data", {
signal: controller.signal
})
.catch((error) => {
console.log(error.name); // AbortError
});Для нового запиту потрібно створити новий контролер.
Частий сценарій — автоматично скасувати запит, якщо він триває надто довго.
Такий підхід працює в широкому спектрі браузерів:
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timerId = setTimeout(() => {
controller.abort();
}, timeout);
try {
const response = await fetch(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
throw new Error(`Запит перевищив ліміт у ${timeout} мс`);
}
throw error;
} finally {
// Таймер більше не потрібен після завершення запиту
clearTimeout(timerId);
}
}
fetchWithTimeout("https://jsonplaceholder.typicode.com/todos/1", 3000)
.then((data) => {
console.log(data);
})
.catch((error) => {
console.error(error.message);
});Таймер обов’язково потрібно очищати в finally. Інакше непотрібний таймер залишатиметься активним до завершення свого часу.
У сучасних браузерах можна використати статичний метод AbortSignal.timeout():
async function loadData() {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos/1",
{
signal: AbortSignal.timeout(3000)
}
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === "TimeoutError") {
console.log("Запит завершився через тайм-аут");
} else if (error.name === "AbortError") {
console.log("Запит скасовано вручну");
} else {
console.error("Інша помилка:", error);
}
}
}
loadData();AbortSignal.timeout(3000) створює сигнал, який автоматично перериває операцію приблизно через 3000 мілісекунд.
У разі автоматичного тайм-ауту сучасні браузери зазвичай використовують помилку з іменем TimeoutError, а під час ручного виклику controller.abort() — AbortError. Якщо застосунок має працювати в різних середовищах, перевірте підтримку цього API або використовуйте варіант із setTimeout.
Для ручного скасування і тайм-ауту можна використати два сигнали та об’єднати їх за допомогою AbortSignal.any():
async function loadWithCancelAndTimeout(url, timeout) {
const controller = new AbortController();
const timeoutSignal = AbortSignal.timeout(timeout);
const signal = AbortSignal.any([
controller.signal,
timeoutSignal
]);
try {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} finally {
// Контролер можна повернути виклику для ручного скасування
}
}
const controller = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000);
const signal = AbortSignal.any([
controller.signal,
timeoutSignal
]);
fetch("https://jsonplaceholder.typicode.com/users", { signal })
.then((response) => response.json())
.then((users) => console.log(users))
.catch((error) => {
if (error.name === "AbortError") {
console.log("Запит скасовано вручну");
} else if (error.name === "TimeoutError") {
console.log("Час очікування вичерпано");
} else {
console.error(error);
}
});
// За потреби скасувати раніше тайм-ауту
controller.abort();AbortSignal.any() створює сигнал, який буде скасовано, коли буде скасовано будь-який із переданих сигналів.
Під час пошуку за текстом користувач може швидко змінювати значення поля. Якщо не скасовувати попередні запити, відповіді можуть прийти в іншому порядку:
запит для "ja";
запит для "javascript";
відповідь для "javascript";
пізніша відповідь для "ja".
У результаті старі дані можуть перезаписати нові. Контролер допомагає скасувати попередній запит.
const searchInput = document.querySelector("#search");
const resultsElement = document.querySelector("#results");
let searchController = null;
searchInput.addEventListener("input", async (event) => {
const query = event.target.value.trim();
searchController?.abort();
if (!query) {
resultsElement.replaceChildren();
return;
}
const controller = new AbortController();
searchController = controller;
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: controller.signal
}
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const results = await response.json();
// Не показуємо результат, якщо він належить старому запиту
if (searchController !== controller) {
return;
}
resultsElement.textContent = JSON.stringify(results);
} catch (error) {
if (error.name !== "AbortError") {
console.error("Помилка пошуку:", error);
}
}
});Для повноцінного пошуку також часто додають затримку перед запитом — debounce. Але debounce лише зменшує кількість запитів, а AbortController скасовує запит, який уже почав виконуватися.
AbortController скасовує операцію на стороні браузера:
очікування мережевої відповіді;
читання тіла відповіді;
роботу fetch() із переданим сигналом.
Це не означає, що сервер гарантовано припинить обробку запиту. Сервер міг уже отримати запит і продовжити його виконання. Скасування насамперед повідомляє клієнту, що результат більше не потрібен.
AbortErrorЯкщо показувати кожну помилку скасування користувачу, інтерфейс може повідомляти про проблеми під час нормальної зміни запиту.
try {
await fetch("/api/data", { signal });
} catch (error) {
if (error.name === "AbortError") {
// Очікуване скасування не показуємо як помилку
return;
}
showErrorMessage(error);
}Після abort() його сигнал назавжди скасований. Для кожної нової операції створюйте новий контролер.
Якщо тайм-аут реалізовано через setTimeout, очищайте його в finally:
const timerId = setTimeout(() => controller.abort(), 5000);
try {
await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timerId);
}Старий запит може завершити обробку після запуску нового. Якщо його finally без перевірки оновлює кнопки або індикатор завантаження, він може зламати стан нового запиту.
Зберігайте посилання на актуальний контролер і перевіряйте його перед оновленням інтерфейсу:
if (currentController === controller) {
loadingElement.hidden = true;
}response.okHTTP-помилка не є AbortError, але fetch() також не відхиляє проміс автоматично для статусів 404 чи 500. Перевіряйте response.ok вручну.
Клієнт може перестати чекати відповідь, але сервер не завжди припиняє вже розпочату роботу. Не покладайтеся на abort() як на механізм відкату серверної операції.
AbortController дає змогу скасовувати fetch() та інші операції, що підтримують AbortSignal.
Сигнал передається через опцію signal.
Операція скасовується викликом controller.abort().
Ручне скасування зазвичай призводить до AbortError.
Для тайм-ауту можна використати setTimeout разом із AbortController або AbortSignal.timeout() у сучасних браузерах.
Таймер потрібно очищати після завершення запиту.
Для кожного нового запиту створюйте новий контролер.
Під час пошуку скасовуйте застарілі запити та захищайте стан інтерфейсу від результатів старих операцій.
Скасування на клієнті не гарантує припинення роботи запиту на сервері.