← JavaScript/AbortController: отмена fetch-запроса#136 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

AbortController: отмена fetch-запроса

Пользователь печатает в поисковой строке "ноутбук" — каждый символ запускает новый fetch. К моменту когда он допечатал, летит 7 запросов. Ответы приходят не по порядку — результаты мелькают и показываются не те. Решение: AbortController — отменять предыдущий запрос при каждом новом вводе.

Что решает AbortController

Контроль над уже запущенными операциями. Без AbortController завершить fetch после его отправки невозможно — можно только игнорировать ответ, но запрос всё равно потребляет ресурсы.

На основе предыдущих уроков

  • Promise: fetch возвращает Promise
  • async/await: ожидание результата fetch
  • try/catch: обработка AbortError
  • fetch: параметр signal передаётся в options
  • AbortController и AbortSignal

    const controller = new AbortController()
    const signal = controller.signal  // объект AbortSignal
    
    // Передаём signal в fetch
    fetch('/api/search?q=ноутбук', { signal })
    
    // Отменяем запрос
    controller.abort()
    // fetch выбросит DOMException с именем 'AbortError'

    Обработка отмены

    async function search(query) {
      const controller = new AbortController()
    
      try {
        const response = await fetch(`/api/search?q=${query}`, {
          signal: controller.signal,
        })
        const data = await response.json()
        return data
    
      } catch (err) {
        if (err.name === 'AbortError') {
          console.log('Запрос отменён')
          return null  // отмена — это не ошибка
        }
        throw err  // реальная сетевая ошибка — пробрасываем
      }
    }

    Проверка состояния сигнала

    const controller = new AbortController()
    const { signal } = controller
    
    console.log(signal.aborted)  // false
    
    // Слушаем событие отмены
    signal.addEventListener('abort', () => {
      console.log('Запрос был отменён:', signal.reason)
    })
    
    controller.abort('Пользователь ушёл со страницы')
    console.log(signal.aborted)  // true
    console.log(signal.reason)   // 'Пользователь ушёл со страницы'

    AbortSignal.timeout() — автоматическая отмена

    В современных браузерах есть удобный статический метод:

    // Автоматически отменяет запрос через 5 секунд
    const response = await fetch('/api/data', {
      signal: AbortSignal.timeout(5000),
    })

    Отмена нескольких запросов одним контроллером

    Один AbortController может управлять несколькими запросами:

    const controller = new AbortController()
    const { signal } = controller
    
    // Запускаем несколько запросов параллельно
    const [users, products, orders] = await Promise.all([
      fetch('/api/users', { signal }),
      fetch('/api/products', { signal }),
      fetch('/api/orders', { signal }),
    ])
    
    // Отменяем все сразу
    controller.abort()

    Реальный пример: поиск с автоотменой

    При каждом новом символе в поле поиска — отменяем предыдущий запрос:

    let searchController = null
    
    async function handleSearchInput(query) {
      // Отменяем предыдущий запрос, если он ещё идёт
      if (searchController) {
        searchController.abort()
      }
    
      // Создаём новый контроллер для нового запроса
      searchController = new AbortController()
    
      try {
        const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: searchController.signal,
        })
    
        if (!response.ok) throw new Error('Ошибка сети')
    
        const results = await response.json()
        renderResults(results)
    
      } catch (err) {
        if (err.name !== 'AbortError') {
          showError(err.message)
        }
        // AbortError молча игнорируем
      }
    }
    
    // Подключаем к полю ввода
    searchInput.addEventListener('input', (e) => {
      handleSearchInput(e.target.value)
    })

    AbortController + собственные асинхронные операции

    AbortController можно использовать не только с fetch, но и с любым async-кодом:

    async function longOperation(signal) {
      for (let i = 0; i < 1000; i++) {
        if (signal.aborted) throw new DOMException('Отменено', 'AbortError')
        await processChunk(i)
      }
    }
    
    const controller = new AbortController()
    setTimeout(() => controller.abort(), 3000)
    
    try {
      await longOperation(controller.signal)
    } catch (err) {
      if (err.name === 'AbortError') console.log('Операция отменена по таймауту')
    }

    Типичные ошибки

    Ошибка 1: не создают новый контроллер для каждого запроса

    // Сломано: один контроллер использован повторно после abort()
    const controller = new AbortController()
    controller.abort()
    
    // Второй запрос отменится мгновенно — signal уже aborted!
    await fetch('/api/data', { signal: controller.signal })
    
    // Исправлено: новый контроллер для каждого запроса
    const controller = new AbortController()
    // ...использовали, отменили
    const newController = new AbortController()  // новый для следующего запроса

    Ошибка 2: не очищают таймер при отмене

    // Сломано: таймер продолжает работать даже после успешного ответа
    async function fetchWithTimeout(url, ms) {
      const controller = new AbortController()
      setTimeout(() => controller.abort(), ms)  // таймер не очищается!
      return fetch(url, { signal: controller.signal })
    }
    
    // Исправлено:
    async function fetchWithTimeout(url, ms) {
      const controller = new AbortController()
      const timerId = setTimeout(() => controller.abort(), ms)
      try {
        const res = await fetch(url, { signal: controller.signal })
        clearTimeout(timerId)  // успех — очищаем таймер
        return res
      } catch (err) {
        clearTimeout(timerId)
        throw err
      }
    }

    Ошибка 3: AbortError обрабатывают как обычную ошибку

    // Сломано: пользователь видит "Ошибка: Отменено" при каждом новом вводе
    try {
      const data = await fetch(url, { signal })
    } catch (err) {
      showError(err.message)  // плохо — AbortError не ошибка, а нормальный сценарий
    }
    
    // Исправлено:
    try {
      const data = await fetch(url, { signal })
      showResults(await data.json())
    } catch (err) {
      if (err.name === 'AbortError') return  // молча игнорируем — это ожидаемо
      showError(err.message)                 // только реальные ошибки
    }

    В реальных проектах

  • Поиск с debounce: AbortController + setTimeout отменяют предыдущий запрос при каждом символе
  • Навигация: при переходе на другую страницу отменяют все незавершённые запросы текущей
  • React useEffect: cleanup-функция вызывает controller.abort() при размонтировании компонента
  • Таймауты: AbortSignal.timeout(5000) — стандартный способ ограничить время запроса
  • Примеры

    Симуляция поиска с автоотменой предыдущего запроса при каждом новом вводе

    // Симулируем fetch с задержкой и поддержкой AbortSignal
    function fakeFetch(query, signal) {
      return new Promise((resolve, reject) => {
        // Если уже отменён до старта
        if (signal.aborted) {
          return reject(new DOMException('Отменено до старта', 'AbortError'))
        }
    
        const delay = 500 + Math.random() * 300  // 500-800мс задержка
    
        const timerId = setTimeout(() => {
          // К моменту ответа запрос мог быть отменён
          if (signal.aborted) {
            reject(new DOMException('Отменено во время выполнения', 'AbortError'))
          } else {
            resolve({
              query,
              results: [
                `${query} — результат 1`,
                `${query} — результат 2`,
                `${query} — результат 3`,
              ],
            })
          }
        }, delay)
    
        // Если signal сработает — отменяем таймер
        signal.addEventListener('abort', () => {
          clearTimeout(timerId)
          reject(new DOMException('Запрос отменён', 'AbortError'))
        })
      })
    }
    
    // Менеджер поиска с автоотменой
    class SearchManager {
      constructor() {
        this.controller = null
        this.totalRequests = 0
        this.cancelledRequests = 0
        this.completedRequests = 0
      }
    
      async search(query) {
        // Отменяем предыдущий запрос
        if (this.controller) {
          this.controller.abort()
          this.cancelledRequests++
        }
    
        this.controller = new AbortController()
        this.totalRequests++
    
        const requestId = this.totalRequests
        console.log(`[#${requestId}] Запрос: "${query}"`)
    
        try {
          const data = await fakeFetch(query, this.controller.signal)
          this.completedRequests++
          console.log(`[#${requestId}] Результат для "${query}":"`, data.results)
          return data
        } catch (err) {
          if (err.name === 'AbortError') {
            console.log(`[#${requestId}] Отменён (новый запрос пришёл раньше)`)
            return null
          }
          throw err
        }
      }
    
      getStats() {
        return {
          total: this.totalRequests,
          cancelled: this.cancelledRequests,
          completed: this.completedRequests,
        }
      }
    }
    
    // Симуляция быстрого ввода пользователя
    async function runDemo() {
      const manager = new SearchManager()
    
      // Пользователь быстро набирает "ноутбук"
      const queries = ['н', 'но', 'ноу', 'ноут', 'ноутб', 'ноутбу', 'ноутбук']
    
      for (const q of queries) {
        manager.search(q)  // не await — запускаем быстро, не ждём
        await new Promise(r => setTimeout(r, 150))  // 150мс между символами
      }
    
      // Ждём завершения последнего запроса
      await new Promise(r => setTimeout(r, 1000))
    
      console.log('\nСтатистика:')
      const stats = manager.getStats()
      console.log('Всего запросов:', stats.total)
      console.log('Отменено:', stats.cancelled)
      console.log('Завершено:', stats.completed)
    }
    
    runDemo()

    AbortController: отмена fetch-запроса

    Пользователь печатает в поисковой строке "ноутбук" — каждый символ запускает новый fetch. К моменту когда он допечатал, летит 7 запросов. Ответы приходят не по порядку — результаты мелькают и показываются не те. Решение: AbortController — отменять предыдущий запрос при каждом новом вводе.

    Что решает AbortController

    Контроль над уже запущенными операциями. Без AbortController завершить fetch после его отправки невозможно — можно только игнорировать ответ, но запрос всё равно потребляет ресурсы.

    На основе предыдущих уроков

  • Promise: fetch возвращает Promise
  • async/await: ожидание результата fetch
  • try/catch: обработка AbortError
  • fetch: параметр signal передаётся в options
  • AbortController и AbortSignal

    const controller = new AbortController()
    const signal = controller.signal  // объект AbortSignal
    
    // Передаём signal в fetch
    fetch('/api/search?q=ноутбук', { signal })
    
    // Отменяем запрос
    controller.abort()
    // fetch выбросит DOMException с именем 'AbortError'

    Обработка отмены

    async function search(query) {
      const controller = new AbortController()
    
      try {
        const response = await fetch(`/api/search?q=${query}`, {
          signal: controller.signal,
        })
        const data = await response.json()
        return data
    
      } catch (err) {
        if (err.name === 'AbortError') {
          console.log('Запрос отменён')
          return null  // отмена — это не ошибка
        }
        throw err  // реальная сетевая ошибка — пробрасываем
      }
    }

    Проверка состояния сигнала

    const controller = new AbortController()
    const { signal } = controller
    
    console.log(signal.aborted)  // false
    
    // Слушаем событие отмены
    signal.addEventListener('abort', () => {
      console.log('Запрос был отменён:', signal.reason)
    })
    
    controller.abort('Пользователь ушёл со страницы')
    console.log(signal.aborted)  // true
    console.log(signal.reason)   // 'Пользователь ушёл со страницы'

    AbortSignal.timeout() — автоматическая отмена

    В современных браузерах есть удобный статический метод:

    // Автоматически отменяет запрос через 5 секунд
    const response = await fetch('/api/data', {
      signal: AbortSignal.timeout(5000),
    })

    Отмена нескольких запросов одним контроллером

    Один AbortController может управлять несколькими запросами:

    const controller = new AbortController()
    const { signal } = controller
    
    // Запускаем несколько запросов параллельно
    const [users, products, orders] = await Promise.all([
      fetch('/api/users', { signal }),
      fetch('/api/products', { signal }),
      fetch('/api/orders', { signal }),
    ])
    
    // Отменяем все сразу
    controller.abort()

    Реальный пример: поиск с автоотменой

    При каждом новом символе в поле поиска — отменяем предыдущий запрос:

    let searchController = null
    
    async function handleSearchInput(query) {
      // Отменяем предыдущий запрос, если он ещё идёт
      if (searchController) {
        searchController.abort()
      }
    
      // Создаём новый контроллер для нового запроса
      searchController = new AbortController()
    
      try {
        const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: searchController.signal,
        })
    
        if (!response.ok) throw new Error('Ошибка сети')
    
        const results = await response.json()
        renderResults(results)
    
      } catch (err) {
        if (err.name !== 'AbortError') {
          showError(err.message)
        }
        // AbortError молча игнорируем
      }
    }
    
    // Подключаем к полю ввода
    searchInput.addEventListener('input', (e) => {
      handleSearchInput(e.target.value)
    })

    AbortController + собственные асинхронные операции

    AbortController можно использовать не только с fetch, но и с любым async-кодом:

    async function longOperation(signal) {
      for (let i = 0; i < 1000; i++) {
        if (signal.aborted) throw new DOMException('Отменено', 'AbortError')
        await processChunk(i)
      }
    }
    
    const controller = new AbortController()
    setTimeout(() => controller.abort(), 3000)
    
    try {
      await longOperation(controller.signal)
    } catch (err) {
      if (err.name === 'AbortError') console.log('Операция отменена по таймауту')
    }

    Типичные ошибки

    Ошибка 1: не создают новый контроллер для каждого запроса

    // Сломано: один контроллер использован повторно после abort()
    const controller = new AbortController()
    controller.abort()
    
    // Второй запрос отменится мгновенно — signal уже aborted!
    await fetch('/api/data', { signal: controller.signal })
    
    // Исправлено: новый контроллер для каждого запроса
    const controller = new AbortController()
    // ...использовали, отменили
    const newController = new AbortController()  // новый для следующего запроса

    Ошибка 2: не очищают таймер при отмене

    // Сломано: таймер продолжает работать даже после успешного ответа
    async function fetchWithTimeout(url, ms) {
      const controller = new AbortController()
      setTimeout(() => controller.abort(), ms)  // таймер не очищается!
      return fetch(url, { signal: controller.signal })
    }
    
    // Исправлено:
    async function fetchWithTimeout(url, ms) {
      const controller = new AbortController()
      const timerId = setTimeout(() => controller.abort(), ms)
      try {
        const res = await fetch(url, { signal: controller.signal })
        clearTimeout(timerId)  // успех — очищаем таймер
        return res
      } catch (err) {
        clearTimeout(timerId)
        throw err
      }
    }

    Ошибка 3: AbortError обрабатывают как обычную ошибку

    // Сломано: пользователь видит "Ошибка: Отменено" при каждом новом вводе
    try {
      const data = await fetch(url, { signal })
    } catch (err) {
      showError(err.message)  // плохо — AbortError не ошибка, а нормальный сценарий
    }
    
    // Исправлено:
    try {
      const data = await fetch(url, { signal })
      showResults(await data.json())
    } catch (err) {
      if (err.name === 'AbortError') return  // молча игнорируем — это ожидаемо
      showError(err.message)                 // только реальные ошибки
    }

    В реальных проектах

  • Поиск с debounce: AbortController + setTimeout отменяют предыдущий запрос при каждом символе
  • Навигация: при переходе на другую страницу отменяют все незавершённые запросы текущей
  • React useEffect: cleanup-функция вызывает controller.abort() при размонтировании компонента
  • Таймауты: AbortSignal.timeout(5000) — стандартный способ ограничить время запроса
  • Примеры

    Симуляция поиска с автоотменой предыдущего запроса при каждом новом вводе

    // Симулируем fetch с задержкой и поддержкой AbortSignal
    function fakeFetch(query, signal) {
      return new Promise((resolve, reject) => {
        // Если уже отменён до старта
        if (signal.aborted) {
          return reject(new DOMException('Отменено до старта', 'AbortError'))
        }
    
        const delay = 500 + Math.random() * 300  // 500-800мс задержка
    
        const timerId = setTimeout(() => {
          // К моменту ответа запрос мог быть отменён
          if (signal.aborted) {
            reject(new DOMException('Отменено во время выполнения', 'AbortError'))
          } else {
            resolve({
              query,
              results: [
                `${query} — результат 1`,
                `${query} — результат 2`,
                `${query} — результат 3`,
              ],
            })
          }
        }, delay)
    
        // Если signal сработает — отменяем таймер
        signal.addEventListener('abort', () => {
          clearTimeout(timerId)
          reject(new DOMException('Запрос отменён', 'AbortError'))
        })
      })
    }
    
    // Менеджер поиска с автоотменой
    class SearchManager {
      constructor() {
        this.controller = null
        this.totalRequests = 0
        this.cancelledRequests = 0
        this.completedRequests = 0
      }
    
      async search(query) {
        // Отменяем предыдущий запрос
        if (this.controller) {
          this.controller.abort()
          this.cancelledRequests++
        }
    
        this.controller = new AbortController()
        this.totalRequests++
    
        const requestId = this.totalRequests
        console.log(`[#${requestId}] Запрос: "${query}"`)
    
        try {
          const data = await fakeFetch(query, this.controller.signal)
          this.completedRequests++
          console.log(`[#${requestId}] Результат для "${query}":"`, data.results)
          return data
        } catch (err) {
          if (err.name === 'AbortError') {
            console.log(`[#${requestId}] Отменён (новый запрос пришёл раньше)`)
            return null
          }
          throw err
        }
      }
    
      getStats() {
        return {
          total: this.totalRequests,
          cancelled: this.cancelledRequests,
          completed: this.completedRequests,
        }
      }
    }
    
    // Симуляция быстрого ввода пользователя
    async function runDemo() {
      const manager = new SearchManager()
    
      // Пользователь быстро набирает "ноутбук"
      const queries = ['н', 'но', 'ноу', 'ноут', 'ноутб', 'ноутбу', 'ноутбук']
    
      for (const q of queries) {
        manager.search(q)  // не await — запускаем быстро, не ждём
        await new Promise(r => setTimeout(r, 150))  // 150мс между символами
      }
    
      // Ждём завершения последнего запроса
      await new Promise(r => setTimeout(r, 1000))
    
      console.log('\nСтатистика:')
      const stats = manager.getStats()
      console.log('Всего запросов:', stats.total)
      console.log('Отменено:', stats.cancelled)
      console.log('Завершено:', stats.completed)
    }
    
    runDemo()

    Задание

    Напиши функцию fetchWithTimeout(url, ms), которая выполняет fetch-запрос и автоматически отменяет его, если он не завершится за ms миллисекунд. При таймауте функция должна выбрасывать ошибку с сообщением "Таймаут: запрос превысил Xмс".

    Подсказка

    const controller = new AbortController(); const timerId = setTimeout(() => controller.abort(), ms); return fetch(url, { signal: controller.signal })

    Загружаем среду выполнения...
    Загружаем AI-помощника...