由axios如何cancel请求想到的如何防止多次重复请求数据覆盖问题

4,195 阅读8分钟

前两天在交流群里的老哥们在聊起取消请求的操作时有了个想法,就去axios文档里面看了一下,官方是有方法提供的,点这里直达axios取消操作,官方提供两种取消请求的方案,我使用的时第二种方案,开搞。

开发环境

vue全家桶

要解决的问题

在项目开发中,相信大家都遇到过下面这些情况吧:

列表展示页面上有多个筛选项,当用户操作太过频繁的时候,在第一个筛选项的筛选结果还未返回的时候就进行了第二个筛选项的筛选操作,如果这时候第一个请求的耗时很长,长到第二个请求的返回值已返回的情况下第一个请求还没返回,那么就会出现,最终渲染出来的列表是第一次筛选后的结果而不是我们想要的第二次筛选的结果。

还有另一种情况会出现在创建的时候,当我们表单项填完之后,点击提交按钮进行创建的时候,有的用户会习惯多点几下,如果这时候前端没有做控制的话,就会发送两个创建请求到服务端,如果服务端端做唯一性限制了还好,最多一个创建请求成功,另一个请求报创建失败错误,而如果服务端没有做唯一性限制的话,那你会发现你创建出来一大堆一样的数据,这就是个大问题了,当然服务端必须做唯一性控制是一方面,另一方面前端要考虑在交互上的友好性,同时不能完全“信赖”服务端开发者,解决这个问题其实也好解决,就是在点击提交按钮之后就在页面上添加一个loading遮罩或者将按钮disable掉,等请求成功或者失败返回之后再关闭遮罩或重启按钮,但是这个方案的复用性不是很好,因为要对哪个模块添加遮罩,要对哪个请求进行监听,在什么时候开启关闭等等问题都导致想要抽离复用这个方案变得不是那么容易。

思路及要达成的效果

既然请求是可以取消的,那么可以维持一个pending状态的请求队列,然后在axios的请求拦截器上面添加一层判断,判断当前处于pending中的请求是否含有与当前要发起的请求相同的,如果有,就把处于pending中的请求cancel掉,然后将当前的请求放入pending请求队列中,并发起当前请求,然后在请求成功或者失败的拦截器中将请求队列中对应的请求删除掉,如果没有在队列中,那么依然是将当前的请求放入pending请求队列中,并发起当前请求,然后在请求成功或者失败的拦截器中将请求队列中对应的请求删除掉。

这样,在项目中如果遇到上面的情况就可以完美避开,在第一种情况,通过这个拦截控制,第一次发起的携带一个筛选项的请求会被cancel掉,而第二个携带两个或多个筛选项的我们真实想要的请求依然会正常请求、返回、渲染,不会存在渲染出旧数据的现象;而第二种情况不论你点多少次,只要上一个请求处于pending中那么就会被cancel掉,继而重新发起请求,这样服务端接收到并返回的就永远只有一个请求了,是不是感觉跟防抖的效果很像?

代码实现

首先声明一个map对象,用来储存处于pending状态的请求以及取消该请求的方法,我这边使用的是请求类型加上请求地址作为key值,取消请求的方法作为value值,别问我为什么要加个请求类型,因为遇到过请求地址一样,类型不一样的接口0.0

以及定义CancelToken构造函数

import axios from 'axios'

let pendingQueue = new Map()
let CancelToken = axios.CancelToken

定义判断请求是否在队列中,如果在就执行取消请求的judgePendingFunc方法,以及将已执行过的请求从队列中删除的removeResolvedFunc方法,这两个方法接收的参数都是axios的config

// 判断请求是否在队列中,如果在就对队列中的该请求执行取消操作
const judgePendingFunc = function(config) {
  if (pendingQueue.has(`${config.method}->${config.url}`)) {
    pendingQueue.get(`${config.method}->${config.url}`)() //注意这里的括号不要漏掉
  }
}
// 删除队列中对应已执行的请求
const removeResolvedFunc = function(config) {
  if (pendingQueue.has(`${config.method}->${config.url}`)) {
    pendingQueue.delete(`${config.method}->${config.url}`)
  }
}

在request拦截器中添加如下代码

// 请求发起之前先调用removeResolvedFunc方法
judgePendingFunc(config)
// 将pending队列中的请求设置为当前
config.cancelToken = new CancelToken(cb => {
  // cb就是取消该请求的方法,调用它就能cancel掉当前请求
  pendingQueue.set(`${config.method}->${config.url}`, cb)
})

在response拦截器中添加

// 调用removeResolvedFunc在队列中删除执行过的请求
removeResolvedFunc(response.config)

这样对重复请求的处理就完成了,因为是在拦截器中添加的,所以就不需要再另外做什么配置了,直接覆盖所有请求。

最后贴上我项目中使用的axios封装的完整代码吧

import axios from 'axios'

// 设置baseURL,判断当前环境是否为生产环境,若不是需设置自己的apiURL
let baseURL = process.env.NODE_ENV !== 'production' ? '/' : '/'
let config = {
  baseURL,
  timeout: 60 * 1000 // 请求超时时间
}

const _axios = axios.create(config)

let pendingQueue = new Map()
let CancelToken = axios.CancelToken
// http request 拦截器
_axios.interceptors.request.use(
  function(config) {
    // Do something before request is sent
    // 请求发起之前先检验该请求是否在队列中,如果在就把队列中pending的请求cancel掉
    judgePendingFunc(config)
    // 将pending队列中的请求设置为当前
    config.cancelToken = new CancelToken(cb => {
      pendingQueue.set(`${config.method}->${config.url}`, cb)
    })
    if (config.method === 'get') {
      let res = handleGetUrl(config.url, config.params)
      config.url = res.url
      config.params = res.params
    }
    return config
  },
  function(error) {
    // Do something with request error
    console.log(error)
    return Promise.reject(error)
  }
)

// http response 拦截器
_axios.interceptors.response.use(
  function(response) {
    // Do something with response data
    removeResolvedFunc(response.config)
    console.log(response)
    return response.data
  },
  function(error) {
    // Do something with response error
    console.log(error)
    return Promise.reject(error)
  }
)

// 二次封装方法
/**
 * 接收三个参数,配置参数config可不传
 * @param {String} url
 * @param {Object} data
 * @param {Object} config
 */
const getFn = async (url, data, config = {}) => {
  let params = { params: data, ...config }
  try {
    return _axios.get(url, params)
  } catch (error) {
    return handleError(error)
  }
}
/**
 * 接收三个参数,配置参数config可不传
 * @param {String} url
 * @param {Object} data
 * @param {Object} config
 */
const postFn = async (url, data, config = {}) => {
  try {
    return _axios.post(url, data, config)
  } catch (error) {
    return handleError(error)
  }
}
const deleteFn = async (url, data) => {
  try {
    return _axios.delete(url, data)
  } catch (error) {
    return handleError(error)
  }
}
// 捕获请求错误
function handleError(error) {
  Promise.reject(error)
}
// 判断请求是否在队列中,如果在就执行取消请求
const judgePendingFunc = function(config) {
  if (pendingQueue.has(`${config.method}->${config.url}`)) {
    pendingQueue.get(`${config.method}->${config.url}`)()
  }
}
// 删除队列中对应已执行的请求
const removeResolvedFunc = function(config) {
  if (pendingQueue.has(`${config.method}->${config.url}`)) {
    pendingQueue.delete(`${config.method}->${config.url}`)
  }
}
// 处理get请求功能性字符和非功能性字符被转换导致的问题
const handleGetUrl = function(url, params) {
  let parts = []
  let resUrl = url
  let resParams = params
  let keys = Object.keys(params)
  for (let key of keys) {
    let values = []
    if (Array.isArray(params[key])) {
      values = params[key]
      key += '[]'
    } else values = [params[key]]
    values.forEach(val => {
      if (val || val === 0)
        parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(val)}`)
    })
  }
  let serializedParams = parts.join('&')
  if (serializedParams) {
    resUrl += (resUrl.includes('?') ? '&' : '?') + serializedParams
  }
  return { url: resUrl, params: resParams }
}

export default {
  get: getFn,
  post: postFn,
  delete: deleteFn
}

使用的话也非常简单

import axios from '@/plugins/axios'

/**
 * 接收请求所需要配置的参数
 * @param { Object } queryParams
 */
export const testGet1 = queryParams => {
  return axios.get('/path/path', queryParams)
}
export const testGet2 = queryParams => {
  // 配置特殊情况修改接收返回值类型为blob(多用于下载文件)
  return axios.get('/path/path', queryParams, { responseType: 'blob' })
}
export const testPost1 = queryParams => {
  return axios.post('/path/path', queryParams)
}
export const testPost2 = queryParams => {
  // 配置特殊情况延长超时时间(多用于上传大文件)
  return axios.post('/path/path', queryParams, { timeout: 2 * 60 * 60 * 1000 })
}

最终效果是这样的

有朋友会注意到我的request请求拦截器中对get请求做了额外的处理,这个其实是由于axios的get请求使用的是encoURI编码方式,当get请求参数中带有数组的时候会有符号“[]”,另外get请求用来连接请求参数的符号“&”与“?”,在编码请求地址时同时存在功能性字符和非功能性字符会出现服务端接收到的请求无法解析请求参数的问题,所以就重写了get请求对请求参数处理的编码方式,需要的朋友可以直接拷贝走使用。

最后

考虑扩展性可以在请求中约定一个参数来控制这个请求是否要进行重复判断,然后在拦截器中根据这个参数来进行判断控制

另外该方案配合按钮的防抖使用更佳,至于封装自己的按钮,并如何在按钮上加防抖控制可以上网搜一下,或者后面有时间我写一下

给窗口添加loading这个方案在交互性上其实更好,但是奈何自己暂时没有想到好的封装方案,只能先搁置了,不过说起loading,给大家推荐个地址,里面有19种纯css实现的loading效果,实现方法挺有意思的,有兴趣的可以看一下,封装进自己的样式库里面。

好了就酱,掰掰0.0


重新编辑:针对本文第二种问题的使用cancel的解决方案并不是很合适,我在本文中已对相应内容做删除线处理,我在巧妙封装全局ElementUI的loading防止请求连续重复提交一文中提供可新的解决方案,大家有兴趣可以移步过去看一下