【Nuxt系列二十一】封装$fetch实现双token刷新

642 阅读1分钟

在很多场景中,nuxt中的$fetch都能够满足使用需求。但如果有些特殊场景,例如根据返回的业务编码进行响应拦截、重试的话,就ofetch就不太可以了。

场景说明

ofetch原生就支持了onResponseErroronRequestError这两个拦截器。

特别是onResponseError,当response.ok不为true的时候,就会执行这个方法。似乎配合retryStatusCodes,我们就可以很方便的实现以下场景

  1. 客户端带着accessToken去请求/get?id=1
  2. 服务端校验accessToken,发现过期了,设置状态码(HTTP status code) 为 401
  3. ofetch解析响应的时候,发现error,触发了 onResponseError
  4. onResponseError内,利用refreshToken获取新的accessToken,并且设置到缓存中
  5. auto retry的相关配置生效,发现401,便自动重试
  6. 客户端带着新的accessToken去获取数据

这个流程简直天衣无缝,非常完美。但是他忽略了一种情况,就是下图

image.png

没错,很多项目会将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
        }
    }
})