Пошук уроків, статей та іншого контенту
Навчитеся скасовувати запити, встановлювати тайм-аути й запобігати застарілим відповідям у інтерфейсі.
У браузері мережевий запит може тривати довше, ніж потрібно інтерфейсу:
користувач увів новий пошуковий запит;
компонент або сторінка більше не потрібні;
з’єднання зависло;
користувач натиснув кнопку «Скасувати»;
відповідь від попереднього запиту вже неактуальна.
AbortController дає змогу скасувати операції, які підтримують AbortSignal. Найчастіше його використовують разом із fetch.
const controller = new AbortController();
fetch("/api/data", {
signal: controller.signal,
});
// Скасування запиту
controller.abort();Після виклику abort() проміс fetch буде відхилено з помилкою AbortError.
Об’єкт AbortController має дві важливі властивості:
signal — сигнал, який передається в операцію;
abort() — метод, що надсилає сигнал скасування.
Один контролер можна використовувати для кількох операцій. У такому випадку виклик abort() скасує їх усі.
const controller = new AbortController();
async function loadData() {
try {
const response = await fetch("/api/data", {
signal: controller.signal,
});
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === "AbortError") {
console.log("Запит скасовано");
return;
}
console.error("Помилка запиту:", error);
}
}
loadData();
// Через секунду скасовуємо запит
setTimeout(() => {
controller.abort();
}, 1000);Після скасування controller.signal.aborted матиме значення true.
const controller = new AbortController();
console.log(controller.signal.aborted); // false
controller.abort();
console.log(controller.signal.aborted); // trueСкасований контролер не можна використати повторно. Для нового запиту потрібно створити новий екземпляр AbortController.
Скасування — це очікувана ситуація, а не обов’язково помилка програми. Тому її варто обробляти окремо від інших помилок.
try {
const response = await fetch("/api/profile", {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
const profile = await response.json();
renderProfile(profile);
} catch (error) {
if (error.name === "AbortError") {
// Користувач або код навмисно скасували запит
return;
}
showError("Не вдалося завантажити профіль");
}Перевірка response.ok потрібна окремо. fetch не відхиляє проміс автоматично для HTTP-статусів 400 або 500.
Типовий приклад — поле пошуку з підказками. Користувач може швидко ввести кілька символів:
надсилається запит для j;
потім запит для ja;
потім запит для javascript.
Якщо не скасовувати попередні запити, їхні відповіді можуть прийти в іншому порядку. Наприклад, відповідь для j може прийти після відповіді для javascript і перезаписати актуальні результати.
Для цього зберігають поточний контролер у змінній та скасовують попередній запит перед створенням нового.
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<title>Пошук із скасуванням запитів</title>
</head>
<body>
<label>
Пошук:
<input id="searchInput" type="search" autocomplete="off">
</label>
<p id="status"></p>
<ul id="results"></ul>
<script>
const input = document.querySelector("#searchInput");
const status = document.querySelector("#status");
const results = document.querySelector("#results");
let currentController = null;
let requestNumber = 0;
input.addEventListener("input", async () => {
const query = input.value.trim();
// Скасовуємо попередній запит
currentController?.abort();
if (!query) {
results.replaceChildren();
status.textContent = "";
return;
}
const controller = new AbortController();
currentController = controller;
const currentRequestNumber = ++requestNumber;
status.textContent = "Завантаження...";
results.replaceChildren();
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: controller.signal,
}
);
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
const items = await response.json();
// Додаткова перевірка захищає від застарілої відповіді
if (currentRequestNumber !== requestNumber) {
return;
}
results.replaceChildren(
...items.map((item) => {
const li = document.createElement("li");
li.textContent = item.name;
return li;
})
);
status.textContent = items.length
? ""
: "Нічого не знайдено";
} catch (error) {
if (error.name === "AbortError") {
return;
}
// Не показуємо помилку для вже неактуального запиту
if (currentRequestNumber !== requestNumber) {
return;
}
status.textContent = "Не вдалося виконати пошук";
console.error(error);
} finally {
// Не змінюємо стан, якщо цей запит уже не є поточним
if (currentRequestNumber === requestNumber) {
currentController = null;
}
}
});
</script>
</body>
</html>У прикладі URL /api/search — це endpoint вашого сервера, який має повертати JSON-масив, наприклад:
[
{ "name": "JavaScript" },
{ "name": "JavaScript Promise" }
]Скасування запиту зазвичай зупиняє fetch, але між завершенням мережевої операції та обробкою результату можливі змагання між асинхронними операціями.
Лічильник requestNumber гарантує, що інтерфейс оновить лише найновіший запит:
const currentRequestNumber = ++requestNumber;
if (currentRequestNumber !== requestNumber) {
return;
}Цей підхід називають перевіркою актуальності запиту або захистом від застарілих відповідей.
fetch самостійно не має стандартного параметра timeout. Тайм-аут можна реалізувати за допомогою AbortController і setTimeout.
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeout);
try {
return await fetch(url, {
...options,
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
}Використання:
try {
const response = await fetchWithTimeout("/api/data", {}, 3000);
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === "AbortError") {
console.log("Запит перевищив допустимий час або був скасований");
} else {
console.error("Інша помилка:", error);
}
}У цій реалізації помилка AbortError може означати як перевищення тайм-ауту, так і зовнішнє скасування. Якщо потрібно розрізняти ці випадки, можна зберігати окремий прапорець.
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeout);
try {
return await fetch(url, {
signal: controller.signal,
});
} catch (error) {
if (error.name === "AbortError" && timedOut) {
throw new Error("Запит перевищив тайм-аут");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}У сучасних середовищах JavaScript доступний статичний метод AbortSignal.timeout(). Він створює сигнал, який автоматично скасовується після вказаної кількості мілісекунд.
try {
const response = await fetch("/api/data", {
signal: AbortSignal.timeout(5000),
});
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);
}
}AbortSignal.timeout() зручний для короткого коду, але підтримка залежить від середовища виконання та його версії. Якщо застосунок має працювати у старих браузерах, ручний варіант із AbortController і setTimeout може бути суміснішим.
Іноді запит потрібно скасувати в кількох випадках:
користувач натиснув «Скасувати»;
завершився тайм-аут;
компонент більше не використовується.
У сучасних середовищах для об’єднання сигналів можна використовувати AbortSignal.any().
const userController = new AbortController();
const signal = AbortSignal.any([
userController.signal,
AbortSignal.timeout(5000),
]);
try {
const response = await fetch("/api/data", { signal });
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === "AbortError" || error.name === "TimeoutError") {
console.log("Запит скасовано");
} else {
console.error(error);
}
}
// Скасування з боку користувача
userController.abort();Якщо AbortSignal.any() недоступний у цільовому середовищі, ці сценарії можна об’єднати вручну, використовуючи один AbortController і кілька обробників.
У клієнтських застосунках запит часто запускається під час монтування компонента. Якщо компонент знищується до завершення запиту, результат більше не потрібно застосовувати.
Загальна схема:
function loadUser(userId) {
const controller = new AbortController();
fetch(`/api/users/${userId}`, {
signal: controller.signal,
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
return response.json();
})
.then((user) => {
renderUser(user);
})
.catch((error) => {
if (error.name !== "AbortError") {
console.error(error);
}
});
// Функція очищення викликається перед знищенням компонента
return () => {
controller.abort();
};
}
const cleanup = loadUser("42");
// Коли компонент більше не потрібен
cleanup();У фреймворках ця логіка зазвичай розміщується в механізмі очищення ефекту або життєвого циклу компонента.
Метод abort() може отримати причину скасування:
const controller = new AbortController();
controller.abort(new Error("Користувач залишив сторінку"));
console.log(controller.signal.reason);Це корисно в складніших сценаріях, коли потрібно зрозуміти, чому саме операцію було перервано.
const controller = new AbortController();
try {
await fetch("/api/data", {
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) {
console.log("Причина:", controller.signal.reason);
return;
}
console.error(error);
}Причина скасування є додатковою інформацією. Для звичайної обробки достатньо перевіряти error.name або signal.aborted.
AbortController скасовує операцію на стороні клієнта:
припиняє очікування відповіді fetch;
може зупинити читання тіла відповіді;
повідомляє пов’язаним API про необхідність завершити роботу.
Однак це не гарантує, що сервер не встиг виконати операцію. Наприклад, сервер може вже отримати POST-запит і почати змінювати дані, коли браузер припинив чекати на відповідь.
Тому скасування запиту не замінює:
ідемпотентність операцій;
перевірку даних на сервері;
контроль повторних запитів;
механізми транзакцій.
AbortSignal підтримують не лише мережеві запити. Його можна використовувати у власних асинхронних функціях.
function wait(ms, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const timerId = setTimeout(() => {
signal.removeEventListener("abort", handleAbort);
resolve();
}, ms);
function handleAbort() {
clearTimeout(timerId);
reject(signal.reason);
}
signal.addEventListener("abort", handleAbort, { once: true });
});
}
async function runTask(signal) {
console.log("Початок операції");
await wait(3000, signal);
console.log("Операцію завершено");
}
const controller = new AbortController();
runTask(controller.signal).catch((error) => {
if (controller.signal.aborted) {
console.log("Операцію скасовано");
} else {
console.error(error);
}
});
setTimeout(() => {
controller.abort(new Error("Операцію перервано користувачем"));
}, 1000);Власна функція має:
перевірити, чи сигнал уже скасований;
підписатися на подію abort;
звільнити таймери та інші ресурси;
завершити проміс із помилкою або іншим узгодженим результатом.
Неправильно:
const controller = new AbortController();
function loadData() {
return fetch("/api/data", {
signal: controller.signal,
});
}
controller.abort();
loadData(); // Цей запит одразу буде скасованоПісля abort() сигнал назавжди залишається скасованим. Створюйте новий контролер для кожної незалежної операції.
Користувач не повинен бачити повідомлення «Сервер недоступний», якщо запит було скасовано навмисно.
catch (error) {
if (error.name === "AbortError") {
return;
}
showError("Сталася помилка");
}Одного abort() часто достатньо, але для критичних оновлень інтерфейсу варто додатково перевіряти, що відповідь належить поточному запиту.
clearTimeoutЯкщо таймер використовується для тайм-ауту, його потрібно очищати після завершення запиту. Для цього зручно застосовувати finally.
const timerId = setTimeout(() => {
controller.abort();
}, 5000);
try {
await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timerId);
}fetch відхилить проміс через HTTP-помилкуСтатуси 404 і 500 не є помилкою мережі для fetch. Їх потрібно перевіряти самостійно:
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}AbortController використовується для скасування асинхронних операцій.
controller.signal передається до fetch.
controller.abort() надсилає сигнал скасування.
Скасування fetch зазвичай потрібно обробляти через error.name === "AbortError".
Тайм-аут можна реалізувати через setTimeout або AbortSignal.timeout().
Для пошуку та інших швидких дій варто скасовувати попередні запити.
Перевірка номера запиту захищає інтерфейс від застарілих відповідей.
Скасування на клієнті не гарантує, що сервер не виконав операцію.
Контролер не можна повторно використовувати після abort().