记录对$fetch的拦截与功能补充
拦截原理
nuxt3使用ofetch库提供fetch功能,在客户端使用window.$fetch进行请求,所以只需要对window.$fetch进行拦截就ok了。
// plugins/fetch-interceptors.client.ts
import type { FetchOptions } from 'ofetch'
export default defineNuxtPlugin(() => {
const originFetch = $fetch
Object.defineProperty(window, '$fetch', {
configurable: true,
enumerable: true,
get(){
return (request: string, opts: FetchOptions = {}) => {
return new Promise(async (resolve, reject) => {
// 如果不是请求接口的话就直接放过
if (!request.startsWith('/api'))
return resolve(originFetch(request, opts))
// 处理请求逻辑
})
}
}
}
})
调试(新增)
创建自定义请求器
使用$fetch.create创建一个自定义$fetch实例
import chalk from 'chalk' // 加点色彩呗?
const IS_DEV = process.env.NODE_ENV === 'development'
const originFetch = $fetch.create({
credentials: 'omit',
async onRequest(ctx) {
// 请求显示
},
async onResponse(ctx) {
// 响应显示
}
}
请求显示
async onRequest(ctx) {
if (IS_DEV) {
console.group(`${chalk.yellow('fetch请求')} ${chalk.grey(ctx.request)}`)
console.log(`${chalk.green('请求方式')}: `, ctx.options.method)
console.groupCollapsed(chalk.green('请求头'))
for (const [key, val] of new Headers(ctx.options.headers).entries()) {
console.log(`${chalk.blue(key)}:`, val)
}
console.groupEnd()
if (!ctx.options.method || ctx.options.method === 'get') {
if (!ctx.options.query) ctx.options.query = {}
console.group(chalk.green('携带参数'))
for (const [key, val] of Object.entries(ctx.options.query)) {
console.log(`${chalk.blue(key)}:`, val)
}
console.groupEnd()
} else {
if (!ctx.options.body) ctx.options.body = {}
console.group(chalk.green('携带值'))
for (const [key, val] of Object.entries(ctx.options.body)) {
console.log(`${chalk.blue(key)}:`, val)
}
console.groupEnd()
}
console.groupEnd()
}
}
响应显示
async onResponse(ctx) {
if (IS_DEV) {
console.group(
`${chalk.yellow('fetch响应')} ${chalk.grey(
(ctx.request as string).split('?')[0],
)}`,
)
console.groupCollapsed(chalk.green('响应头'))
for (const [key, val] of ctx.response.headers) {
console.log(`${chalk.blue(key)}:`, val)
}
console.groupEnd()
console.group(`${chalk.green('响应码')}:`, ctx.response.status)
if (ctx.response.status !== 200) {
console.log(`${chalk.blue('statusText')}:`, ctx.response.statusText)
}
console.groupEnd()
if (ctx.response._data) {
if (typeof ctx.response._data === 'object') {
console.group(chalk.green('响应值'))
for (const [key, val] of Object.entries(ctx.response._data)) {
console.log(`${chalk.blue(key)}:`, val)
}
console.groupEnd()
} else console.log(`${chalk.green('响应值')}:`, ctx.response._data)
}
console.groupEnd()
}
}
请求合并
// 请求桶,相同选项且同一接口的请求将合并
const requests_bucket = new Map<
string,
{ resolve: Set<(res: any) => void>; reject: Set<(res: any) => void> }
>()
// -> 处理请求逻辑
// 拼接请求的接口与序列化后的选项,作为同一请求的判断依据
const request_key = request + JSON.stringify(opts)
let responses = requests_bucket.get(request_key)
// 判断如果请求池已经存在,就把resolve和reject的控制权转交给这个请求池,然后中止函数运行
if (responses)
return responses.resolve.add(resolve), responses.reject.add(reject)
// 判断如果请求池还不存在,就新建一个请求池,然后把resolve和reject的控制权转交给这个请求池
responses = {
resolve: new Set([resolve]),
reject: new Set([reject])
}
requests_bucket.set(request_key, responses)
// 定义一个变量用于表示是否等待中
let pedding = true
// 重新定义resolve和reject,此时的功能变成了请求池的响应与拒绝
resolve = (value: unknown) => {
if (!responses?.resolve.size || !pedding) return
// 锁定请求池
const _responses = Array.from(responses.resolve)
requests_bucket.delete(request_key)
for (const resolve of _responses) {
resolve(value)
}
// 关闭状态
pedding = false
}
reject = (reason?: any) => {
if (!responses?.reject.size || !pedding) return
const _responses = Array.from(responses.reject)
requests_bucket.delete(request_key)
for (const reject of _responses) {
reject(reason)
}
pedding = false
}
// 后续正常进行其他操作
params选项修复
为啥要对params选项进行修复捏?
ofetch对params选项的定位是和query一样的作用,功能重复- 同时
nitro对restful风格的接口会将例如/api/commit/:id里的id识别并置入event.context.params中 - 并且前端
$fetch的接口类型提示只对/api/commit/:id生效,而对/api/commit/5不提供智能提示
为了统一且避免重复功能,在前端对params选项进行拦截并修改接口,使用接口时只使用/api/commit/:id。
if (
opts.params &&
typeof opts.params === 'object'
) {
for (const [key, val] of Object.entries(opts.params)) {
if (['string', 'number'].includes(typeof val))
request = request.replaceAll(`:${key}`, String(val))
}
// 如果不删除`opts.params`,则会请求一个`/api/commit/5?id=5`的接口
delete opts.params
}
双token鉴权
双token机制不做讲解,贴一个我看到的流程图,正常应该看了就能懂……吧……
- 单Token认证:
- 双Token认证:
用户状态管理
// stores/useAccount.ts
interface UserInfo {}
interface UserTokenStorage {
value: string
expires: number
}
interface UserToken {
access?: UserTokenStorage
refresh?: UserTokenStorage
}
export default definePiniaStore('account', {
state: () => ({
userinfo: null as UserInfo | null,
token: {} as UserToken,
}),
getters: {
// 把含有效期的存储用的token格式转换为字符串token格式
access_token(): string | undefined {
const { value, expires = 0 } = this.token.access || {}
if (Date.now() > expires) return
return value
},
// 循环使用getters记得标注返回类型
refresh_token(): string | undefined {
const { value, expires = 0 } = this.token.refresh || {}
if (Date.now() > expires) return
return value
},
// 这个是用于防止进入页面时反复登录的
has_login(): boolean {
return Boolean(this.userinfo && this.access_token && this.refresh_token)
},
},
actions: {
async login(sign_form: Client.SignForm) {
const { JSEncrypt } = await import('jsencrypt')
const encrypt = new JSEncrypt()
const { username, password, mood, save } = sign_form
const publicKey = await $fetch('/api/config/rsaKey', {
query: { type: 'login' },
})
encrypt.setPublicKey(publicKey)
const encrypted = encrypt.encrypt(`${password}:${mood}`)
const token = await $fetch('/api/account/login', {
method: 'post',
body: { username, encrypted, save },
})
this.token = token
return this.getUserInfo()
},
async getUserInfo(userinfo?: Client.UserInfo, justread = false) {
if (justread) userinfo = this.userinfo!
if (!userinfo) userinfo = await $fetch('/api/account/userinfo')
if (!justread) this.userinfo = userinfo!
return userinfo
},
// 除了这个函数,其他的action莫管,看了也不告诉你
async refreshToken(token?: Client.UserToken) {
if (!token)
token = await $fetch('/api/account/refresh_token', {
method: 'post',
headers: [['Authentication', this.refresh_token || '']],
})
this.token = token!
return this.token as Required<Client.UserToken>
},
async logout() {
await $fetch('/api/account/logout', {
method: 'post',
body: { access: this.access_token, refresh: this.refresh_token },
})
this.$reset()
const route = useRoute()
const router = useRouter()
router.replace({ path: '/', query: { redirect: route.fullPath } })
},
},
// 这个是一个pinia插件的配置,链接放下面
persist: [
{
key: 'USER_INFO',
paths: ['userinfo'],
},
{
key: 'USER_TOKEN',
storage: persistedState.localStorage,
paths: ['token'],
},
],
})
Pinia持久化插件:pinia-plugin-persistedstate
请求拦截
// 刷新锁
let refresh_lock = false
// 请求缓存
const request_cache = new Set<(token: string) => void>()
// -> 处理请求逻辑
// 封装跳转到登录界面的逻辑
const go_login = async () => {
const route = useRoute()
if (route.path !== '/')
await navigateTo(
{ path: '/', query: { redirect: route.fullPath } },
{ replace: true },
)
}
// 如果是刷新token用的请求,单独处理掉
if (request === '/api/account/refresh_token')
return originFetch(request, opts).then(resolve, async err => {
await go_login()
reject(err)
})
// 封装给请求头加token的逻辑
const setToken = (token?: string) => {
if (!token) return
if (opts.headers instanceof Headers) {
opts.headers.set('Authentication', token)
} else if (Array.isArray(opts.headers)) {
opts.headers.push(['Authentication', token])
} else {
if (!opts.headers) opts.headers = {}
opts.headers['Authentication'] = token
}
}
// 刷新锁定时移入缓存
if (refresh_lock)
return request_cache.add(token => {
setToken(token)
originFetch(request, opts).then(resolve, reject)
})
const account = useAccount()
setToken(account.access_token)
const resolvent = async (err: NuxtError) => {
// 处理响应结果
reject(err)
}
originFetch(request, opts).then(resolve, resolvent)
响应拦截
const resolvent = async (err: NuxtError) => {
if (err.statusCode === 401)
return await refresh(err)
// 处理其余响应结果
reject(err)
}
const refresh = async (err: NuxtError) => {
try {
// 刷新锁定时移入缓存
if (refresh_lock)
return request_cache.add(token => {
setToken(token)
originFetch(request, opts).then(resolve, reject)
})
// 刷新时锁定
refresh_lock = true
if (account.refresh_token) {
const { access } = await account.refreshToken()
const _requestsCache = Array.from(request_cache)
request_cache.clear()
refresh_lock = false
for (const request of _requestsCache) {
request(access.value)
}
setToken(access.value)
// 重新发起请求,错误处理改用reject而不是resolvent
originFetch(request, opts).then(resolve, reject)
} else {
// 如果没有refresh_token直接解锁
refresh_lock = false
reject()
await go_login()
}
} catch (e) {
// 如果出错了则清空缓存并解锁,直接将原始错误返回
request_cache.clear()
refresh_lock = false
reject(err)
}
}