12k
All articles

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

AbortController и AbortSignal позволяют отменять fetch-запросы при вводе в поиске, размонтировании компонентов и таймаутах, исключая устаревшие данные в UI.

OpenReplay Team
OpenReplay Team
Отмена 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 отдельно от реальных сетевых сбоев, чтобы не рассматривать намеренные отмены как ошибки.

Наиболее распространённые случаи использования — управление поисковыми запросами, очистка компонентов и обработка таймаутов — следуют простым паттернам, которые вы можете адаптировать под свои конкретные потребности. При правильной реализации отмена запросов улучшает как пользовательский опыт, так и производительность приложения, предотвращая ненужную сетевую активность и обновления устаревшими данными.

Готовы реализовать отмену запросов в своём приложении? Начните с базового паттерна для наиболее распространённого случая использования, затем расширьте до более сложных сценариев по мере необходимости. Инвестиции в правильное управление запросами окупаются в производительности приложения и пользовательском опыте.

Часто задаваемые вопросы

Можно ли повторно использовать AbortController после вызова abort()?

Нет, после вызова abort() сигнал контроллера остаётся в прерванном состоянии навсегда. Вы должны создать новый AbortController для каждого нового запроса или попытки повтора.

Останавливает ли вызов abort() отправку сетевого запроса на сервер?

Нет, abort() только предотвращает обработку ответа вашим JavaScript-кодом. Если запрос уже был отправлен по сети, сервер всё ещё может получить и обработать его.

Как отменить несколько fetch-запросов одновременно?

Создайте несколько контроллеров и сохраните их в массиве или Map, затем вызовите abort() для каждого. Альтернативно, используйте один контроллер и передайте его сигнал нескольким fetch-вызовам - однократный вызов abort() отменит их все.

Что происходит, если вызвать abort() после того, как fetch уже завершился?

Ничего не происходит. Вызов abort() игнорируется, если запрос уже успешно завершился или завершился с ошибкой по другим причинам.

Можно ли использовать AbortController с синтаксисом async/await?

Да, 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

We use cookies to improve your experience. By using our site, you accept cookies.