AbortController API: 中断 Anything?
1 views
前言
最近面试中有一个问题出现的频率相对高一些,就是 如果有并发请求的时候,如果中断之前的请求?
讨论
这个问题的出发点:
- 同一个异步请求,如何确保请求的顺序,如果后一个先返回,前一个再返回时,还需要覆盖之前的数据吗。
- 多个异步请求,如果有新的请求发出是,之前没有响应的请求是否需要中断。
当然这个问题不单单是指请求,通过 Promise 封装的异步方法都在讨论的范围中。
目前比较好的一种实现方案是基于 AbortController Api 来实现事件的中断。
1. 什么是 AbortController?
以下是 MDN 关于
AbortController 接口表示一个控制器对象,允许你根据需要中止一个或多个 Web 请求 或者事件。
你可以使用 AbortController() 构造函数创建一个新的 AbortController 对象。使用 AbortSignal 对象可以完成与异步操作的通信。
可以看到 AbortController 是 JavaScript 中用于中断任何事的全局类。使用方法如下:
const controller = new AbortController();
controller.signal;controller.abort();一旦创建一个 AbortController 实例,就会得到两件事:
signal属性,它是AbortSignal 的一个实例。它可以填充到提供响应中断事件的 API 中,然后执行中断。举例来说,填充到 fetch 请求中可以中断请求;.abort()方法,该方法一旦执行,就会触发signal上的中断事件。
到目前为止一切顺利。但实际的中止逻辑在哪里呢?这正是它的美妙之处 —— 由使用者定义。中止处理的核心在于监听中止事件,并以适合当前逻辑的方式实现中止:
controller.signal.addEventListener('abort', () => { // Implement the abort logic.});下面我们探究在 JavaScript 标准中提供的 AbortSignal 。
2. 使用
2.1 事件监听
可以在事件监听中添加中断属性 signal ,当中断发生时它会自动移除事件监听。
const controller = new AbortController();
window.addEventListener('resize', listener, { signal: controller.signal });
controller.abort();执行 controller.abort() 会移除 window 上的 resize 事件监听。这是非常优雅的卸载事件监听方式,因为不再只能使用固定的卸载事件监听函数 .removeEventListener() 。
// 1function listener() {}window.addEventListener('resize', listener);window.removeEventListener('resize', listener);
// 2const controller = new AbortController();window.addEventListener('resize', () => {}, { signal: controller.signal });controller.abort();如果应用程序的不同部分负责移除监听器,将 AbortController 实例传递下去会更加方便。
对我来说,一个很棒的“顿悟”时刻是,意识到你可以使用单个 signal 来移除多个事件监听器!
function eventListener() { 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() hook 添加一串不同逻辑的事件监听。注意在 clean up 函数中,我只需要执行一次 controller.abort() 就移除了所有的事件监听。这个比使用 removeEventListener() 依次来移除更加优雅,因为不再需要在每个事件监听器中都执行 removeEventListener()。
2.2 fetch 请求
fetch() 函数同样支持 AbortSignal 。一旦触发 abort 事件信号, fetch() 函数代表的请求 promise 的状态将会变为 reject ,中断请求的继续。
function uploadFile(file) { const controller = new AbortController();
// Provide the abort signal to this fetch request // so it can be aborted anytime be calling `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 中的请求处理。
2.2.1 AbortSignal.timeout
你可以使用 AbortSignal.timeout() 静态方法作为简写,创建一个在特定超时时间后触发中止事件的信号。如果你只想在请求超时后取消请求,就无需创建 AbortController :
fetch(url, { // 如果这个请求超过三秒将会自动取消 signal: AbortSignal.timeout(3000)});2.2.2 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() 的任何中止信号触发了中止事件,那么父信号也将触发中止事件。在此之后的任何其他中止事件将被忽略。
2.3 流 streams
可以使用 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 订阅的中断事件。
3. 封装
AbortController API 的部分是它非常灵活。如此灵活,以至于你可以让任何逻辑变得可中止!
拥有这样的超能力,不仅可以自己提供更好的体验,还可以增强你使用那些不支持中止/取消的第三方库的方式。事实上,我们就来做这个。
让我们将 AbortController 添加到
import { TransactionRollbackError } from 'drizzle-orm';
function makeCancelableTransaction(db) { return (callback, options = {}) => { return db.transaction((tx) => { return new Promise((resolve, reject) => { // Rollback this transaction if the abort event is dispatched. 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 错误来拒绝事务承诺,以回滚整个事务(这相当于调用 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 。从此以后,我可以像在 Drizzle 中一样正常使用我的自定义事务 transaction ,执行多个数据库操作,同时也可以提供一个中止 signal ,以便一次性取消所有操作。
4 终止错误行为
每个中止事件都伴随着中止的原因。这为你提供了更多的自定义可能性,因为你可以对不同的中止原因做出不同的反应。
中止原因是 controller.abort() 方法的一个可选参数。你可以在任何 AbortSignal 实例的 reason 属性中访问中止原因。
const controller = new AbortController();
controller.signal.addEventListener('abort', () => { console.log(controller.signal.reason); // "user cancellation"});
// 给这个中止提供一个自定义原因controller.abort('user cancellation');
reason参数可以是任何JavaScript值,因此你可以传递字符串、错误甚至对象。
5. TanStack Query
如果你正在使用 TanStack Query,你可以使用 AbortController 的 signal 属性来中断请求,这样可以让你的应用更加简单,更加可控。
可以参考
6.总结
如果你正在创建 JavaScript 库,其中中止或取消操作是合理的,我强烈建议你使用 AbortController API。它非常出色!如果你在构建应用程序,当需要取消请求、移除事件监听器、中止流,或使任何逻辑变得可中止时,你都可以有效地利用中止控制器。
7.参考文章:
AbortController AbortSignal Don’t Sleep on AbortController