不可忽视的 AbortController

423 阅读6分钟

今天,我想谈谈 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。它太棒了!如果你正在构建应用程序,当你需要取消请求、移除事件监听器、中止流,或者教会任何逻辑变得可中止时,你都可以很好地利用中止控制器。