Nuxt3的$fetch拦截

2,889 阅读2分钟

记录对$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选项进行修复捏?

  • ofetchparams选项的定位是和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)
    }
}

完整代码块