AbortController API: 中断 Anything?

1 views

AbortController API: 中断 Anything?
Loading...

前言

最近面试中有一个问题出现的频率相对高一些,就是 如果有并发请求的时候,如果中断之前的请求?

讨论

这个问题的出发点:

  • 同一个异步请求,如何确保请求的顺序,如果后一个先返回,前一个再返回时,还需要覆盖之前的数据吗。
  • 多个异步请求,如果有新的请求发出是,之前没有响应的请求是否需要中断。

当然这个问题不单单是指请求,通过 Promise 封装的异步方法都在讨论的范围中。

目前比较好的一种实现方案是基于 AbortController Api 来实现事件的中断。

1. 什么是 AbortController?

以下是 MDN 关于 的描述:

AbortController 接口表示一个控制器对象,允许你根据需要中止一个或多个 Web 请求 或者事件。

你可以使用 AbortController() 构造函数创建一个新的 AbortController 对象。使用 AbortSignal 对象可以完成与异步操作的通信。

可以看到 AbortControllerJavaScript 中用于中断任何事的全局类。使用方法如下:

const controller = new AbortController();
controller.signal;
controller.abort();

一旦创建一个 AbortController 实例,就会得到两件事:

  1. signal 属性,它是 的一个实例。它可以填充到提供响应中断事件的 API 中,然后执行中断。举例来说,填充到 fetch 请求中可以中断请求;
  2. .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()

// 1
function listener() {}
window.addEventListener('resize', listener);
window.removeEventListener('resize', listener);
// 2
const 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

可以使用 AbortControllerAbortSignal 中止流。

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,你可以使用 AbortControllersignal 属性来中断请求,这样可以让你的应用更加简单,更加可控。

可以参考

6.总结

如果你正在创建 JavaScript 库,其中中止或取消操作是合理的,我强烈建议你使用 AbortController API。它非常出色!如果你在构建应用程序,当需要取消请求、移除事件监听器、中止流,或使任何逻辑变得可中止时,你都可以有效地利用中止控制器。

7.参考文章: