一文学会请求中断、请求重发、请求排队、请求并发

4,895 阅读6分钟

大家好,今天我们来聊聊前端开发中的网络请求,顺便也来体验一下promise的神奇之处!
以下示例是基于axios@1.5.1进行开发,在一些低版本中的一些用法可能不太一样,建议安装新版进行测试。
阅读下文需要了解前置知识:promise、class、axios

请求中断

1.判定相同请求:请求url、请求方法、请求params参数、请求 body参数,四个值都相同,则认为是一个相同的请求。
2.判断请求中断:在上一个相同请求还没有得到响应前,再次请求,则会自动中断。

image.png

请求重发(无感刷新token)

1.当前请求返回401时,执行刷新token。
2.当同时存在多个请求返回401时,可在类中维护一个静态变量保存请求刷新接口的promise,防止多次调用刷新token。
3.RetryRequest类实例化参数:
   instance:请求实例对象
   success:刷新成功回调函数
   error:刷新失败回调函数
image.png

请求排队

  1. queue:请求等待队列。
  2. isWating:是否正在等待上个请求响应。
  3. add:向队列里加入一个等待请求的promise的resolve方法,执行该方法可立刻发送下一个请求 。
  4. next:执行下一个请求方法,在上一个请求响应后调用。
    image.png

响应处理

image.png

axios请求实例

image.png

测试

1.请求中断测试

快速点击test请求按钮多次

image.png

2.刷新token请求重发测试

(1)当用户没有登录请求接口时

image.png

(2)当用户登录后,accessToken过期,但refreshToken还没过期调用接口时

image.png 在调用刷新token接口成功后,将重发失败的test接口

(3)当refreshToken过期后调用接口时

image.png 这时已经无法刷新token了,只能乖乖跳转到登录页面了。

3.请求排队测试

(1)没有使用请求排队时

场景:当输入框输入关键字实时查找内容时,由于网络原因,可能会出现先请求的后响应的请求,导致请求错乱。
如下,模拟网络请求延迟: image.png

当输入框依次输入1、2、3、4、5时,期望的返回结果应该是1,12,123,1234,12345。 但确得到了以下的结果: image.png

(2)使用请求队列时

在网络请求的waterfall列可以清晰看到,当上一请求完成才会执行下一请求,直到等待队列执行完成。 image.png

源码

后端接口

var express = require('express');
var router = express.Router();

const access_token = 'access_token'
const refresh_token = 'refresh_token'
// token有效期(单位毫秒)
const tokenValidTime = 1000*2
// 刷新token有效期
const refreshTokenValidTime = 1000*5
// 登录时间,模拟token过期
let loginTime;
// 模拟判断token是否过期
const IsTokenExpired = () => {
  if(new Date().getTime() > loginTime + tokenValidTime) {
    return true
  }
  return false
}
router.post('/login', (req, res) => {
  loginTime = new Date().getTime()
  res.json({
    access_token,
    refresh_token
  })
})

router.post('/refresh-token', (req, res, next) => {
  const refreshToken = req.headers.authorization
  console.log('refresh-token', refreshToken)
  if(refreshToken !== refresh_token) {
    return res.status(401).json({
      msg: ' refreshToken不正确!'
    })
  }
  if(new Date().getTime() > loginTime + refreshTokenValidTime) {
    return res.status(401).json({
      msg: ' refreshToken已过期,请重新登录!'
    })
  }
  loginTime = new Date().getTime()
  res.json({
    access_token: 'access_token',
    refresh_token: 'refresh_token'
  })
})

router.get('/test', (req, res, next) => {
  const token = req.headers.authorization
  if(token !== access_token) {
    return res.status(401).json({
      msg: '没有访问权限'
    })
  }
  if(IsTokenExpired()) {
    return res.status(401).json({
      msg: 'token已过期'
    })
  }
  res.json({
    name: '哈哈'
  })
})

router.get('/random', (req, res) => {
  const keyword = req.query.keyword
  setTimeout(() => {
    res.json({
      value: keyword
    })
    // 5秒内随机返回,测试网络请求延迟效果
  }, Math.random()*5000);
})

module.exports = router;

前端

Index.js

import axios from "axios"
import AbortRequest from './hooks/AbortRequest'
import ResponseHanlder from "./hooks/ResponseHanlder"
import { getAccessToken, getRefreshToken } from '@/utils'
import { refreshTokenUrl } from '@/api/urls'
import { useRequestKey } from "./hooks/useRequestKey"
import RequestQueue from "./hooks/RequestQueue"
import { isAddQueue } from '@/api'

export const baseURL = '/api'
const timeout = 6000

// 创建axios实例
const instance = axios.create({
  baseURL,
  timeout
});

// 创建中断请求控制器
const abortRequest = new AbortRequest()
// 创建响应处理器
const responseHandler = new ResponseHanlder(instance)
// 创建请求队列排队实例
const requestQueue = new RequestQueue()

// 添加请求拦截器
instance.interceptors.request.use(function (config) {
  console.log('在发送请求之前做些什么', config)
  // 在发送请求之前做些什么
  if(config.url !== '/login') {
    const token = config.url === refreshTokenUrl ? getRefreshToken() : getAccessToken()
    config.headers.Authorization = token
  }
  // 刷新token接口不用创建取消请求,已经再RetryRequest类维护静态属性
  if(config.url !== refreshTokenUrl) {
    abortRequest.create(useRequestKey(config), config)
  }
  // 加入请求等待队列
  if(isAddQueue(config)) {
    return requestQueue.add(config.url, config)
  }
  return config
}, function (error) {
  // 对请求错误做些什么
  return Promise.reject(error);
});

// 添加响应拦截器
instance.interceptors.response.use(function (response) {
  const config = response.config
  console.log('响应成功', response)
  abortRequest.remove(useRequestKey(config))
  if(isAddQueue(config)) {
    requestQueue.next(config.url, config)
  }
  // 2xx 范围内的状态码都会触发该函数。
  // 对响应数据做点什么
  return responseHandler.success(response)
}, function (error) {
  const config = error.config
  console.log('响应错误', config)
  if(config) {
    abortRequest.remove(useRequestKey(config))
  }
  if(isAddQueue(config)) {
    requestQueue.next(config.url, config)
  }
  // 超出 2xx 范围的状态码都会触发该函数。
  // 对响应错误做点什么
  if(axios.isCancel(error)) {
    return Promise.reject('已取消重复请求!')
  }
  return responseHandler.error(error)
});

export default instance;

AbortRequest.js

// 重复请求中断类
class AbortRequest {
  constructor() {
    // 请求中断控制器集合
    this.list = new Map()
  }
  // 创建中断请求控制器
  create(key, config) {
    const controller = new AbortController();
    config.signal = controller.signal
    // 集合中存在当前一样的请求,直接中断
    if(this.list.has(key)) {
      controller.abort()
    } else {
      this.list.set(key, controller)
    }
  }
  // 请求完成后移除集合中的请求
  remove(key) {
    this.list.delete(key)
  }
}
export default AbortRequest

RequestQueue.js

/**
 * 相同url请求队列,排队执行维护类
 */
class RequestQueue {
  constructor() {
    // 请求等待队列
    this.queue = {}
    // 正在等待上一请求执行中
    this.isWating = false
  }
  add(url, config) {
    return new Promise((resolve) => {
      const list = this.queue[url] || []
      if(this.isWating) {
        // 当前请求url存在等待发送的请求,则放入请求队列
        list.push({ resolve: () => resolve(config) })
      } else {
        // 没有等待请求,直接发送
        resolve(config)
        this.isWating = true
      }
      this.queue[url] = list
      console.log('list', list)
    })
  }
  // 响应处理
  next(url) {
    this.isWating = false
    // 拿出当前请求url的下一个请求对象
    if(this.queue[url]?.length > 0) {
      const nextRequest = this.queue[url].shift()
      // 执行请求
      nextRequest.resolve()
    }
  }
}

export default RequestQueue

RetryRequest.js

/**
 * 无感刷新token类
 */
class RetryRequest {
  // 解决存在多个并发请求时,重复调用刷新token接口问题
  static refreshTokenPromise = null
  constructor({
    instance, // axios实例
    success, // 刷新token成功执行的回调函数
    error  // 刷新token失败执行的回调函数
  }) {
    this.instance = instance
    this.success = success
    this.error = error
  }
  /**
   * @param config 当前请求对象,等待token刷新完成再重复执行
   * @param refreshTokenApi 刷新token方法
   */
  useRefreshToken(config, refreshTokenApi) {
    if(!config.headers.Authorization) {
      this.error()
      return Promise.reject('token不存在!')
    }
    return new Promise((resolve, reject) => {
      if(!RetryRequest.refreshTokenPromise) {
        // refreshTokenPromise不为null,则当前正在执行刷新token方法,不再重复调用
        RetryRequest.refreshTokenPromise = refreshTokenApi()
      }
      RetryRequest.refreshTokenPromise.then(res => {
        // 刷新token成功
        this.success(res)
        // 重新发送请求
        this.instance(config).then(data => {
          resolve(data)
        }).catch(err => {
          // 重发失败
          reject(err)
        })
      }).catch(err => {
        // refreshToken失效或刷新token失败
        this.error()
        reject(err)
      }).finally(() => {
        // 刷新token调用完成,重置
        RetryRequest.refreshTokenPromise = null
      })
    })
  }
}

export default RetryRequest

ResponseHanlder.js

import RetryRequest from './RetryRequest'
import { refreshToken as refreshTokenApi } from '@/api/index'
import { getRefreshToken, setRefreshToken, setAccessToken } from '@/utils'
import { refreshTokenUrl } from '@/api/urls'

/**
 * 响应处理类
 */
class ResponseHanlder {
  constructor(instance) {
    this.retryRequest = new RetryRequest({
      instance,
      success: (res) => {
        const { access_token, refresh_token } = res
        setAccessToken(access_token)
        setRefreshToken(refresh_token)
      },
      error: () => {
        console.log('刷新token失败!')
        // 执行失败逻辑...
      }
    })
  }
  // 请求正常响应方法
  success(response) {
    // 对响应数据做处理
    return response.data
  }
  // 请求错误响应方法
  error(error) {
    const status = error.response?.status
    // 当前返回401,且不是调用刷新token接口响应的(避免后端刷新token失败返回401导致死循环的情况)
    if(status === 401 && error.config.url !== refreshTokenUrl) {
      return this.retryRequest.useRefreshToken(
        error.config, 
        () => refreshTokenApi(getRefreshToken())
      )
    } else {
      return Promise.reject(error.response)
    }
  }
}

export default ResponseHanlder

request.js

import instance from './index'

class Request {
  constructor() {
    
  }
  get(url, params, args) {
    return instance.get(url, {
      params,
      ...args
    })
  }
  delete(url, params) {
    return instance.get(url, {
      params
    })
  } 
  post(url, data) {
    return instance.post(url, data)
  }
  put(url, data) {
    return instance.put(url, data)
  }
}

export default new Request();

结语

还有一个控制请求并发数量还没进行扩展,相信大家了解了请求排队的思路后,实现请求并发控制也不是什么难事了。

无感刷新token参考文章:juejin.cn/post/728974…