今天,我想谈谈 JavaScript 中一个你可能忽视的标准 API。它就是 AbortController。
什么是 AbortController?
AbortController 是 JavaScript 中的一个全局类,你可以用它来中止任何操作!以下是使用方法:
const controller = new AbortController()
controller.signal
controller.abort()
当你创建一个控制器实例时,你会得到两个东西:
- signal 属性:这是 AbortSignal 的一个实例。这是一个可插拔的部分,你可以将其提供给任何 API 以响应中止事件,并相应地实现它。例如,将其提供给 fetch() 请求将中止该请求;
- .abort() 方法:当调用时,会在 signal 上触发中止事件。它还会将信号标记为已中止状态。
到目前为止还不错。但实际的中止逻辑在哪里?这就是它的优美之处 —— 它由使用者定义。中止处理归结为监听 abort 事件,并以适合相关逻辑的方式实现中止:
controller.signal.addEventListener('abort', () => {
// 实现中止逻辑
})
让我们来探索一下原生支持 AbortSignal 的标准 JavaScript API。
使用场景
事件监听器
你可以在添加事件监听器时提供一个中止 signal,这样一旦发生中止,监听器就会自动移除。
const controller = new AbortController()
window.addEventListener('resize', listener, { signal: controller.signal })
controller.abort()
调用 controller.abort() 会从 window 中移除 resize 监听器。这是一种非常优雅的处理事件监听器的方式,因为你不再需要抽象监听器函数只是为了提供给 .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()
}
}, [])
在上面的例子中,我在 React 中添加了一个 useEffect() 钩子,它引入了一堆具有不同目的和逻辑的事件监听器。注意在清理函数中,我可以通过一次调用 controller.abort() 来移除所有添加的监听器。很整洁!
Fetch 请求
fetch() 函数也支持 AbortSignal!一旦信号上的 abort 事件被触发,fetch() 函数返回的请求 promise 将被拒绝,中止待处理的请求。
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 promise 以及一个 controller 引用,可以在任何时候中止该请求。这在需要取消待处理的上传时很有用,例如,当用户点击"取消"按钮时。> Node.js 中 http 模块发出的请求也支持 signal 属性!AbortSignal 类还提供了一些静态方法来简化 JavaScript 中的请求处理。
AbortSignal.timeout
你可以使用 AbortSignal.timeout() 静态方法作为快捷方式,创建一个在特定超时时间后发送中止事件的信号。如果你只想在请求超过超时时间后取消它,就不需要创建 AbortController:
fetch(url, {
// 如果请求完成时间超过 3000ms,自动中止此请求
signal: AbortSignal.timeout(3000),
})
AbortSignal.any
类似于如何使用 Promise.race() 来按先到先得的方式处理多个 promise,你可以使用 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 来取消流。
const stream = new WritableStream({
write(chunk, controller) {
controller.signal.addEventListener('abort', () => {
// 在这里处理流的中止
})
},
})
const writer = stream.getWriter()
await writer.abort()
WritableStream 控制器暴露了 signal 属性,这就是同样的 AbortSignal。这样,我可以调用 writer.abort(),它会冒泡到流的 write() 方法中的 controller.signal 上的中止事件。
让任何东西都可中止
关于 AbortController API,我最喜欢的部分是它极其通用。你可以教会任何逻辑变得可中止!
有了这样的超能力,你不仅可以自己提供更好的体验,还可以增强如何使用原生不支持中止/取消的第三方库。事实上,让我们就来做这件事。
让我们为 Drizzle ORM 事务添加 AbortController,这样我们就可以一次取消多个事务。
import { TransactionRollbackError } from 'drizzle-orm'
function makeCancelableTransaction(db) {
return (callback, options = {}) => {
return db.transaction((tx) => {
return new Promise((resolve, reject) => {
// 如果发出中止事件,回滚此事务
options.signal?.addEventListener('abort', async () => {
reject(new TransactionRollbackError())
})
return Promise.resolve(callback.call(this, tx)).then(resolve, reject)
})
})
}
}
makeCancelableTransaction() 函数接受一个数据库实例并返回一个高阶事务函数,该函数现在接受一个中止 signal 作为参数。为了知道何时发生中止,我在 signal 实例上添加了"abort"事件的事件监听器。每当发出中止事件时(即调用 controller.abort() 时),该事件监听器就会被调用。因此,当发生这种情况时,我可以用 TransactionRollbackError 错误拒绝事务 promise 来回滚整个事务(这等同于调用 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 }
)
我用 db 实例调用 makeCancelableTransaction() 工具函数来创建一个自定义的可中止 transaction。从这一点开始,我可以像在 Drizzle 中通常那样使用我的自定义 transaction,执行多个数据库操作,但我也可以为它提供一个中止 signal 来一次性取消所有操作。
中止错误处理
每个中止事件都伴随着中止的原因。这带来了更多的可定制性,因为你可以对不同的中止原因做出不同的反应。中止原因是 controller.abort() 方法的可选参数。你可以在任何 AbortSignal 实例的 reason 属性中访问中止原因。
const controller = new AbortController()
controller.signal.addEventListener('abort', () => {
console.log(controller.signal.reason) // "用户取消"
})
// 为这次中止提供自定义原因
controller.abort('用户取消')
reason 参数可以是任何 JavaScript 值,所以你可以传递字符串、错误或者甚至对象。
结论
如果你正在创建 JavaScript 库,其中中止或取消操作是有意义的,我强烈建议你不要错过 AbortController API。它太棒了!如果你正在构建应用程序,当你需要取消请求、移除事件监听器、中止流,或者教会任何逻辑变得可中止时,你都可以很好地利用中止控制器。