Не проспите AbortController

Источник: «Don't Sleep on AbortController»
Сегодня я хотел бы поговорить об одном из стандартных JavaScript API, который вы, скорее всего, проспали. Он называется AbortController.

Что такое AbortController

AbortController — это глобальный класс в JavaScript, который можно использовать для прерывания, ну, в общем, чего угодно!

Вот как его использовать:

const controller = new AbortController()

controller.signal
controller.abort()

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

Пока всё хорошо. Но где же собственно логика прерывания? В этом вся прелесть — она определяется потребителем. Обработка прерывания сводится к прослушиванию события 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 для большего эффекта, когда нужно отменить запросы, удалить слушателей событий, прервать потоки или научить любую логику быть прерываемой.

Комментарии


Дополнительные материалы

Предыдущая Статья

Отладка SQL запросов в Laravel

Следующая Статья

Создание анимации орбиты с помощью CSS переменных