Не проспите AbortController
AbortController
.Что такое AbortController
AbortController
— это глобальный класс в JavaScript, который можно использовать для прерывания, ну, в общем, чего угодно!
Вот как его использовать:
const controller = new AbortController()
controller.signal
controller.abort()
Создав экземпляр контроллера, вы получаете две вещи:
- Свойство
signal
, являющееся экземпляромAbortSignal
. Это подключаемая часть, которую можно предоставить любому API для реакции на событие abort, и реализовать её соответствующим образом. Например, если предоставить её запросуfetch()
, запрос будет прерван; - Метод
.abort()
, при вызове которого срабатывает событие abort наsignal
. Он также обновляет сигнал, чтобы он был помечен как прерванный.
Пока всё хорошо. Но где же собственно логика прерывания? В этом вся прелесть — она определяется потребителем. Обработка прерывания сводится к прослушиванию события abort
и реализации прерывания тем способом, который подходит для данной логики:
controller.signal.addEventListener('abort', () => {
// Реализация логики прерывания.
})
Давайте изучим стандартные API JavaScript, поддерживающие 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
! Как только произойдёт событие abort
по этому сигналу, промис запроса, возвращённый функцией fetch()
, будет отклонён, прерывая ожидающий запрос.
function uploadFile(file: File) {
const controller = new AbortController()
// Предоставьте сигнал abort этому fetch запросу,
// чтобы его можно было прервать в любой момент, вызвав `controller.abort()`.
const response = fetch('/upload', {
method: 'POST',
body: file,
signal: controller.signal,
})
return { response, controller }
}
Здесь функция uploadFile()
инициирует запрос POST
/upload
, возвращая соответствующий промис response
, а также ссылку на controller
для прерывания запроса в любой момент. Это удобно, если необходимо отменить загрузку, например, когда пользователь нажимает на кнопку Отмена
.
Класс AbortSignal
также поставляется с несколькими статическими методами, упрощающими обработку запросов в JavaScript.
AbortSignal.timeout
Статический метод AbortSignal.timeout()
можно использовать как сокращение для создания сигнала, отправляющего событие abort
по истечении определённого времени ожидания. Нет необходимости создавать AbortController
, если всё, что нужно, — это отменить запрос по таймауту:
fetch(url, {
// Если запрос выполняется более 3000 мс,
// он автоматически прерывается.
signal: AbortSignal.timeout(3000),
})
AbortSignal.any
Подобно тому, как можно использовать Promise.race()
для обработки нескольких промисов в порядке очереди, можно использовать статический метод AbortSignal.any()
для объединения нескольких сигналов abort
в один.
const publicController = new AbortController()
const internalController = new AbortController()
channel.addEventListener('message', handleMessage, {
signal: AbortSignal.any([publicController.signal, internalController.signal]),
})
В приведённом выше примере представлены два контроллера прерывания. Публичный контроллер (publicController
) открыт для потребителя кода, позволяя ему вызвать прерывание, в результате чего слушатель события message
будет удалён. Внутренний (internalController
) позволяет мне удалить этот слушатель, не вмешиваясь в работу публичного контроллера прерывания.
Если какой-либо из сигналов прерывания, переданных в AbortSignal.any()
, отправляет событие abort
, то родительский сигнал также отправит событие abort
. Любые другие события abort
после этого момента игнорируются.
Потоки
Также для прерывания потоков можно использовать AbortController
и AbortSignal
.
const stream = new WritableStream({
write(chunk, controller) {
controller.signal.addEventListener('abort', () => {
// Обработка прерывания потока.
})
},
})
const writer = stream.getWriter()
await writer.abort()
Контроллер WritableStream
использует свойство signal
, являющееся тем же старым AbortSignal
. Таким образом, можно вызвать writer.abort()
, что приведёт к событию abort
в controller.signal
в методе write()
в потоке.
Делаем всё прерываемым
Моя любимая часть API 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
. Этот слушатель будет вызываться всякий раз, когда будет происходить событие abort
, т. е. когда будет вызван 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()
с экземпляром db
для создания transaction
с возможностью отмены. С этого момента можно использовать transaction
, как это обычно делается в Drizzle, выполняя несколько операций с базой данных, но можно также подать signal
прерывания, позволяющий отменить их все сразу.
Обработка ошибок прерывания
Каждое событие abort сопровождается указанием причины прерывания. Это даёт ещё больше возможностей для настройки, поскольку можно по-разному реагировать на разные причины прерывания.
Причина прерывания является необязательным аргументом метода controller.abort()
. Получить доступ к причине прерывания можно в свойстве reason
любого экземпляра AbortSignal
.
const controller = new AbortController()
controller.signal.addEventListener('abort', () => {
console.log(controller.signal.reason) // "user cancellation"
})
// Укажите причину прерывания.
controller.abort('user cancellation')
Заключение
Если вы создаёте библиотеки на JavaScript, в которых прерывание или отмена операций имеет смысл, настоятельно рекомендую обратить внимание на API AbortController
. Он потрясающий! А если создаёте приложения, то можете использовать контроллер Abort
для большего эффекта, когда нужно отменить запросы, удалить слушателей событий, прервать потоки или научить любую логику быть прерываемой.