异步中断-AbortController

Published on

MDN 文档:

AbortController: https://developer.mozilla.org/zh-CN/docs/Web/API/AbortController

AbortSignal: https://developer.mozilla.org/zh-CN/docs/Web/API/AbortSignal

弃用 fetch 请求

构造函数获取 AbortController 实例对象 controller,获取 controller.signal 传入 fetch 参数,作为 signal。调用 controller.abort() 方法可以弃用(cancel) fetch 请求。效果和 ajax 中 xhr.abort() 一样。

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

fetch('url',{signal});

// cancel fetch
controller.abort();

demo-从服务和客户端看 abort

从服务端和客户端两边看 abort 的表现。

  1. 建立服务。通过 setTimeout添加延时让响应不那么快,留出时间 abort。在处理请求后返回前打出日志[request /profile]

    const http = require('http')
    
    const hostname = '127.0.0.1'
    const port = 3000
    
    const server = http.createServer((req, res) => {
      if (req.method === 'GET' && req.url === '/profile') {
        res.statusCode = 200
        res.setHeader('Content-Type', 'application/json')
        res.setHeader('Access-Control-Allow-Origin', '*')
        setTimeout(() => {
          console.info('[request /profile]')
          res.end(
            JSON.stringify({
              name: 'john',
              age: 30,
              email: 'john.doe@example.com',
            })
          )
        }, 2000)
      } else {
        res.statusCode = 404
        res.setHeader('Content-Type', 'text/plain')
        res.end('404 Not Found')
      }
    })
    
    server.listen(port, hostname, () => {
      console.log(`Server running at http://${hostname}:${port}/`)
    })
    
  2. 建立客户端页面, 用于发起请求。通过较长时间间隔(大于 2S)和较短时间间隔(小于 2S)点击去看多次请求的结果。

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
      </head>
    
      <body>
        <div>
          <button onclick="refetchProfile()">get profile</button>
        </div>
      </body>
      <script>
        let controller = new AbortController()
        async function refetchProfile() {
          if (controller) {
            controller.abort('abort!')
          }
          controller = new AbortController()
          console.info('signal', controller.signal)
          fetch('http://127.0.0.1:3000/profile', { signal: controller.signal })
            .then((res) => {
              console.info('[res]', res)
            })
            .catch((err) => {
              console.warn('[err]', err)
            })
        }
      </script>
    </html>
    
  3. 打开客户端页面操作。

    • 4 次快速连续点击(间隔小于 2 秒)。页面上的请求,前三次状态均为 canceled ,第四次等待 2s 后成功返回数据。

      服务端是否收到了这些请求?

      请求并没有真正被终止,服务端收到了全部请求。controller.abort() 所做的可能表达为【客户端弃用请求】更合理。客户端 fetch 被 cancel ,会导致 fetch promise 抛出错误 [err] abort!

    • 点击一次之后过 5 秒再点击, 上一次 fetch 已经完成,再去 abort 是没有效果的。

AbortSignal 对象

controller 的只读属性 signal 获取的是一个的 AbortSignal对象实例。上面就是将signal: controller.signal 参数传入 fetch 后用来控制 abort fetch 请求。 AbortSignal 对象具有静态方法:

  • abort 获取一个已终止的 AbortSignal 实例。
  • timeout 返回一个指定时间后自动终止的 AbortSignal 实例。

设定超时

MDN 的例子,将一个 5S 后超时的 AbortSignal 实例传入 fetch 的 signal 参数,用来控制 fetch 请求超时时间 5S,通过 catch 判断错误类型(是超时还是其他)进一步处理(出现超时时 fetch promise 抛出 “TimeoutError” 的错误,以此区分)

try {
  const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
  const result = await res.blob()
} catch (e) {
  if (err.name === 'TimeoutError') {
    console.error('Timeout: It took more than 5 seconds to get the result!')
  } else if (err.name === 'AbortError') {
    console.error('Fetch aborted by user action (browser stop button, closing tab, etc.')
  } else if (err.name === 'TypeError') {
    console.error('AbortSignal.timeout() method is not supported')
  } else {
    // A network error, or some other problem.
    console.error(`Error: type: ${err.name}, message: ${err.message}`)
  }
}

手动"终止"Promise

AbortSignal对象实例还具有事件, abort。 通过 addEventListener 添加事件处理,于是可以手动 DIY 出来一个可终止的 Promise。

如下 demo: customPromise 方法是一个耗时 3S 的操作,传入 signal 后添加事件监听。在外部需要终止的时候触发 controller.abort, Promse 内部监听后直接 reject 改变 Promise 状态。

function customPromise(signal) {
  return new Promise((resolve, reject) => {
    signal.addEventListener('abort', () => {
      reject(new Error('操作已被中止'))
    })

    // 模拟异步操作
    setTimeout(() => {
      console.info('finish')
      resolve('操作成功')
    }, 3000)
  })
}

const controller = new AbortController()
const promise = customPromise(controller.signal)

// 在 1 秒后中止操作
setTimeout(() => {
  controller.abort()
}, 1000)

promise.then((result) => console.log(result)).catch((error) => console.error(error.message))

执行后输出:

操作已被中止
[finish]

Pormise 执行,耗时操作期间手动触发 abort 后 reject 调用,模拟异常终止。