在很多场景中,nuxt中的$fetch都能够满足使用需求。但如果有些特殊场景,例如根据返回的业务编码进行响应拦截、重试的话,就ofetch就不太可以了。
场景说明
ofetch原生就支持了onResponseError
、onRequestError
这两个拦截器。
特别是onResponseError
,当response.ok
不为true的时候,就会执行这个方法。似乎配合retryStatusCodes
,我们就可以很方便的实现以下场景
- 客户端带着accessToken去请求
/get?id=1
- 服务端校验accessToken,发现过期了,设置状态码(HTTP status code) 为 401
- ofetch解析响应的时候,发现error,触发了 onResponseError
- onResponseError内,利用refreshToken获取新的accessToken,并且设置到缓存中
- auto retry的相关配置生效,发现401,便自动重试
- 客户端带着新的accessToken去获取数据
这个流程简直天衣无缝,非常完美。但是他忽略了一种情况,就是下图
没错,很多项目会将HTTP状态码包装在response body当中,真实的HTTP状态码统一返回200
为什么要这么做呢,原因众说纷纭。有说运营商会劫持404响应到其他页面的,有说甲方单位将除了200以外的状态码都封禁了。
在这里我也不多做分析,总之,存在即合理。
我下面的文章也是针对这种200派而写的。如果你是正派,老老实实用HTTP状态码,那么建议你看一下ofetch的官方文档,了解一下拦截器的使用。就OK的了。
实现双token的无感刷新
在plugins目录中,创建一个api.ts
文件结构大概如下
export default defineNuxtPlugin((nuxtApp) => {
let refreshList :any[] = [] //方法栈,如果正在刷新,则把所有的方法扔进去,刷新完毕之后再执行
let isRefreshToken = false //是否正在刷新
const whiteList: string[] = ['/login', '/refresh-token'] // 白名单
// 定义api方法
// 重点!
async function api<repT = any>(url: string, options: ApiOptions): Promise<any> {
// 请求前的拦截逻辑
// 1. 判断是不是需要添加token (根据白名单或者是options的值),如果需要就从本地缓存中添加
// 2. 对其他的options进行处理(例如其他的header参数)
// 正式发送请求
const response = await $fetch(url, newOptions as NitroFetchOptions<any>);
// 请求后的拦截
// 判断code是否发生了错误
// 如果发生了错误,判断是否其他的请求已经在刷新token过程中了
// 如果isRefreshToken==false
// 利用$fetch直接获取新的accessToken,把把refreshList中的方法执行一遍中的方法执行一遍,最后返回重新执行的自己
// 如果isRefreshToken==true
// 把当前方法存储在把refreshList中的方法执行一遍
// 如果没有发生错误,返回response
}
// 将api 函数,注册成为nuxt 的 plugins
return {
provide: {
api
}
}
}
当然,还有很多细节,获取accessToken的时候也失败了,需要跳转到登录页面之类的
下面是完整的代码。当然了,我是个小菜鸡,如果你有更好的办法,请你一定要告诉我啊
最后的最后,我更新了下面的代码。原因是我发现原本捕获了401,刷新了token,再去重新请求的时候。重新设置的accessToken居然还是旧的。我百思不得其解,这样导致了我的请求陷入了死循环。我不得不在重新发起请求之前,把新的accesstoken传进option中,并为此做了特殊处理。
希望有好心的大佬告诉我,为什么新请求中,获取的userToken.value.accessToken还是旧的。我明明已经手动刷新了cookies,按理来说不会这样的
import type {CookieRef} from "nuxt/app";
import type {NitroFetchOptions} from "nitropack";
import type {ApiOptions, CommonResponse, UserToken} from "~/types";
import {useMessage} from "~/composables/useMessage";
export default defineNuxtPlugin((nuxtApp) => {
const meesage = useMessage()
let requestList: any[] = []
// 是否正在刷新中
let isRefreshToken = false
// 请求白名单,无须token的接口
const whiteList: string[] = ['/login', '/refresh-token']
function isCommonResponse<repT>(arg: any): arg is CommonResponse<repT> {
return arg && typeof arg.code === 'number' && typeof arg.msg === 'string' && arg.data !== undefined;
}
//define api function
async function api<repT = any>(url: string, options: ApiOptions): Promise<any> {
////////////////////////////////请求前进行拦截的逻辑///////////////////
// 请求前的逻辑
// 1. 判断是否需要token
let isToken = options.isToken ?? true
// 白名单不需要token
whiteList.some((item) => {
if (url.includes(item)) {
isToken = false
return true
}
return false
})
//get cookies
const userToken: CookieRef<UserToken> = useCookie('userToken', {
maxAge: 60 * 60 * 24 * 7,
})
refreshCookie('userToken')
console.log("isToken", isToken,userToken.value)
if(!(options.headers && ('Authorization' in options.headers))){
// 如果存在 就说明了,token已经设置过了,不需要再设置
if (isToken && userToken.value.accessToken ) {
// 设置请求头
options.headers = {
...options.headers,
'Authorization': `Bearer ${userToken.value.accessToken}`
}
} else {
console.log("no token")
options.headers = {
...options.headers,
'Authorization': `Bearer 123456789`
}
}
}
const apiSuffix = options.isApp ? '/app-api' : '/admin-api'
//默认请求头
const defaultOptions = {
baseURL: 'http://localhost:48080'+apiSuffix,
method: 'GET',
}
const newOptions = {
...defaultOptions,
...options
}
////////////////////////////////请求前进行拦截的逻辑///////////////////
console.log("请求拦截完成------------>{}", url ,newOptions)
try {
// 这里进行发送请求
const response = await $fetch(url, newOptions as NitroFetchOptions<any>);
console.log("发送请求------------>{}", response)
if(isCommonResponse(response)){
if (response.code === 401) {
console.log("发生401错误------->{}", response)
if (!isRefreshToken) {
try {
isRefreshToken = true
if (!userToken.value.refreshToken) {
// 1. 如果获取不到刷新令牌,则只能执行登出操作
navigateTo("/login")
}
// 1. 尝试刷新令牌
const refreshResp: any = await api(`/system/auth/refresh-token`, {
...defaultOptions,
method: 'POST',
query: {
refreshToken: userToken.value.refreshToken
}
})
console.log("刷新令牌成功------------>{}", refreshResp)
//刷新成功了,设置到cookies
userToken.value = refreshResp
// 刷新token,避免重复刷新
refreshCookie('userToken')
console.log("保存新的令牌到cookies---------->{}",userToken.value)
//
isRefreshToken = false
// 2.1 放回队列的请求 + 当前请求
requestList.forEach((cb) => {
cb()
})
requestList = []
// 重新请求一次
options.headers = {
...options.headers,
'Authorization': `Bearer ${refreshResp.accessToken}`
}
return api<repT>(url, options);
} catch (error) {
console.log("捕获刷新令牌时候发生的Promise错误------->{}", response)
// 捕获刷新令牌时候发生的Promise错误
//发生错误了,直接失败,跳到登录页面
console.log("refresh token error", error)
//处理方法栈
requestList.forEach((cb: any) => {
cb()
})
// 清除token
userToken.value = {
accessToken: '',
refreshToken: '',
expiresTime: 0,
userId: ''
}
isRefreshToken = false
//跳转
navigateTo("/login")
}
} else {
//加入队列,进行等待
return new Promise((resolve) => {
requestList.push(() => {
resolve(api<repT>(url, options))
})
})
}
} else if (response.code === 500) {
//服务器错误
//todo 这里需要提示
console.log("发生500错误------->{}", response)
meesage.error(response.msg)
return Promise.reject(response)
} else if (response.code != 200) {
//其他错误
//todo 这里需要提示
meesage.error(response.msg)
console.log("发生其他错误------->{}", response)
return Promise.reject(response)
} else {
console.log("返回响应数据------------>{}", response.data)
return Promise.resolve(response.data)
}
}else {
console.log("返回原始数据----->{}", response)
meesage.error('error!')
return Promise.resolve(response)
}
////////////////////////////////响应请求逻辑//////////////////////////////
////////////////////////////////结束响应//////////////////////////////
} catch (error) {
meesage.error('error!')
console.warn("发送请求的时候发生错误------------> {}", error)
return Promise.reject(error);
}
// 走到这里还没有执行的话,那就是发生了意外错误
return Promise.reject("Unexpected error");
}
return {
provide: {
api
}
}
})