•  阅读 4 分钟

尽情使用 AbortController(Don't Sleep on AbortController)

今天,我想讨论一个名叫 AbortController 的 JavaScript 标准 APIs。也许你想要睡觉,还是接着读…

AbortController

AbortController 是 JavaScript 中用于中断任何事的全局类。使用方法如下:

const controller = new AbortController()

controller.signal
controller.abort()

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

  1. signal 属性,它是 AbortSignal 的一个实例。它可以填充到提供响应中断事件的 API 中,然后执行中断。举例来说,填充到 fetch 请求中可以中断请求;
  2. .abort() 方法,该方法一旦执行,就会触发 signal 上的中断事件。

到目前为止一切顺利。但实际的中止逻辑在哪里呢?这正是它的美妙之处 —— 由使用者定义。中止处理的核心在于监听中止事件,并以适合当前逻辑的方式实现中止:

controller.signal.addEventListener('abort', () => {
  // Implement the abort logic.
})

下面我们探究在 JavaScript 标准中提供的 AbortSignal

使用

事件监听

可以在事件监听中添加中断属性 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() 就移除了所有的事件监听。

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 中的请求处理。

AbortSignal.timeout

你可以使用 AbortSignal.timeout() 静态方法作为简写,创建一个在特定超时时间后触发中止事件的信号。如果你只想在请求超时后取消请求,就无需创建 AbortController

fetch(url, {
  // 如果这个请求超过三秒将会自动取消
  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() 的任何中止信号触发了中止事件,那么父信号也将触发中止事件。在此之后的任何其他中止事件将被忽略。

流 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 订阅的中断事件。

使每件事可中断

我最喜欢的关于 AbortController API 的部分是它非常灵活。如此灵活,以至于你可以让任何逻辑变得可中止!

拥有这样的超能力,不仅可以自己提供更好的体验,还可以增强你使用那些不支持中止/取消的第三方库的方式。事实上,我们就来做这个。

让我们将 AbortController 添加到 Drizzle ORM 的事务中,这样我们就能够一次取消多个事务。

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,以便一次性取消所有操作。

中止错误行为

每个中止事件都伴随着中止的原因。这为你提供了更多的自定义可能性,因为你可以对不同的中止原因做出不同的反应。

中止原因是 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 值,因此你可以传递字符串、错误甚至对象。

总结

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

文章由 吳文俊 翻译,原文引用 Don’t Sleep on AbortController

标签:
> cd ..