Отмена HTTP-запросов в полёте с помощью AbortController

Современные веб-приложения часто выполняют HTTP-запросы, которые пользователям может не потребоваться завершать. Пользователь, вводящий текст в поисковое поле, генерирует множество запросов, но важен только последний. Пользователь, переходящий с одной страницы на другую, делает все ожидающие запросы для этой страницы неактуальными. Без правильной отмены эти ненужные запросы тратят пропускную способность, потребляют ресурсы сервера и могут привести к появлению устаревших данных в пользовательском интерфейсе.
API AbortController предоставляет чистый, стандартизированный способ отмены fetch-запросов и других асинхронных операций. В этой статье показано, как реализовать отмену запросов с использованием AbortController, включая практические паттерны, такие как debouncing поиска, очистка компонентов и обработка таймаутов.
Ключевые выводы
- AbortController создаёт сигнал, который fetch отслеживает на предмет событий отмены
- Всегда проверяйте AbortError в блоках catch, чтобы отличить отмены от сбоев
- Отменяйте предыдущие запросы в интерфейсах поиска для предотвращения состояний гонки
- Используйте функции очистки в компонентах фреймворков для отмены запросов при размонтировании
- Комбинируйте AbortController с setTimeout для функциональности таймаута запросов
- Каждый AbortController используется однократно - создавайте новые для логики повторных попыток
- Node.js 18+ включает нативную поддержку AbortController
Что делают AbortController и AbortSignal
AbortController создаёт объект-контроллер, который управляет отменой через связанный AbortSignal. У контроллера одна задача: вызов abort()
, когда вы хотите отменить операцию. Сигнал выступает в качестве канала связи, который fetch и другие API отслеживают на предмет событий отмены.
const controller = new AbortController()
const signal = controller.signal
// Сигнал изначально не прерван
console.log(signal.aborted) // false
// Вызов abort() изменяет состояние сигнала
controller.abort()
console.log(signal.aborted) // true
Когда вы передаёте сигнал в fetch и позже вызываете abort()
, промис fetch отклоняется с DOMException
с именем AbortError
. Это позволяет различать отмены и реальные сетевые сбои.
Базовая реализация AbortController
Для отмены fetch-запроса создайте AbortController, передайте его сигнал в fetch, затем вызовите abort()
при необходимости:
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log('Data received:', data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('Request was cancelled')
} else {
console.error('Request failed:', error)
}
})
// Отменить запрос
controller.abort()
Ключевые моменты:
- Создайте контроллер перед выполнением запроса
- Передайте
controller.signal
в опции fetch - Вызовите
controller.abort()
для отмены - Проверьте
AbortError
в блоке catch
Как отменить поисковые запросы при вводе пользователем
Интерфейсы поиска часто запускают запросы при каждом нажатии клавиши. Без отмены медленные ответы могут приходить в неправильном порядке, показывая результаты для устаревших запросов. Вот как отменить предыдущие поисковые запросы:
let searchController = null
function performSearch(query) {
// Отменить любой существующий поиск
if (searchController) {
searchController.abort()
}
// Создать новый контроллер для этого поиска
searchController = new AbortController()
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: searchController.signal
})
.then(response => response.json())
.then(results => {
console.log('Search results:', results)
updateSearchUI(results)
})
.catch(error => {
if (error.name === 'AbortError') {
console.log('Search cancelled')
} else {
console.error('Search failed:', error)
showSearchError()
}
})
}
// Использование: каждый новый поиск отменяет предыдущий
performSearch('javascript')
performSearch('javascript frameworks') // Отменяет поиск 'javascript'
Этот паттерн гарантирует, что в пользовательском интерфейсе появляются только результаты самого последнего поиска, предотвращая состояния гонки, когда более медленные запросы перезаписывают более быстрые.
Отмена запросов при размонтировании компонента
Во фронтенд-фреймворках компоненты часто выполняют fetch-запросы, которые должны быть отменены при размонтировании компонента. Это предотвращает утечки памяти и ошибки от попыток обновления размонтированных компонентов.
Пример для React
import { useEffect, useState } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const controller = new AbortController()
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const userData = await response.json()
setUser(userData)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to fetch user:', error)
}
} finally {
if (!controller.signal.aborted) {
setLoading(false)
}
}
}
fetchUser()
// Функция очистки отменяет запрос
return () => controller.abort()
}, [userId])
if (loading) return <div>Loading...</div>
return <div>{user?.name}</div>
}
Пример для Vue
export default {
data() {
return {
user: null,
controller: null
}
},
async mounted() {
this.controller = new AbortController()
try {
const response = await fetch(`/api/users/${this.userId}`, {
signal: this.controller.signal
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
this.user = await response.json()
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to fetch user:', error)
}
}
},
beforeUnmount() {
if (this.controller) {
this.controller.abort()
}
}
}
Реализация таймаутов запросов с AbortController
Сетевые запросы могут зависать бесконечно. AbortController в сочетании с setTimeout
предоставляет чистый механизм таймаута:
function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, timeoutMs)
return fetch(url, {
...options,
signal: controller.signal
}).finally(() => {
clearTimeout(timeoutId)
})
}
// Использование
fetchWithTimeout('/api/slow-endpoint', {}, 3000)
.then(response => response.json())
.then(data => console.log('Data:', data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('Request timed out')
} else {
console.error('Request failed:', error)
}
})
Для переиспользуемой логики таймаута создайте утилитарную функцию:
function createTimeoutSignal(timeoutMs) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
// Очистить таймаут при использовании сигнала
controller.signal.addEventListener('abort', () => {
clearTimeout(timeoutId)
}, { once: true })
return controller.signal
}
// Использование
fetch('/api/data', { signal: createTimeoutSignal(5000) })
.then(response => response.json())
.catch(error => {
if (error.name === 'AbortError') {
console.log('Request timed out or was cancelled')
}
})
Правильная обработка ошибок для отменённых запросов
Всегда различайте отмены и реальные ошибки. AbortError указывает на намеренную отмену, а не на сбой:
async function handleRequest(url) {
const controller = new AbortController()
try {
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return await response.json()
} catch (error) {
if (error.name === 'AbortError') {
// Это ожидается при отмене - не логировать как ошибку
console.log('Request was cancelled')
return null
}
// Это реальная ошибка, которая требует обработки
console.error('Request failed:', error)
throw error
}
}
Для приложений с отслеживанием ошибок исключите AbortError из отчётов об ошибках:
.catch(error => {
if (error.name === 'AbortError') {
// Не сообщать об отменах в отслеживание ошибок
return
}
// Сообщить о реальных ошибках
errorTracker.captureException(error)
throw error
})
Управление множественными запросами
При работе с несколькими параллельными запросами вам может потребоваться отменить их все сразу или управлять ими индивидуально:
class RequestManager {
constructor() {
this.controllers = new Map()
}
async fetch(key, url, options = {}) {
// Отменить существующий запрос с тем же ключом
this.cancel(key)
const controller = new AbortController()
this.controllers.set(key, controller)
try {
const response = await fetch(url, {
...options,
signal: controller.signal
})
return response
} finally {
this.controllers.delete(key)
}
}
cancel(key) {
const controller = this.controllers.get(key)
if (controller) {
controller.abort()
this.controllers.delete(key)
}
}
cancelAll() {
for (const controller of this.controllers.values()) {
controller.abort()
}
this.controllers.clear()
}
}
// Использование
const requestManager = new RequestManager()
// Эти запросы могут управляться независимо
requestManager.fetch('user-profile', '/api/user/123')
requestManager.fetch('user-posts', '/api/user/123/posts')
// Отменить конкретный запрос
requestManager.cancel('user-profile')
// Отменить все ожидающие запросы
requestManager.cancelAll()
Поддержка браузерами и совместимость с Node.js
AbortController имеет отличную поддержку браузерами:
- Chrome 66+
- Firefox 57+
- Safari 12.1+
- Edge 16+
Для Node.js AbortController доступен нативно в Node 18+. Для более ранних версий используйте полифилл abort-controller:
npm install abort-controller
// Для Node.js < 18
const { AbortController } = require('abort-controller')
// Использовать как обычно
const controller = new AbortController()
Заключение
AbortController предоставляет чистый, стандартизированный способ отмены fetch-запросов в современных веб-приложениях. Ключевые паттерны: создать контроллер перед каждым запросом, передать его сигнал в fetch и вызвать abort() при необходимости отмены. Всегда обрабатывайте AbortError отдельно от реальных сетевых сбоев, чтобы не рассматривать намеренные отмены как ошибки.
Наиболее распространённые случаи использования — управление поисковыми запросами, очистка компонентов и обработка таймаутов — следуют простым паттернам, которые вы можете адаптировать под свои конкретные потребности. При правильной реализации отмена запросов улучшает как пользовательский опыт, так и производительность приложения, предотвращая ненужную сетевую активность и обновления устаревшими данными.
Готовы реализовать отмену запросов в своём приложении? Начните с базового паттерна для наиболее распространённого случая использования, затем расширьте до более сложных сценариев по мере необходимости. Инвестиции в правильное управление запросами окупаются в производительности приложения и пользовательском опыте.
Часто задаваемые вопросы
Нет, после вызова abort() сигнал контроллера остаётся в прерванном состоянии навсегда. Вы должны создать новый AbortController для каждого нового запроса или попытки повтора.
Нет, abort() только предотвращает обработку ответа вашим JavaScript-кодом. Если запрос уже был отправлен по сети, сервер всё ещё может получить и обработать его.
Создайте несколько контроллеров и сохраните их в массиве или Map, затем вызовите abort() для каждого. Альтернативно, используйте один контроллер и передайте его сигнал нескольким fetch-вызовам - однократный вызов abort() отменит их все.
Ничего не происходит. Вызов abort() игнорируется, если запрос уже успешно завершился или завершился с ошибкой по другим причинам.
Да, AbortController работает одинаково как с цепочками Promise, так и с async/await. Промис fetch будет отклонён с AbortError при отмене, что можно поймать блоками try/catch.