你的应用需要一个 “可取消的异步 HTTP 请求模块”

程序员成长指北

共 19654字,需浏览 40分钟

 ·

2021-03-14 17:37

作者:李永宁

原文地址:https://juejin.cn/post/6935238528510984205

如何取消一个异步 HTTP 请求?

异步 HTTP 请求在现代 web 应用中可以说是随处可见。为了更好的用户体验,05 年出现了 Ajax,支持不刷新页面实现局部更新。

Ajax 支持同步和异步两种方式,但是大家基本上只用异步方法,因为发送同步请求会让浏览器进入暂时性的假死状态,特别是请求需要处理大数据量、长时间等待的接口,这种情况下采用同步请求,会带来非常不好的用户体验。所以大家普遍都采用异步请求的方式,于是就有了今天的话题,你可能需要一个可取消的异步 HTTP 请求。大家可以思考以下几个问题:

  • 为什么需要可取消的异步 HTTP 请求?

我们经常会遇到发送了某个 HTTP 请求,在等待接口响应的过程中突然不需要其结果的情形。

  • 在什么场景下会用到?

比如页面上有多个 Tab 标签,点击每个标签发送对应的 HTTP 请求,然后将请求结果显示在内容区。现在,用户在操作时,点了 Tab1 标签,得到了接口 1 的请求结果,但是在点了 Tab2 后由于接口需要等待 3s 才能返回,用户不想等了,直接点了 Tab3,这时 Tab2 的接口的返回结果就不再需要了。

  • 它有什么用?

这时如果你不取消 Tab2 的请求,内容区就会出现这样的现象:首先显示 Tab3 接口的结果,然后等一下( 0 <= waitTime <= 3 )内容去又变成了 Tab2 的数据,这时就发现,如果不取消 Tab2 发送的异步 HTTP 请求就有问题了,测试就会要你来领 bug。

下面两个动画是对上述过程的一个演示,可以帮助大家更加清晰的理解这个场景。第一个动画是正常操作,在点击 Tab 2 按钮时,等待接口返回后才继续点击 Tab 3。但第二个动画就演示了这个异常现象,点击 Tab 2 后没等接口返回直接点击 Tab 3,发现内容去先显示 Tab 3 接口的内容,然后又变成了 Tab 2 接口的结果。

正常结果:

正常结果

异常结果:

异常结果

这样的需求和场景在现代 web 应用开发中可以说是非常常见了。只是大家平时可能不太会意识到这里会有问题,因为这个 bug 这只存在于需要经过长时间等待才可以得到响应结果的接口(比如 Tab 2),所以如果你的应用存在接口需要处理和传输大量数据或者应用在弱网环境下使用时,就可能会遇到这个问题。所以,一个成熟、稳定的 web 应用必须支持可取消的异步 HTTP 请求。

示例

现代 web 应用开发中,通用的做法是封装一个公共的 HTTP 请求模块,该模块一般是基于第三方开源库(比如 Axios)或者原生方法(比如 Fetch API、XMLHttpRequest)。

接下来我们就通过一个示例来模拟真实的项目场景,其实上述动画来源于真实的项目开发,实际案例不方便提供,所以通过示例来模拟。案例中的 HTTP 请求模块(request)分别通过 Axios、Fetch API、XMLHttpRequest 各实现了一遍。

服务端

这里通过 express 框架来实现服务端,使用 node server.jsnodemon server.js 来启动

const app = require('express')()

const cors = require('cors')
app.use(cors())

app.get('/tab1', (req, res) => {
  res.json('Tab 1 的结果')
})

app.get('/tab2', (req, res) => {
  // 这里通过延时代码来模拟处理大数据量的场景
  setTimeout(() => {
    res.json('Tab 2 的结果')
  }, 3000)
})

app.get('/tab3', (req, res) => {
  res.json('Tab 3 的结果')
})

app.listen(3000, () => {
  console.info('app start at 3000 port')
})

前端

index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #content {
      display: flex;
      justify-content: center;
      align-items: center;
      width200px;
      height100px;
      border1px solid #eee;
    }
  
</style>
</head>

<body>
  <!-- 内容显示区 -->
  <h3>内容区</h3>
  <p id="content">no content</p>

  <!-- 通过三个按钮来模拟三个 Tab 标签 -->
  <button id="tab1">Tab 1</button>
  <button id="tab2">Tab 2</button>
  <button id="tab3">Tab 3</button>

  <button id="reset">reset</button>

  <!-- axios -->
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
  <!-- 基于 axios 封装的 request 接口 -->
  <script src="./axiosRequest.js"></script>
  <!-- 基于 fetch 封装的 request 接口 -->
  <!-- <script src="./fetchRequest.js"></script> -->
  <!-- 基于 XMLHttpRequest 封装的 request 接口 -->
  <!-- <script src="./xhrRequest.js"></script> -->

  <script>
    // 分别给三个按钮添加点击事件,当发生点击时执行相应的回调函数请求对应的接口,请求成功后将结果显示到内容区
    const tab1 = document.querySelector('#tab1')
    const tab2 = document.querySelector('#tab2')
    const tab3 = document.querySelector('#tab3')

    // 内容显示区
    const content = document.querySelector('#content')

    // reset
    const reset = document.querySelector('#reset')

    tab1.addEventListener('click'async function ({
      const { data } = await request({ url'/tab1' })
      content.textContent = data
    })

    tab2.addEventListener('click'async function ({
      const { data } = await request({ url'/tab2' }, true)
      content.textContent = data
    })

    tab3.addEventListener('click'async function ({
      const { data } = await request({ url'/tab3' })
      content.textContent = data
    })

    reset.addEventListener('click'function ({
      content.textContent = 'no content'
    })

  
</script>
</body>

</html>

axiosRequest.js

基于 Axios 封装的 request 接口,可以根据自己的业务需要去扩展,一般是在两个拦截的地方去做一些扩展

const baseURL = 'http://localhost:3000'

const ins = axios.create({
  baseURL,
  timeout10000
})

ins.interceptors.request.use(config => {
  // 拦截请求,可以在这里自定义一些配置,比如 token
  return config
})

ins.interceptors.response.use(response => {
  // 拦截响应,可以根据服务端返回的状态码做一些自定义的响应和信息提示
  return response
})

function request(reqArgs{
  return ins.request(reqArgs)
}

fetchRequest.js

基于 fetch API 封装的 request 接口,封装简单,只为说明问题;返回的数据格式兼容基于 axiosRequest 的示例代码

const baseURL = 'http://localhost:3000'

function request(reqArgs{
  // 接口返回的数据格式是为了兼容 axios 的示例代码
  return fetch(baseURL + reqArgs.url).then(async response => ({ dataawait response.json() }))
}

xhrRequest.js

基于 XMLHttpRequest API 封装的 request 接口,封装简单,只为说明问题;返回的数据格式兼容基于 axiosRequest 的示例代码

const baseURL = 'http://localhost:3000'

const xhr = new XMLHttpRequest()
function request(reqArgs{
  return new Promise((resolve, reject) => {
    xhr.onload = function ({
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        // 接口返回的数据格式是为了兼容 axios 的示例代码
        resolve({ data: xhr.responseText })
      } else {
        // 出错了
        reject(xhr.status)
      }
    }
    xhr.open(reqArgs.method || 'get', baseURL + reqArgs.url, true)
    xhr.send(reqArgs.data || null)
  })
}

解决方案

这里演示如何改造一个已有应用,以达到最小改动实现安全无缝升级的目的。而对于一个新起的项目,只需在做架构的时候将以下解决方案集成进去即可。

解决方案可以分为两类:

  • 原生方法

Axios、Fetch API、XMLHttpRequest 原生都提供了取消异步 HTTP 请求的能力,只是有的可能没那么好用,比如 Fetch API。

  • 通用方法

我更推荐通用方法,简单易懂,不需要记各种各样的原生方法。

可取消的 Promise

在进入正式的改造之前,先给大家普及一个知识,如何取消一个 Promise?

大家都知道,Promise 的逻辑一旦开始执行,就无法被停止,除非它执行完成。所以我们经常会遇到异步逻辑正常处理过程中,程序却不再需要其结果的情形,这点和我们的案例很像。这时候如果能够取消 Promise 就好了,一些第三方库,比如 Axios,就提供了这个特性。实际上,TC39 委员会也曾准备增加这个特性,但相关提案最终被撤回了。结果 ES6 的 Promise 被认为是 “激进的”。

实际上,我们可以通过 Promise 的特性来提供一种临时性的封装,以实现类似取消 Promise 的功能(但知识类似)。我们都知道 Promise 的状态一旦落定(从 pending 变为 fulfilled 或 rejected)就不可再次改变。

const p = new Promise((resolve, reject) => {
  resolve('result message')
  // 这个 resolve 会被忽略
  resolve('我被忽略了。。。')
  console.log('I am running !!')
})

// I am running!!

// Promise {<fulfilled>: "result message"}
console.log(p)

我们可以利用这个特性来实现一个可取消的 Promise。可以向外暴露一个取消函数,需要取消 Promise 时就调用该函数,函数被调用时会执行 Promise 的 resovle 或 reject 方法,这样接口得到响应时再执行 resolve 或 reject 就会被忽略。通过这样的方式来实现类似取消 Promise 的功能。


<!-- 可取消的 Promise -->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #result {
      display: flex;
      justify-content: center;
      align-items: center;
      width200px;
      height100px;
      border1px solid #eee;
    }
  
</style>
</head>

<body>
  <!-- 显示请求结果 -->
  <h3>请求结果</h3>
  <p id="result">no result</p>

  <!-- 三个按钮:请求按钮、取消请求的按钮、复位按钮 -->
  <button id="req1">req 1</button>
  <button id="cancel">cancel</button>
  <button id="reset">reset</button>

  <script>
    // 暴露取消 Promise 的接口
    let cancelReq = null
    // 暴露 request 接口
    function request(reqArgs{
      return new Promise((resolve, reject) => {
        // 通过延时代码来模拟异步 http 请求
        setTimeout(() => {
          resolve('result message')
        }, 2000);

        // 给用户提供一个取消请求的函数
        cancelReq = function ({
          resolve('请求被取消了')
          cancelReq = null
        }
      })
    }
  
</script>

  <script>
    // 三个按钮
    const req1 = document.querySelector('#req1')
    const cancel = document.querySelector('#cancel')
    const reset = document.querySelector('#reset')
    // 结果显示区
    const result = document.querySelector('#result')

    // 给三个按钮添加 click 事件
    req1.addEventListener('click'async function ({
      const ret = await request('/req')
      result.textContent = ret
    })
    cancel.addEventListener('click'function ({
      cancelReq()
    })
    reset.addEventListener('click'function({
      result.textContent = 'no result'
    })
  
</script>
</body>

</html>

有了以上基础,接下来我们就可以开始改造我们的案例代码了。

效果

先上效果,可以看到,升级以后,之前的问题就不存在了。

index.html

// 在需要取消 http 请求的地方添加取消函数调用
tab3.addEventListener('click'async function ({
  // 取消上一个请求
  cancelFn('Tab 2 的接口请求被取消了')

  const { data } = await request({ url'/tab3' })
  content.textContent = data
})

axiosRequest.js

其实 axios 的原生解决方案和通用解决方案是一致的,都是利用 Promise 落定以后状态不可变的特性实现的。

原生方案 Axios 官网:

const baseURL = 'http://localhost:3000'
const CancelToken = axios.CancelToken

const ins = axios.create({
  baseURL,
  timeout10000
})

ins.interceptors.request.use(config => {
  // 拦截请求,可以在这里自定义一些配置,比如 token
  return config
})

ins.interceptors.response.use(response => {
  // 拦截响应,可以根据服务端返回的状态码做一些自定义的响应和信息提示
  return response
})

// 初始化为一个函数,防止报错
let cancelFn = function ({}

function request(reqArgs{
  // 在传递的参数中设置一个 cancelToken 实例
  reqArgs.cancelToken = new CancelToken(function (cancel{
    // 向外暴露取消函数
    cancelFn = cancel
  })
  return ins.request(reqArgs)
}

通用方案

const baseURL = 'http://localhost:3000'
const CancelToken = axios.CancelToken

const ins = axios.create({
  baseURL,
  timeout10000
})

ins.interceptors.request.use(config => {
  // 拦截请求,可以在这里自定义一些配置,比如 token
  return config
})

ins.interceptors.response.use(response => {
  // 拦截响应,可以根据服务端返回的状态码做一些自定义的响应和信息提示
  return response
})

// 初始化为一个函数,防止报错
let cancelFn = function ({}

function request(reqArgs{
  return new Promise((resolve, reject) => {
    // 请求接口
    ins.request(reqArgs).then(res => resolve(res))

    // 向外暴露取消函数
    cancelFn = function (msg{
      reject({ message: msg })
    }
  })
}

fetchRequest.js

Fetch API 支持通过 AbortController/AbortSignal 中断请求,也可使用通用解决方案。其实通用解决方案更好,因为被中断的 Fetch 会被打上一个标记,将变得不可用,除非刷新页面。其实 fetch 的原生方案解决不了我们案例中的问题。它虽然中断了请求,可也阻止了后续请求的发送。

原生方案

执行以后会在控制台看到以下信息:

第一个表示用户终止了 fetch 请求,也就是 cancelFn 函数调用导致产生的提示信息。而第二个错误提示是因为我们在中断 fetch 请求以后重新发了另外一个 fetch 请求(点击了 tab 3 按钮)导致的报错,告诉你当前 window 对象上的 fetch 已经被用户终止了,所以你需要刷新页面,重新初始化这些全局对象(window.fetch)

// 通过 AbortController/AbortSignal 中断请求
const abortController = new AbortController()

// 向外暴露取消函数
function cancelFn({
  // 中断所有网络传输,特别适合希望停止传输大型负载的情况
  abortController.abort()
}

const baseURL = 'http://localhost:3000'

function request(reqArgs{
  // 接口返回的数据格式是为了兼容 axios 的示例代码
  return fetch(baseURL + reqArgs.url, { signal: abortController.signal }).then(async response => ({ dataawait response.json() }))
}

通用方案

// 初始化为一个函数,防止报错
let cancelFn = function ({}

const baseURL = 'http://localhost:3000'

function request(reqArgs{
  return new Promise((resolve, reject) => {
    // 接口返回的数据格式是为了兼容 axios 的示例代码
    fetch(baseURL + reqArgs.url).then(async response => resolve({ dataawait response.json() }))

    // 向外暴露取消函数
    cancelFn = function(msg{
      reject({ message: msg })
    }
  })
}

xhrRequest.js

原生方案

const baseURL = 'http://localhost:3000'

const xhr = new XMLHttpRequest()
function request(reqArgs{
  return new Promise((resolve, reject) => {
    xhr.onload = function ({
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        // 接口返回的数据格式是为了兼容 axios 的示例代码
        resolve({ data: xhr.responseText })
      } else {
        // 出错了
        reject(xhr.status)
      }
    }
    xhr.open(reqArgs.method || 'get', baseURL + reqArgs.url, true)
    xhr.send(reqArgs.data || null)
  })
}

// 向外暴露取消函数
function cancelFn({
  // xhr 原生提供了 abort 方法
  xhr.abort()
}

通用方案

const baseURL = 'http://localhost:3000'

const xhr = new XMLHttpRequest()

// 初始化取消函数,防止调用报错
let cancelFn = function({}

function request(reqArgs{
  return new Promise((resolve, reject) => {
    xhr.onload = function ({
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        // 接口返回的数据格式是为了兼容 axios 的示例代码
        resolve({ data: xhr.responseText })
      } else {
        // 出错了
        reject(xhr.status)
      }
    }
    xhr.open(reqArgs.method || 'get', baseURL + reqArgs.url, true)
    xhr.send(reqArgs.data || null)

    // 向外暴露取消函数
    cancelFn = function (msg{
      reject({ message: msg })
    }
  })
}

总结

这就是所有终止异步 HTTP 请求的方案,可以总结为两类:

  • 原生方案
  • 基于 Promise 进行二次封装的通用方案

大家可根据自己的需要进行选择。

链接

  • 视频讲解地址:https://www.bilibili.com/video/BV1D54y1h7md/

  • github地址:https://github.com/liyongning/cancel-async-http-request.git

❤️爱心三连击

1.看到这里了就点个在看支持下吧,你的点赞在看是我创作的动力。

2.关注公众号程序员成长指北,回复「1」加入高级前端交流群!「在这里有好多 前端 开发者,会讨论 前端 Node 知识,互相学习」!

3.也可添加微信【ikoala520】,一起成长。

“在看转发”是最大的支持

浏览 20
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报