前端接口防止重复请求实现方案

文章介绍了在项目开发中如何通过axios拦截器防止重复请求,包括使用请求key判断重复,以及改进方案中采用发布订阅模式处理相同请求。特别提到了处理文件上传时的特殊情况。
摘要由CSDN通过智能技术生成
众所周知,我们在项目开发过程中难免会遇到重复请求的处理(用户快速点击按钮,触发调用接口操作),下面是实现防止重复请求的实现方案
  • 方案1

    这个方案是最容易想到也是最朴实无华的一个方案:通过使用axios拦截器,在请求拦截器中开启全屏Loading,然后在响应拦截器中将Loading关闭。

    在这里插入图片描述

    这个方案固然已经可以满足我们目前的需求,但不管三七二十一,直接搞个全屏Loading还是不太美观,何况在目前项目的接口处理逻辑中还有一些局部Loading,就有可能会出现Loading套Loading的情况,两个圈一起转,头皮发麻。

  • 方案2

    加Loading的方案不太友好,而对于同一个接口,如果传参都是一样的,一般来说都没有必要连续请求多次吧。那我们可不可以通过代码逻辑直接把完全相同的请求给拦截掉,不让它到达服务端呢?这个思路不错,我们说干就干。

    首先,我们要判断什么样的请求属于是相同请求

    一个请求包含的内容不外乎就是请求方法地址参数以及请求发出的页面hash。那我们是不是就可以根据这几个数据把这个请求生成一个key来作为这个请求的标识呢?

    // 根据请求生成对应的key
    function generateReqKey(config, hash) {
        const { method, url, params, data } = config;
        return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
    }
    

    有了请求的key,我们就可以在请求拦截器中把每次发起的请求给收集起来,后续如果有相同请求进来,那都去这个集合中去比对,如果已经存在了,说明就是一个重复的请求,我们就给拦截掉。当请求完成响应后,再将这个请求从集合中移除。合理,nice!

    具体实现如下:

    import axios from 'axios';
    
    const instance = axios.create({
      baseUrl: '/api/',
    });
    
    // 根据请求生成对应的 key
    function generateReqKey(config, hash) {
      const { method, url, params, data } = config;
      return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join('&');
    }
    
    // 存储已发送但未响应的请求
    const pendingRequest = new Set();
    
    // 添加请求拦截器
    instance.interceptors.request.use(function (config) {
      const hash = location.hash;
      // 生成请求 Key
      const reqKey = generateReqKey(config, hash);
      if (pendingRequest.has(reqKey)) {
        return Promise.reject();
      } else {
        // 将请求的 key 保存在 config
        config.pendKey = reqKey;
        pendingRequest.add(reqKey);
      }
      return config;
    }, function (error) {
      return Promise.reject(error);
    });
    
    // 添加响应拦截器
    instance.interceptors.response.use(function (response) {
      // 从 config 取出请求 key,并从集合中移除
      pendingRequest.delete(response.config.pendKey);
      return response;
    }, function (error) {
      // 从 config 取出请求 key,并从集合中移除
      pendingRequest.delete(error.config.pendKey);
      return Promise.reject(error);
    });
    
    export default instance;
    

    是不是觉得这种方案还不错,万事大吉?

    no,no,no! 这个方案虽然理论上是解决了接口防重复请求这个问题,但是它会引发更多的问题。

    比如,我有这样一个接口处理:

    在这里插入图片描述

    那么,当我们触发多次请求时:

    在这里插入图片描述

    这里我连续点击了4次按钮,可以看到,的确是只有一个请求发送出去,可是因为在代码逻辑中,我们对错误进行了一些处理,所以就将报错消息提示了3次,这样是很不友好的,而且,如果在错误捕获中有做更多的逻辑处理,那么很有可能会导致整个程序的异常。

    而且,这种方案还会有另外一个比较严重的问题

    我们在上面在生成请求key的时候把hash考虑进去了(如果是history路由,可以将pathname加入生成key),这是因为项目中会有一些数据字典型的接口,这些接口可能有不同页面都需要去调用,如果第一个页面请求的字典接口比较慢,第二个页面的接口就被拦截了,最后就会导致第二个页面逻辑错误。那么这么一看,我们生成key的时候加入了hash,讲道理就没问题了呀。

    可是倘若我这两个请求是来自同一个页面呢?

    比如,一个页面同时加载两个组件,而这两个组件都需要调用某个接口时:

    在这里插入图片描述

    那么此时,后调接口的组件就无法拿到正确数据了。啊这,真是难顶!

  • 方案3

    方案2的路子,我们发现确实问题重重,那么接下来我们来看第三种方案,也是我们最终采用的方案。

    延续我们方案二的前面思路,仍然是拦截相同请求,但这次我们可不可以不直接把请求挂掉,而是对于相同的请求我们先给它挂起,等到最先发出去的请求拿到结果回来之后,把成功或失败的结果共享给后面到来的相同请求

    在这里插入图片描述

    思路我们已经明确了,但这里有几个需要注意的点:

    • 我们在拿到响应结果后,返回给之前我们挂起的请求时,我们要用到发布订阅模式
    • 对于挂起的请求,我们需要将它拦截,不能让它执行正常的请求逻辑,所以一定要在请求拦截器中通过return Promise.reject()来直接中断请求,并做一些特殊的标记,以便于在响应拦截器中进行特殊处理

    最后,直接附上完整代码:

    import axios from "axios"
    
    let instance = axios.create({
        baseURL: "/api/"
    })
    
    // 发布订阅
    class EventEmitter {
        constructor() {
            this.event = {}
        }
        on(type, cbres, cbrej) {
            if (!this.event[type]) {
                this.event[type] = [[cbres, cbrej]]
            } else {
                this.event[type].push([cbres, cbrej])
            }
        }
    
        emit(type, res, ansType) {
            if (!this.event[type]) return
            else {
                this.event[type].forEach(cbArr => {
                    if(ansType === 'resolve') {
                        cbArr[0](res)
                    }else{
                        cbArr[1](res)
                    }
                });
            }
        }
    }
    
    
    // 根据请求生成对应的key
    function generateReqKey(config, hash) {
        const { method, url, params, data } = config;
        return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
    }
    
    // 存储已发送但未响应的请求
    const pendingRequest = new Set();
    // 发布订阅容器
    const ev = new EventEmitter()
    
    // 添加请求拦截器
    instance.interceptors.request.use(async (config) => {
        let hash = location.hash
        // 生成请求Key
        let reqKey = generateReqKey(config, hash)
        
        if(pendingRequest.has(reqKey)) {
            // 如果是相同请求,在这里将请求挂起,通过发布订阅来为该请求返回结果
            // 这里需注意,拿到结果后,无论成功与否,都需要return Promise.reject()来中断这次请求,否则请求会正常发送至服务器
            let res = null
            try {
                // 接口成功响应
              res = await new Promise((resolve, reject) => {
                        ev.on(reqKey, resolve, reject)
                    })
              return Promise.reject({
                        type: 'limiteResSuccess',
                        val: res
                    })
            }catch(limitFunErr) {
                // 接口报错
                return Promise.reject({
                            type: 'limiteResError',
                            val: limitFunErr
                        })
            }
        }else{
            // 将请求的key保存在config
            config.pendKey = reqKey
            pendingRequest.add(reqKey)
        }
    
        return config;
      }, function (error) {
        return Promise.reject(error);
      });
    
    // 添加响应拦截器
    instance.interceptors.response.use(function (response) {
        // 将拿到的结果发布给其他相同的接口
        handleSuccessResponse_limit(response)
        return response;
      }, function (error) {
        return handleErrorResponse_limit(error)
      });
    
    // 接口响应成功
    function handleSuccessResponse_limit(response) {
          const reqKey = response.config.pendKey
        if(pendingRequest.has(reqKey)) {
          let x = null
          try {
            x = JSON.parse(JSON.stringify(response))
          }catch(e) {
            x = response
          }
          pendingRequest.delete(reqKey)
          ev.emit(reqKey, x, 'resolve')
          delete ev.reqKey
        }
    }
    
    // 接口走失败响应
    function handleErrorResponse_limit(error) {
        if(error.type && error.type === 'limiteResSuccess') {
          return Promise.resolve(error.val)
        }else if(error.type && error.type === 'limiteResError') {
          return Promise.reject(error.val);
        }else{
          const reqKey = error.config.pendKey
          if(pendingRequest.has(reqKey)) {
            let x = null
            try {
              x = JSON.parse(JSON.stringify(error))
            }catch(e) {
              x = error
            }
            pendingRequest.delete(reqKey)
            ev.emit(reqKey, x, 'reject')
            delete ev.reqKey
          }
        }
          return Promise.reject(error);
    }
    
    export default instance;
    
  • 补充

    到这里,这么一通操作下来上面的代码讲道理是万无一失了,但不得不说,线上的情况仍然是复杂多样的。而其中一个比较特殊的情况就是文件上传

    在这里插入图片描述

    可以看到,我在这里是上传了两个不同的文件的,但只调用了一次上传接口。按理说是两个不同的请求,可为什么会被我们前面写的逻辑给拦截掉一个呢?

    我们打印一下请求的config:

    在这里插入图片描述

    可以看到,请求体data中的数据是FormData类型,而我们在生成请求key的时候,是通过JSON.stringify方法进行操作的,而对于FormData类型的数据执行该函数得到的只有{}。所以,对于文件上传,尽管我们上传了不同的文件,但它们所发出的请求生成的key都是一样的,这么一来就触发了我们前面的拦截机制。

    那么我们接下来我们只需要在我们原来的拦截逻辑中判断一下请求体的数据类型即可,如果含有FormData类型的数据,我们就直接放行不再关注这个请求就是了。

    function isFileUploadApi(config) {
      return Object.prototype.toString.call(config.data) === "[object FormData]"
    }
    
  • 16
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值