В этой статье я расскажу об одном из стандартных API JavaScript, о котором, вы, возможно не слышали. Это AbortController.


❯ Что такое AbortController?


AbortController — это глобальный класс в JS, который можно использовать для прерывания любых асинхронных операций.


const controller = new AbortController()

controller.signal
controller.abort()

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


  1. Свойство signal — экземпляр AbortSignal. Это модульная часть, которую можно предоставить любому API, чтобы реагировать на событие прерывания и обрабатывать его соответствующим образом. Например, при передаче signal в запрос fetch() запрос станет прерываемым.
  2. Метод abort, который, при вызове, инициирует событие прерывания на signal и помечает его как прерванный.

Пока все понятно. А как насчет самой логики прерывания? В этом и заключается прелесть — она определяется самим разработчиком. Необходимо лишь регистрировать событие abort и выполнять прерывание любым способом в соответствии с определенной логикой.


controller.signal.addEventListener('abort', () => {
  // Логика прерывания
})

Рассмотрим, какие стандартные API JS поддерживают AbortSignal.


❯ Использование


Обработчики событий


При добавлении обработчика события, ему можно передать signal прерывания, для его автоматического удаления после возникновения события прерывания:


const controller = new AbortController()

window.addEventListener('resize', listener, { signal: controller.signal })

controller.abort()

Вызов controller.abort() автоматически удаляет обработчик события resize, зарегистрированный на window. Это весьма элегантное решение для управления обработчиками событий, поскольку не нужно отдельно сохранять ссылку на функцию-обработчик для ее последующей передачи в removeEventListener():


// const listener = () => {}
// window.addEventListener('resize', listener)
// window.removeEventListener('resize', listener)

const controller = new AbortController()
window.addEventListener('resize', () => {}, { signal: controller.signal })
controller.abort()

Экземпляр AbortController намного удобнее использовать, если за удаление обработчика отвечает другая часть приложения.


Для меня это было настоящим моментом прозрения, когда я понял, что с помощью одного signal можно удалять сразу несколько обработчиков событий:


useEffect(() => {
  const controller = new AbortController()

  window.addEventListener('resize', handleResize, {
    signal: controller.signal,
  })
  window.addEventListener('hashchange', handleHashChange, {
    signal: controller.signal,
  })
  window.addEventListener('storage', handleStorageChange, {
    signal: controller.signal,
  })

  return () => {
    // Вызов `abort()` удаляет ВСЕ обработчики событий
    // связанные с `controller.signal`.
    controller.abort()
  }
}, [])

В приведенном примере мы добавляем хук useEffect() в React, который регистрирует множество обработчиков событий с разной целью и логикой. Обратите внимание, как в функции очистки мы удаляем все обработчики одним вызовом controller.abort(). Это очень удобно.


Запросы fetch()


Функция fetch также поддерживает AbortSignal. Если во время выполнения fetch() возникнет событие прерывания, то промис, возвращаемый fetch(), будет отклонен. Это приведет к прерыванию выполняемого запроса:


function uploadFile(file: File) {
  const controller = new AbortController()

  // Передаем сигнал прерывания в `fetch()`,
  // чтобы его можно было прервать в любое время,
  // вызвав `controller.abort()`.
  const response = fetch('/upload', {
    method: 'POST',
    body: file,
    signal: controller.signal,
  })

  return { response, controller }
}

Здесь функция uploadFile отправляет запрос POST /upload, возвращая связанный с ним промис response и ссылку на controller, который можно в любой момент использовать для отмены этого запроса. Это удобно, если необходимо отменять запрос, например, по требованию пользователя (когда он нажимает на кнопку "Отменить").


Запросы, выполняемые модулем http в Node.js, также поддерживают свойство signal.

Класс AbortSignal также имеет несколько статических методов, упрощающих обработку запросов в JS.


AbortSignal.timeout()


Статический метод AbortSignal.timeout можно использовать для создания сигнала, прерываемого по истечении определенного времени. В этом случае нет смысла создавать экземпляр AbortController:


fetch(url, {
  // Автоматически прерываем запрос, если он
  // выполняется дольше 3000 мс.
  signal: AbortSignal.timeout(3000),
})

AbortSignal.any()


Подобно тому, как Promise.race() позволяет обрабатывать несколько промисов по принципу "первым пришел — первым обработан", статический метод AbortSignal.any дает возможность объединять несколько сигналов отмены в один:


const publicController = new AbortController()
const internalController = new AbortController()

channel.addEventListener('message', handleMessage, {
  signal: AbortSignal.any([publicController.signal, internalController.signal]),
})

В приведенном примере мы использую два контроллера прерывания. Публичный контроллер предоставляется потребителям кода, позволяя им инициировать прерывание, влекущее удаление обработчика события message. Внутренний контроллер, в свою очередь, позволяет мне удалить этот обработчик, не вмешиваясь в работу публичного контроллера.


При генерации события прерывания любым из сигналов, переданных AbortSignal.any(), генерируется "родительский" сигнал и обработчик удаляется. После этого любые другие события прерывания игнорируются.


Потоки


AbortController и AbortSignal можно использовать для прерывания потоков (streams):


const stream = new WritableStream({
  write(chunk, controller) {
    controller.signal.addEventListener('abort', () => {
      // Обрабатываем прерывание потока
    })
  },
})

const writer = stream.getWriter()
await writer.abort()

Контроллер WritableStream экспортирует свойство signal, которое представляет собой тот же AbortSignal. Благодаря этому можно вызвать writer.abort(), и эта операция вызовет событие прерывания на controller.signal в методе write потока.


❯ Возможность прерывания любых операций


Одна из наиболее привлекательных особенностей AbortController — его исключительная универсальность. Возможность прерывания операций можно встроить практически в любую логику.


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


Добавим AbortController в транзакции Drizzle ORM, чтобы получить возможность одновременной отмены множества операций:


import { TransactionRollbackError } from 'drizzle-orm'

function makeCancelableTransaction(db) {
  return (callback, options = {}) => {
    return db.transaction((tx) => {
      return new Promise((resolve, reject) => {
        // Прерываем эту операцию при возникновении события `abort`.
        options.signal?.addEventListener('abort', async () => {
          reject(new TransactionRollbackError())
        })

        return Promise.resolve(callback.call(this, tx)).then(resolve, reject)
      })
    })
  }
}

Функция makeCancelableTransaction принимает экземпляр базы данных и возвращает функцию транзакции высшего порядка, которая теперь принимает signal прерывания в качестве аргумента.


Для того, чтобы узнать, когда произошло прерывание, мы добавляем обработчик события abort к экземпляру signal. Этот обработчик будет вызываться при каждом срабатывании события прерывания, т.е. при каждом вызове controller.abort(). Это позволяет отклонять промис транзакции с ошибкой TransactionRollbackError, что приводит к отмене всей транзакции (это эквивалентно вызову tx.rollback(), который выбрасывает такое же исключение).


Теперь применим это в Drizzle:


const db = drizzle(options)

const controller = new AbortController()
const transaction = makeCancelableTransaction(db)

await transaction(
  async (tx) => {
    await tx
      .update(accounts)
      .set({ balance: sql`${accounts.balance} - 100.00` })
      .where(eq(users.name, 'Dan'))
    await tx
      .update(accounts)
      .set({ balance: sql`${accounts.balance} + 100.00` })
      .where(eq(users.name, 'Andrew'))
  },
  { signal: controller.signal }
)

Мы вызываем утилиту makeCancelableTransaction с экземпляром базы данных, чтобы создать пользовательскую transaction, которую можно прерывать. После этого мы можем использовать кастомную transaction так же, как это обычно делается в Drizzle, выполняя несколько операций с базой данных. Мы также можем предоставить ей signal прерывания, чтобы прервать все операции сразу.


Обработка ошибок прерывания


Каждое событие прерывания сопровождается указанием причины. Это дает возможность более гибко настраивать поведение, позволяя реагировать на различные причины прерывания по-разному.


Причина прерывания — это необязательный аргумент метода controller.abort. Причина прерывания доступна через свойство reason любого экземпляра AbortSignal:


const controller = new AbortController()

controller.signal.addEventListener('abort', () => {
  console.log(controller.signal.reason) // "user cancellation"
})

// Указываем причину отмены
controller.abort('user cancellation')

Аргумент reason может принимать любое валидное значение, поэтому можно использовать строки, ошибки и даже объекты.

❯ Заключение


Если вы разрабатываете библиотеки на JS, где необходимо отменять или прерывать операции, настоятельно рекомендую взглянуть на AbortController. Это действительно удобный инструмент. Если вы создаете приложения, AbortController поможет эффективно управлять отменой запросов, удалением обработчиков событий, прерыванием потоков и реализацией любой другой отменяемой логики.




Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Комментарии (2)


  1. aleksey4uk
    09.10.2024 08:27

    Никогда не задумывался, что можно применять в eventListeners. В основном мы применяли его для отмены запросов в хуках Реакт.


  1. john_samilin
    09.10.2024 08:27

    ага, пропаганда абортов?