什么是无感知刷新token?无感知刷新token,就是accessToken过期时,去请求refreshToken接口,请求成功后继续之前因为accessToken过期导致无法请求的接口。无感知刷新token的好处?无感知刷新token,当accessToken过期时自动请求refreshToken接口获取新的accessToken从而实现续签,且此过程用户毫无感知。
传统实现方式为弹个弹框提示认证过期,点击确定才会去请求refreshToken接口。对于accessToken有效期较短的项目,用户隔三差五就看到弹框叫重新获取认证,非常不友好。
实现逻辑
统一约定好规则,错误代码
200:请求成功 401:认证失败 403:禁止访问,直接弹框提示
1、状态200返回响应数据
2、状态403直接弹框,点击确定 清除缓存刷新页面
3、状态 401认证过期
1)设置 isRefresh 变量为true
2)请求刷新token接口
3) 此时有其他请求时,将请求放到队列中,待刷新token响应时再请求
4)刷新token响应成功时,将isRefresh设置为false
第一种实现方式,具体上代码 作者自研
import axios from 'axios'
import type {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAppStoreHook } from '@/store/modules/app'
const useAppStore = useAppStoreHook()
import { useUserStoreHook } from '@/store/modules/user'
interface RequestObj {
[key: string]: {
cancelSource: any // 堆代码 duidaima.com
noShowLoading: boolean // 是否展示加载遮罩
changeRouteRemove: boolean // 是否路由跳转时 取消未响应的接口请求
isCancel: boolean // 是否是取消请求
}
}
interface NewAddConfig {
preventDuplicateRequests?: boolean // 是否配置防止重复请求
noShowLoading?: boolean // 是否展示加载遮罩
changeRouteRemove?: boolean // 是否路由跳转时 取消未响应的接口请求
}
export interface UserAxiosRequestConfig extends NewAddConfig, AxiosRequestConfig {}
interface NewAxiosRequestConfig extends NewAddConfig, InternalAxiosRequestConfig {}
interface NewAxiosResponse extends AxiosResponse {
config: NewAxiosRequestConfig
}
const CancelToken = axios.CancelToken
class Axios {
// axios 实例
instance: AxiosInstance
// 基础配置,url和超时时间
baseConfig: AxiosRequestConfig = { baseURL: '/proxy', timeout: 10000 }
// 请求对象
requestObj: RequestObj = {}
// 正在请求的接口数量
requestingNum = 0
// 记录正在刷新token时 请求的接口地址
authErrorArr = []
// accessToken过期后 请求的接口
queue = []
// 是否正在执行刷新token请求
isRefresh = false
// 是否展示了重新登录弹框
isShowReLoginDialog = false
// 取消请求方法,
remove(url: string, isRouteChange: boolean): void {
// 没有这个请求,直接返回
if (!this.requestObj[url]) return
// 路由处调用的remove方法, 且此接口切换路由时不进行 取消请求操作 直接返回
if (isRouteChange && !this.requestObj[url].changeRouteRemove) return
this.requestObj[url].cancelSource.cancel({
...this.requestObj[url]
})
if (isRouteChange) {
delete this.requestObj[url]
}
}
async clearAuth(message: string, useUserStore: any): Promise<void> {
if (this.isShowReLoginDialog) {
return
}
this.isShowReLoginDialog = true
await useUserStore.clearAuth()
ElMessageBox.alert(`${message}`, '提示', {
confirmButtonText: 'OK',
showClose: false,
callback: async (action: string) => {
if (action === 'confirm') {
// 刷新浏览器是为了 跳转登录页时query的redirect 会带上 当前页面地址
window.location.reload()
}
}
})
Promise.reject(new Error(message || 'Error'))
}
constructor(config?: AxiosRequestConfig) {
// 使用axios.create创建axios实例
this.instance = axios.create(Object.assign(this.baseConfig, config))
this.instance.interceptors.request.use(
(config: NewAxiosRequestConfig) => {
// 如果没配置不展示加载动画 则请求开始 请求数量+1
if (!config.noShowLoading) {
this.requestingNum++
useAppStore.setLoadingStatus(true)
}
const url = config.url
const index = this.authErrorArr.indexOf(url)
// 不存在的才要push 加进去,当正要去请求刷新token接口 且此接口不是刷新token接口
if (index === -1 && this.isRefresh && url !== '/xiaobu-admin/refresh-token') {
this.authErrorArr.push(url)
}
// 配置 preventDuplicateRequests 为false 则不校验防止重复请求
if (url && config.preventDuplicateRequests !== false) {
this.remove(url, false)
this.requestObj[url] = {
cancelSource: CancelToken.source(),
isCancel: true,
noShowLoading: !!config.noShowLoading,
changeRouteRemove: !(config.changeRouteRemove === false) // 配置为false时才不清除
}
config.cancelToken = this.requestObj[config.url].cancelSource.token
}
// 一般会请求拦截里面加token
const useUserStore = useUserStoreHook()
const accessToken = useUserStore.gettersAccessToken
if (accessToken) {
config.headers['authorization'] = 'Bearer ' + accessToken
}
return config
},
(err: any) => {
return Promise.reject(err)
}
)
this.instance.interceptors.response.use(
async (res: NewAxiosResponse) => {
const useUserStore = useUserStoreHook()
const { config } = res
// 如果没配置不展示加载动画 则响应结束 请求数量-1
if (!config.noShowLoading) {
this.requestingNum--
if (this.requestingNum <= 0) {
useAppStore.setLoadingStatus(false)
}
}
// 响应结束 去除 请求对象 的key值
if (config.url && this.requestObj[config.url]) {
delete this.requestObj[config.url]
}
const data = res.data
// 返回的是文件流 直接返回
if (config.responseType === 'blob') {
return data
}
// code为200 直接返回有用数据
if (data.code === 200) {
return data.data
}
const index = this.authErrorArr.indexOf(config.url)
if (index !== -1) {
this.authErrorArr.splice(index, 1)
// 当前正在尝试刷新token,先返回一个promise阻塞请求并推进请求列表中
return new Promise((resolve) => {
this.queue.push((accessToken: string) => {
Reflect.set(config.headers!, 'authorization', 'Bearer ' + accessToken)
// @ts-ignore
resolve(this.instance.request<NewAxiosResponse<any>>(config))
})
})
}
// token过期 请求refreshToken
if (data.code === 401) {
const refreshToken = useUserStore.gettersRefreshToken
// 不存在refreshToken时 直接弹框提示
if (!refreshToken) {
return this.clearAuth(data.message, useUserStore)
}
// 判断当前是否为刷新状态中(防止多个请求导致多次调refresh接口)
if (!this.isRefresh) {
// 设置当前状态为刷新中
this.isRefresh = true
try {
const { accessToken } = await useUserStore.refreshTokenAction({ refreshToken })
// 遍历队列,重新发起请求
this.queue.forEach((cb) => cb(accessToken))
// 请求第一个报 401的接口 并return 否则 无法返回数据
return this.instance.request(config)
} catch {
return this.clearAuth(data.message, useUserStore)
} finally {
this.queue = []
// 重置状态
this.isRefresh = false
}
} else {
//还是会存在部分接口和 第一个报 401接口 一起进来的 所以此处要对非第一个报 401 接口 的接口做处理
// 当前正在尝试刷新token,先返回一个promise阻塞请求并推进请求列表中
return new Promise((resolve) => {
this.queue.push((accessToken: string) => {
Reflect.set(config.headers!, 'authorization', 'Bearer ' + accessToken)
// @ts-ignore
resolve(this.instance.request<NewAxiosResponse<any>>(config))
})
})
}
}
// accessToken过期或者无效 清除token 然后刷新页面
if (data.code === 403) {
return this.clearAuth(data.message, useUserStore)
}
ElMessage({
showClose: true,
message: `${data.message}`,
type: 'error'
})
return Promise.reject(new Error(data.message || 'Error'))
},
(err: any) => {
// 这里用来处理http常见错误,进行全局提示
let message = ''
if (err?.config) {
const url = err.config.url
// 报错时 删除这个正在请求的对象
if (this.requestObj[url]) delete this.requestObj[url]
// 接口报错时 删除
const index = this.authErrorArr.indexOf(url)
if (index !== -1) this.authErrorArr.splice(index, 1)
}
// 取消请求中message.noShowLoading不为true 或者 非取消请求中 config.noShowLoading 不为true
if (
(err?.message?.isCancel && !err?.message?.noShowLoading) ||
(err.config && !err.config.noShowLoading)
) {
this.requestingNum--
if (this.requestingNum <= 0) {
useAppStore.setLoadingStatus(false)
}
}
message = err?.message?.isCancel ? '' : err.message
if (err.response && err.response.status) {
switch (err.response.status) {
case 400:
message = '请求错误(400)'
break
case 401:
message = '未授权,请重新登录(401)'
// 这里可以做清空storage并跳转到登录页的操作
break
case 403:
message = '拒绝访问(403)'
break
case 404:
message = '请求出错(404)'
break
case 408:
message = '请求超时(408)'
break
case 500:
message = '服务器错误(500)'
break
case 501:
message = '服务未实现(501)'
break
case 502:
message = '网络错误(502)'
break
case 503:
message = '服务不可用(503)'
break
case 504:
message = '网络超时(504)'
break
case 505:
message = 'HTTP版本不受支持(505)'
break
default:
message = `连接出错(${err.response.status})!`
}
}
// 这里错误消息可以使用全局弹框展示出来
if (message) {
// 比如element plus 可以使用 ElMessage
ElMessage({
showClose: true,
message: `${message},请检查网络或联系管理员!`,
type: 'error'
})
}
// 这里是AxiosError类型,所以一般我们只reject我们需要的响应即可
return Promise.reject(err)
}
)
}
get<T = any>(url: string, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, ...config, method: 'GET' })
}
post<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, data, ...config, method: 'POST' })
}
delete<T = any>(url: string, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, ...config, method: 'DELETE' })
}
patch<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, data, ...config, method: 'PATCH' })
}
put<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, data, ...config, method: 'PUT' })
}
}
export default new Axios()
此种方式,要求后端响应状态码为token认证过期响应401,refreshToken过期响应403,异地登录响应403,正常响应200。
另一种方式,上代码
import axios from 'axios'
import type {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAppStoreHook } from '@/store/modules/app'
const useAppStore = useAppStoreHook()
import { useUserStoreHook } from '@/store/modules/user'
interface RequestObj {
[key: string]: {
cancelSource: any
noShowLoading: boolean // 是否展示加载遮罩
changeRouteRemove: boolean // 是否路由跳转时 取消未响应的接口请求
isCancel: boolean // 是否是取消请求
}
}
interface NewAddConfig {
preventDuplicateRequests?: boolean // 是否配置防止重复请求
noShowLoading?: boolean // 是否展示加载遮罩
changeRouteRemove?: boolean // 是否路由跳转时 取消未响应的接口请求
}
export interface UserAxiosRequestConfig extends NewAddConfig, AxiosRequestConfig {}
interface NewAxiosRequestConfig extends NewAddConfig, InternalAxiosRequestConfig {}
interface NewAxiosResponse extends AxiosResponse {
config: NewAxiosRequestConfig
}
const CancelToken = axios.CancelToken
class Axios {
// axios 实例
instance: AxiosInstance
// 基础配置,url和超时时间
baseConfig: AxiosRequestConfig = { baseURL: '/proxy', timeout: 10000 }
// 请求对象
requestObj: RequestObj = {}
// 正在请求的接口数量
requestingNum = 0
// accessToken过期后 请求的接口
queue = []
// 是否正在执行刷新token请求
isRefresh = false
// 是否展示了重新登录弹框
isShowReLoginDialog = false
currentCount = 0
MAX_ERROR_COUNT = 3
// 取消请求方法,
remove(url: string, isRouteChange: boolean): void {
// 没有这个请求,直接返回
if (!this.requestObj[url]) return
// 路由处调用的remove方法, 且此接口切换路由时不进行 取消请求操作 直接返回
if (isRouteChange && !this.requestObj[url].changeRouteRemove) return
this.requestObj[url].cancelSource.cancel({
...this.requestObj[url]
})
if (isRouteChange) {
delete this.requestObj[url]
}
}
async clearAuth(message: string, useUserStore: any): Promise<void> {
if (this.isShowReLoginDialog) {
return
}
this.isShowReLoginDialog = true
await useUserStore.clearAuth()
ElMessageBox.alert(`${message}`, '提示', {
confirmButtonText: 'OK',
showClose: false,
callback: async (action: string) => {
if (action === 'confirm') {
// 刷新浏览器是为了 跳转登录页时query的redirect 会带上 当前页面地址
window.location.reload()
}
}
})
Promise.reject(new Error(message || 'Error'))
}
constructor(config?: AxiosRequestConfig) {
// 使用axios.create创建axios实例
this.instance = axios.create(Object.assign(this.baseConfig, config))
this.instance.interceptors.request.use(
(config: NewAxiosRequestConfig) => {
// 如果没配置不展示加载动画 则请求开始 请求数量+1
if (!config.noShowLoading) {
this.requestingNum++
useAppStore.setLoadingStatus(true)
}
const url = config.url
// 配置 preventDuplicateRequests 为false 则不校验防止重复请求
if (url && config.preventDuplicateRequests !== false) {
this.remove(url, false)
this.requestObj[url] = {
cancelSource: CancelToken.source(),
isCancel: true,
noShowLoading: !!config.noShowLoading,
changeRouteRemove: !(config.changeRouteRemove === false) // 配置为false时才不清除
}
config.cancelToken = this.requestObj[config.url].cancelSource.token
}
// 一般会请求拦截里面加token
const useUserStore = useUserStoreHook()
const accessToken = useUserStore.gettersAccessToken
if (accessToken) {
/**
* 当 NewAxiosRequestConfig 继承自 AxiosRequestConfig时 会报 headers不一定存在,于是加了下面的判断,
* 后面查看axios/index.d.ts代码,发现还有一层 InternalAxiosRequestConfig 包含headers的引用类型,然后可去除下面的判断
* */
// if (!config.headers) {
// config.headers = {}
// }
config.headers['authorization'] = 'Bearer ' + accessToken
}
return config
},
(err: any) => {
return Promise.reject(err)
}
)
this.instance.interceptors.response.use(
async (res: NewAxiosResponse) => {
const useUserStore = useUserStoreHook()
const { config } = res
// 如果没配置不展示加载动画 则响应结束 请求数量-1
if (!config.noShowLoading) {
this.requestingNum--
if (this.requestingNum <= 0) {
useAppStore.setLoadingStatus(false)
}
}
// 响应结束 去除 请求对象 的key值
if (config.url && this.requestObj[config.url]) {
delete this.requestObj[config.url]
}
const data = res.data
// 返回的是文件流 直接返回
if (config.responseType === 'blob') {
return data
}
// code为200 直接返回有用数据
if (data.code === 200) {
return data.data
}
// token过期 请求refreshToken
if (data.code === 401) {
const refreshToken = useUserStore.gettersRefreshToken
// 不存在refreshToken时 直接弹框提示
if (!refreshToken) {
return this.clearAuth(data.message, useUserStore)
}
// 判断是否refresh失败且状态码401,再次进入错误拦截器
if (config.url === '/xiaobu-admin/refresh-token') {
return this.clearAuth(data.message, useUserStore)
}
// 判断当前是否为刷新状态中(防止多个请求导致多次调refresh接口)
if (!this.isRefresh) {
// 设置当前状态为刷新中
this.isRefresh = true
// 如果重发次数超过,直接退出登录
if (this.currentCount > this.MAX_ERROR_COUNT) {
return this.clearAuth(data.message, useUserStore)
}
// 增加重试次数
this.currentCount += 1
try {
const { accessToken } = await useUserStore.refreshTokenAction({ refreshToken })
this.currentCount = 0
// 遍历队列,重新发起请求
this.queue.forEach((cb) => cb(accessToken))
// 请求第一个报 401的接口 并return 否则 无法返回数据
return this.instance.request(config)
} catch {
return this.clearAuth(data.message, useUserStore)
} finally {
this.queue = []
// 重置状态
this.isRefresh = false
}
} else {
//还是会存在部分接口和 第一个报 401接口 一起进来的 所以此处要对非第一个报 401 接口 的接口做处理
// 当前正在尝试刷新token,先返回一个promise阻塞请求并推进请求列表中
return new Promise((resolve) => {
this.queue.push((accessToken: string) => {
Reflect.set(config.headers!, 'authorization', 'Bearer ' + accessToken)
// @ts-ignore
resolve(this.instance.request<NewAxiosResponse<any>>(config))
})
})
}
}
// accessToken过期或者无效 清除token 然后刷新页面
if (data.code === 403) {
return this.clearAuth(data.message, useUserStore)
}
ElMessage({
showClose: true,
message: `${data.message}`,
type: 'error'
})
return Promise.reject(new Error(data.message || 'Error'))
},
(err: any) => {
// 这里用来处理http常见错误,进行全局提示
let message = ''
// 取消请求中message.noShowLoading不为true 或者 非取消请求中 config.noShowLoading 不为true
if (
(err?.message?.isCancel && !err?.message?.noShowLoading) ||
(err.config && !err.config.noShowLoading)
) {
this.requestingNum--
if (this.requestingNum <= 0) {
useAppStore.setLoadingStatus(false)
}
}
message = err?.message?.isCancel ? '' : err.message
if (err.response && err.response.status) {
switch (err.response.status) {
case 400:
message = '请求错误(400)'
break
case 401:
message = '未授权,请重新登录(401)'
break
case 403:
message = '拒绝访问(403)'
break
case 404:
message = '请求出错(404)'
break
case 408:
message = '请求超时(408)'
break
case 500:
message = '服务器错误(500)'
break
case 501:
message = '服务未实现(501)'
break
case 502:
message = '网络错误(502)'
break
case 503:
message = '服务不可用(503)'
break
case 504:
message = '网络超时(504)'
break
case 505:
message = 'HTTP版本不受支持(505)'
break
default:
message = `连接出错(${err.response.status})!`
}
}
// 这里错误消息可以使用全局弹框展示出来
if (message) {
// 比如element plus 可以使用 ElMessage
ElMessage({
showClose: true,
message: `${message},请检查网络或联系管理员!`,
type: 'error'
})
}
// 这里是AxiosError类型,所以一般我们只reject我们需要的响应即可
return Promise.reject(err)
}
)
}
get<T = any>(url: string, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, ...config, method: 'GET' })
}
post<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, data, ...config, method: 'POST' })
}
delete<T = any>(url: string, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, ...config, method: 'DELETE' })
}
patch<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, data, ...config, method: 'PATCH' })
}
put<T = any>(url: string, data: any, config: UserAxiosRequestConfig): Promise<T> {
return this.instance.request({ url, data, ...config, method: 'PUT' })
}
}
export default new Axios()
此种方式要求后端,refreshToken过期及token过期都返回401,只有异地登录时可以返回403。因为403意味着弹框提示,点击确定清除缓存,刷新页面。此方式会刷新token请求响应401时,还有机会再请求,具体请求次数为配置的MAX_ERROR_COUNT。