Skip to content

一、vue-axios-重复请求的优化

  • 背景

项目上线之后,经常会接受到客户反馈说:点击这个那个提交按钮报错了,排查原因结果发现,在相差毫秒级的时间里,请求提交了两次,导致了异常。

  • 原因

多方面的原因:

  • 我们知道页面按钮被点击是有一定的延迟时间的,只要在这延迟时间内,多次点击就会产生多次请求,比如移动端的300ms延迟。
  • 事件混乱造成的,比如事件叠加
  • 解决方案

思路:

  • 我可不可以把每次请求先存在一个地方,下一次请求到缓存的地方去比较,如果存在相同的请求,不再发送请求。直到本次请求完成之后,再去删除缓存中的请求。

  • 有人会说这么做会不会有性能方面的问题,或者安全方面的问题,至于性能,我是觉得可以忽略不计,现在基本都是CSR模式,客户端渲染页面,对于客户来说,这些微小的差距,基本可以忽略不计。其次是安全,因为是存在变量中,除非内存泄漏,不然也不会出现安全问题

思考:

  • 考虑一个问题:
  • 怎么能表示唯一的请求【带时间戳的请求+参数+方法】

动手:

  • 将唯一的请求拼接成一个字符串作为一个map中的一个key
  • 在请求开始的时候,进行判断,map中是否存在这个key
  • 如果不存在,则存放map
  • 如果存在就直接返回错误消息,告诉用户不可重复点击
  • 请求最后,删除map中的请求。
  • 具体代码示例
    • request
      js
      /**
       * 使用方法:
       * import Request from '@/utils/request';
       *
       *
       * // promise 写法
       * Request.get(url, options).then(() => {
       *
       * });
       *
       * Request.post(url, options) // 与get类似Request.post(url, options) // 与get类似
       *
       * options: {
       *  data: {}, // 请求参数
       *  onlyOnce: true, // 防止单位时间内多次无效请求  *【非axios自带】
       *  loading: true, // 是否需要全局loading(通常用于提交类操作)
       *  form: true,  // 处理是否以form格式发送 *【非axios自带】
       *  errorTips: true, // 是否提示错误信息 *【非axios自带】
       *  timeout: 3000, // 默认为3秒,如果业务特殊需要可调整
       *  headers: {}, // 向header中添加特殊值时使用(通常用不到)
       *  ... // note: 更多options,可以参考axios的config配置,均支持
       * }
       *
       * 取消单一请求:
       * const req = Request.get('/api');
       * Request.cancel(req);
       *
       */
      import axios from 'axios'
      
      import CHANNEL from '@/constants'
      import { stringify } from '../queryString'
      import loading from './loading'
      import Response from './response'
      
      // 存储当前正在请求中的request对象,用于cancel时使用
      const reqTokenMap = new Map()
      
      // 用于存储onlyOnce请求
      const uniqueRequestMap = {}
      
      /**
       * 根据请求方法类型、参数、url拼接
       * @param {*} param
       */
      const getUniqueRequestKey = ({ method, url, data }) => `${method}|${url}|${JSON.stringify(data)}`
      
      /**
       * 防止重复请求逻辑
       * @param {*} { onlyOnce, ...config }
       * @returns
       */
      const processOnlyOnce = (config, key) => {
        if (uniqueRequestMap[key]) {
          throw new Error({
            code: 10000,
            message: `${config.url}请勿重复请求`
          })
        }
        uniqueRequestMap[key] = true
      }
      
      /**
       * 根据传入对象中的config,将对应key从map中移除
       * @param {*} obj 传入对象需包含config
       */
      const removeRequestFromUniqueMap = (uniqueKey) => {
        delete uniqueRequestMap[uniqueKey]
      }
      
      /**
       * 拼接url
       * @param {*} url
       * @param {*} str
       */
      const appendUrl = (url, str) => `${url}${url.indexOf('?') > -1 ? '&' : '?'}${str}`
      
      // 判断类型
      const checkType = (data) => {
        return Object.prototype.toString.call(data).slice(8, -1)
      }
      
      const processForm = (config) => {
        config.headers = config.headers || {}
        config.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'
        config.data = stringify(config.data)
      }
      
      const processUpload = (config) => {
        config.headers = config.headers || {}
        config.headers['Content-Type'] = 'multipart/form-data'
        const formData = new FormData()
        for (const key in config.data) {
          // 针对file进行特殊处理,业务页面自己处理也可
          if (key === 'files') {
            const targetType = checkType(config.data.files)
            if (targetType === 'Array') {
              config.data.files.forEach((file) => {
                formData.append('files', file.file); // files是键,file是值,就是要传的文件
              })
            } else {
              formData.append('files', config.data.files.file)
            }
          } else {
            // 其余的键值参数直接添加进formData
            formData.append(key, config.data[key])
          }
        }
        config.data = formData
      }
      
      
      
      // default settings
      axios.defaults.headers['Content-Type'] = 'application/json;charset=UTF-8'
      axios.defaults.headers['Request-Source'] = CHANNEL
      
      // axios使用的默认配置
      const defaultConfig = {
        withCredentials: true,
        timeout: 10000
      }
      const instance = axios.create(defaultConfig)
      
      // request 拦截
      instance.interceptors.request.use(
        (config) => {
          // 拼接时间戳,防止请求缓存
          config.url = appendUrl(config.url, `_time=${Date.now()}`)
          return config
        },
        (error) => Promise.reject(error)
      )
      
      // response 拦截
      instance.interceptors.response.use(
        (response) => response.data,
        (error) => {
          if (error.toString() === 'Cancel') {
            return Promise.reject({ code: Response.RES_CODE.CANCELED })
          }
          return Promise.reject({
            code: 10000,
            message: '服务异常,请稍后再试'
          })
        }
      )
      
      const fetch = (options) => {
        // 声明取消请求时需使用的source
        const source = axios.CancelToken.source()
      
        const promise = new Promise((resolve, reject) => {
          const {
            onlyOnce, formTag, uploadTag, ...resetOptions
          } = options
          // 唯一Key
          const uniqueKey = getUniqueRequestKey(resetOptions)
          // 处理onlyOnce
          onlyOnce && processOnlyOnce(resetOptions, uniqueKey)
          // 处理form
          formTag && processForm(resetOptions)
          // 处理文件上传
          uploadTag && processUpload(resetOptions)
          // 是否开启loading
          loading && loading(resetOptions)
          instance({
            ...resetOptions,
            cancelToken: source.token
          }).then((response) => {
            Response.commonResponseCallback({
              response, options, resolve, reject
            })
          }).catch((response) => {
            Response.commonResponseCallback({
              response, options, resolve, reject
            })
          }).finally(() => {
            reqTokenMap.delete(promise)
            removeRequestFromUniqueMap(uniqueKey)
          })
        })
      
        reqTokenMap.set(promise, source)
      
        return promise
      }
      
      // 默认配置
      const defaultOptions = {
        onlyOnce: false,
        formTag: false,
        uploadTag: false,
        errorTips: true
      }
      
      export default {
        get(url, options = {}) {
          return fetch({
            ...defaultOptions,
            ...options,
            url,
            method: 'get',
            params: options.data
          })
        },
        post(url, options = {}) {
          return fetch({
            ...defaultOptions,
            ...options,
            url,
            method: 'post'
          })
        },
        cancel(req) {
          // 当传入request时,则取消对应的请求
          if (req) {
            reqTokenMap.get(req).cancel()
            reqTokenMap.delete(req)
            return
          }
          for (const request of reqTokenMap.values()) {
            request.cancel()
          }
          reqTokenMap.clear()
        }
      }
    • response
      js
      import { Toast } from 'vant'
      
      // 后端请求状态码
      const RES_CODE = {
        SUCCESS: 0, // 成功
        NOT_LOGIN: 102, // 未登录
      }
      
      const commonResponseCallback = ({
        response, options, resolve, reject
      }) => {
        const { code, data, message } = response
        // 请求完自动清除loading
        if (window._loading) {
          window._loading.clear()
        }
        // 成功返回
        if (code === RES_CODE.SUCCESS) {
          resolve(data)
          return
        }
        // 有消息返回,且判断是否需要提示
        if (message) {
          options.errorTips && Toast(message)
          reject()
        }
      }
      
      export default { commonResponseCallback, RES_CODE }
    • loading
      js
      import { Toast } from 'vant'
      
      Toast.setDefaultOptions('loading', { forbidClick: true, duration: 0 })
      
      export default (config) => {
        if (config.loading) {
          window._loading = Toast.loading()
        }
      }

Released under the MIT License.