项目中如何实现无感知刷新 token

134 阅读4分钟

我们的项目大多涉及很多接口不同类型的请求,每一种请求涉及的参数可能不一样,但他们还是有很多共同

响应拦截,主要处理响应状态码,给一些友好提示,再结合业务,判断这个请求在业务上是成功还是失败。其次就是对 token 过期的处理,如果token 过期了,后端可能返回401状态码,此时我们就需要在请求头上带上refreshToken,自动获取 token 并更新(记得同步更新请求拦截中的 config中的token,否则会死循环),然后再自动发起一次之前的请求;并且处理了多个请求失败引起多次刷新token问题

问题1:用户在使用在线客服座席端的时候,偶尔会出现突然页面跳转到登录页面,需要重新登录

问题2:当refreshToken 过期时候,会停留在首页,无法跳转到登录页 问题3:当存在中断请求时(多个相同请求只发一个请求)

现象原因: 突然跳到登录页面:是由于当前的accessToken 过期了,导致请求失败,在响应拦截器中(axios.interceptor.response.use(config=>{}))中,处理请求失败状态码为401时,可知accessToken 过期,因为跳转到登录页,让用户重新进行登录。 客服聊天可能存在停留时间很长

token无感知刷新

方案:

使用Promise 处理失败的请求,将由于 token 过期导致的失败请求存储起来(存储的是请求回调函数,resolve 状态),后续我们更新了 token ,可以将存储的失败请求重新发起,从而达到用户无感的体验。

@1 创建axios 实例,进行axios 请求前拦截

@2 响应拦截前,封装一个类,定义一个静态变量为 fetchNewTokenPromise 对象

@3 判断响应失败状态码为401的,且 url 地址不是refresh_token的

@4 判断 静态变量 fetchNewTokenPromise 是否为空,为空将赋值为带着 refre_token 获取新的token实例

@4 不为空则等待实例成功直接重新请求,防止多次刷新token

@5 最后成功后置空静态变量 fetchNewTokenPromise

具体思路: @1 登录时获取accessToken 和 refreshToken ,并存储到localStorage中 @2 每次接口请求时,带上accessToken @3 如果 accessToken过期了,接口返回 401 状态码 @4 此时前端会使用 refreshToken 去请求后端再给一个有效的 accessToken @5 重新拿到最新的accessToken 之后,再将刚才请求重新发起 @6 如果refreshToken 也过期了,就只能回到登陆页面重新获取了

实现思路

封装一个 AxiosRetry 类,当 accessToken 过期时,后端返回401,前端拿着 refreshToken 重新获取 accessToken 。

  • 类 AxiosRetry 能携带 refreshToken 去重新获取 accessToken

  • 当获取到新的 accessToken时,重新发起当前失败的这个请求

    注意:config中的data数据格式需要看一下格式,是否满足后端需求

  • 注意:当有多个请求并发时,做好拦截,不要多次去获取 accessToken

以上三点需求中,在AxiosRetry类中进行实现:

@1 requestWrapper 函数 会对每个请求进行额外处理,

@2 fetchNewTokenPromise 变量是一个 Promise 实例,只有在请求到新的 accessToken 时,状态才会成功(fulfilled)

实现过程

import Axios from "axios";
// AxiosRetry该类提供一个方法,可以发起请求,带着 refreshToken 去获取新的 accessToken
// 提供一个 requestWrapper 高阶函数,对每一个请求进行额外处理
// 当获取新的 AccessToken 时,可以重新发起刚刚失败了的请求,当有多个请求并发时,要做好拦截,避免多次获取 accessToken
class AxiosRetry {
  /**
   *Creates an instance of AxiosRetry.
   * @param {*} baseUrl 基础url
   * @param {*} url 请求新的accessToken的 url
   * @param {*} getRefreshToken 获取refreshToken 的函数
   * @param {*} unauthorizedCode  无权限的状态码,默认 401
   * @param {*} onSuccess 获取新的 accessToken 成功后的回调
   * @param {*} onError 获取新的 accessToken 失败后的回调
   * @memberof AxiosRetry
   */
  constructor(baseUrl, url, getRefreshToken, unauthorizedCode, onSuccess, onError) {
    this.baseUrl = baseUrl
    this.url = url
    this.getRefreshToken = getRefreshToken
    this.unauthorizedCode = unauthorizedCode
    this.fetchNewTokenPromise = null; // Promise 实例,只有再请求到新的accessToken 时才会fulfilled
    this.onSuccess = onSuccess
    this.onError = onError
  }
  // 处理每一个请求的函数
  requestWrapper(request) {
    return new Promise((resolve,reject) => {
      // 先把请求保留下来
      const requestFn = request
      return request().then(resolve)
        .catch(err => {
          // 请求新的accessToken的 url,当前失败的地址不是携带refreshToken去获取accessToken地址,要不然死循环?
          if (err?.status === this.unauthorizedCode && !(err?.config?.url === this.url)) {
            if (!this.fetchNewTokenPromise) {
            // 没有获取accessToken请求的实例时
              this.fetchNewTokenPromise = this.fetchNewToken()
            }
            // 有因accessToken 过期导致的其他失败请求,直接等 实例状态成功
            this.fetchNewTokenPromise.then(() => {
              // 获取成功后,重新执行请求
              requestFn().then(resolve).catch(reject)
            })
              .finally(() => {
                // 置空
                this.fetchNewTokenPromise = null; 
            })
          } else {
            reject()
          }
        })
    })
  }
  // 获取token 的函数
  fetchNewToken() {
    return new Axios({
      baseURL: this.baseUrl
    }).get(this.url, {
      headers: {
        Authorization: this.getRefreshToken()
      }
    }).then(this.onSuccess)
      .catch(() => {
        this.onError();
        return Promise.reject();
    })
  }
}

封装axios请求

import Axios from "axios";
import {AxiosRetry} from './retry.js'

const axios = new Axios({
  baseURL: 'http://localhost:8080'
})

// 请求拦截
axios.interceptors.request.use((config) => {
  const url = config.url;
  if (url !== '/login') {
    config.headers.Authorization = localStorage.getItem('LOCAL_ACCESS_KEY')
  }
  return config
})

// 响应拦截
axios.interceptors.response.use((res) => {
  if (res.status !== 200) {
    return Promise.reject(res)
  }
  return JSON.parse(res.data);
})

// 请求逻辑
const axiosRetry = new AxiosRetry({
  baseURL: BASE_URL,
  url: FETCH_TOKEN_URL,
  unauthorizedCode: 401,
  getRefreshToken: () => localStorage.getItem(LOCAL_REFRESH_KEY),
  onSuccess: res => {
    const accessToken = JSON.parse(res.data).accessToken;
    localStorage.setItem(LOCAL_ACCESS_KEY,accessToken)
  },
  onError:()=>console.log('refreshToken 过期了,请重新登录')
})

const get = (url, option) => {
  return axiosRetry.requestWrapper(()=> axios.get(url,option))
}

const post = (url, option) => {
  return axios.requestWrapper(()=> axios.post(url,option))
}

参考:juejin.cn/post/725457…

参考:juejin.cn/post/729380…

参考: juejin.cn/post/725457…