Back

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

Отмена 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.

Listen to your bugs 🧘, with OpenReplay

See how users use your app and resolve issues fast.
Loved by thousands of developers