Stories

Detail Return Return

axios雙Token無痛刷新,解決隊列請求、歷史請求問題 - Stories Detail

token無痛刷新機制主要是由一個accessToken和一個refreshToken實現的,請求接口的時候使用accessToken,一旦accessToken過期,立刻用refreshToken請求刷新token接口,拿到accessTokenrefreshToken存起來,然後使用accessToken請求接口。
這其中有幾個點需要注意:

  1. 當前過期的token如何處理?
  2. 過期token刷新後,當前失敗的接口怎麼處理?
  3. 在刷新token的過程中,新進來的請求如何處理?
  4. 如果refreshToken也過期了,如何處理?

帶着這幾個問題開始解決
這裏我先展示token的操作文件,本文會用到
這裏展示一個測試目錄結構:

api.ts——api接口文件

import request from "@/views/test/request";

// 登錄
export const getTestTokenAPI = () => {
  return request({
    url: "/mock/test/getTestToken",
    method: "get"
  });
};

// 刷新token
export const getTestTokenRefreshAPI = (data: any) => {
  return request({
    url: "/mock/test/getTestTokenRefresh",
    method: "post",
    data,
    _isRefreshToken: true // 添加自定義屬性,標記當前接口為刷新token接口
  } as any);
};

// 其它請求
export const getTestRequestAPI = (params: any) => {
  return request({
    url: "/mock/test/getTestRequest",
    method: "get",
    params
  });
};

在請求的時候添加的自定義屬性,如:_isRefreshToken, 在axios的響應攔截器的config屬性裏可以獲取到,例如:response.config._isRefreshToken

auth.ts——token的獲取和設置文件

import { useLocalStorage } from "@vueuse/core";

// https://vueuse.org.cn/core/uselocalstorage/
// 響應式 LocalStorage
const accessToken = useLocalStorage("accessToken", "");
const refreshToken = useLocalStorage("refreshToken", "");

// accessToken是否存在
function isAccessToken() {
  return !!accessToken.value;
}

// refreshToken是否存在
function isRefreshToken() {
  return !!refreshToken.value;
}

// 獲取accessToken
function getAccessToken() {
  return accessToken.value;
}

// 獲取refreshToken
function getRefreshToken() {
  return refreshToken.value;
}

// 設置雙token
function setToken(newToken: { accessToken: string; refreshToken: string }) {
  accessToken.value = newToken.accessToken;
  refreshToken.value = newToken.refreshToken;
}

// 清空雙token
function clearToken() {
  accessToken.value = "";
  refreshToken.value = "";
}

export { isAccessToken, isRefreshToken, getAccessToken, getRefreshToken, setToken, clearToken };

refreshToken.ts——刷新token的邏輯處理文件

import service from "@/views/test/request";
import { getTestTokenRefreshAPI } from "@/views/test/api";
import { setToken, getRefreshToken } from "@/views/test/auth";

// 請求隊列
let failedQueue: any[] = [];

/**
 * 刷新token
 * 該函數會調用刷新token的接口,刷新成功後會將新的token設置到localStorage中
 */
export async function refreshToken() {
  // 調用刷新token接口
  let res = await getTestTokenRefreshAPI({ refreshToken: getRefreshToken() });
  let params = {
    accessToken: res.data.accessToken,
    refreshToken: res.data.refreshToken
  };
  setToken(params);
}

/**
 * 將請求添加到隊列
 * @param response 需要添加的請求
 * @returns {Promise<any>} 隊列中的請求結果
 */
export function addRequestToQueue(response: any) {
  return new Promise((resolve, reject) => {
    failedQueue.push({
      resolve: async (newToken: string) => {
        // 用新Token重試請求
        response.config.headers["Authorization"] = "Bearer " + newToken;
        let res = await service.request(response.config);
        resolve(res);
      },
      reject: (err: any) => reject(err)
    });
  });
}

/**
 * 請求隊列中的接口,只有在token刷新成功或失敗後才會調用
 * 刷新失敗的調用方式:processQueue(error)
 * 刷新成功的調用方式:processQueue(null, newToken)
 * @param error 刷新失敗的錯誤結果
 * @param token 刷新成功的token
 */
export function processQueue(error: any | null, token: string | null = null) {
  // 遍歷所有隊列的接口
  // 如果刷新成功則注入新token,否則直接reject錯誤信息
  failedQueue.forEach(promise => {
    if (error) {
      // 刷新token失敗,直接reject錯誤信息
      promise.reject(error);
    } else if (token) {
      // 調用resolve回調
      promise.resolve(token);
    }
  });
  // 最後清空隊列
  failedQueue = [];
}

/**
 * 清空隊列
 */
export function resetQueue() {
  failedQueue = [];
}

這裏的核心主要是添加隊列和請求隊列接口的邏輯
addRequestToQueue函數為添加隊列邏輯,failedQueue其實是push了一個對象進去,該對象包含了請求邏輯

{
  resolve: async (newToken: string) => {/* 重試邏輯 */},
  reject: (err: any) => reject(err)
}

resolve函數:接收新token,更新請求頭並重試接口
reject函數:直接傳遞錯誤信息
而且函數內部的resolve(res)reject(err)並非創建新的Promise,而是引用外層Promise的狀態控制器
這種設計實現了請求狀態的延遲控制:

  1. 當token過期時,先將請求邏輯存入隊列
  2. 等待token刷新完成後,通過調用隊列中對象的resolve/reject方法
  3. 最終決定外層Promise的狀態(成功/失敗)

調用processQueue函數,根據刷新token的狀態判斷調用隊列裏成功或失敗的回調。

request.ts——封裝axios的基本文件

import axios from "axios";

const service = axios.create({
  baseURL: ""
});

// 請求攔截器
service.interceptors.request.use(
  function (config: any) {
    console.log("請求信息",config)
    return config;
  },
  function (error: any) {
    console.log("請求報錯", error);
    return Promise.reject(error);
  }
);

// 響應攔截器
service.interceptors.response.use(
  async function (response: any) {
    console.log("響應成功", response);
    return Promise.resolve(error);
  },
  function (error: any) {
    console.log("響應報錯", error);
    return Promise.reject(error);
  }
);

export default service;

request.ts——修改axios基本文件,加入雙token刷新機制流程

import axios from "axios";
import { getAccessToken, getRefreshToken, clearToken } from "@/views/test/auth";
import { refreshToken, addRequestToQueue, processQueue, resetQueue } from "@/views/test/refreshToken";

const service = axios.create({
  baseURL: ""
});

// 是否刷新token中
let isRefreshing = false;

// 請求攔截器
service.interceptors.request.use(
  function (config: any) {
    // 是否為刷新token請求
    if (config._isRefreshToken) {
      // 為刷新token請求,則用refreshToken做請求頭
      let refreshToken = getRefreshToken();
      if (refreshToken) {
        config.headers["Authorization"] = "Bearer " + refreshToken;
      }
    } else {
      // 否則用accessToken做請求頭
      let accessToken = getAccessToken();
      if (accessToken) {
        config.headers["Authorization"] = "Bearer " + accessToken;
      }
    }
    return config;
  },
  function (error: any) {
    console.log("請求報錯", error);
    return Promise.reject(error);
  }
);

// 響應攔截器
service.interceptors.response.use(
  async function (response: any) {
    // 1、將成功的請求放到最開始判斷
    if (response.data.code === 200) {
      return Promise.resolve(response.data);
    }
    // 2、刷新token的請求也報錯了,説明refreshToken也不可用
    if (response.data.code !== 200 && response.config._isRefreshToken) {
      console.log("刷新token也過期了");
      clearToken();
      resetQueue();
      // 可以做其它邏輯處理,比如登錄...
      // 省略其它...
      return Promise.reject(response.data);
    }

    // 3、刷新token中
    if (isRefreshing) {
      // 刷新token過程中新進來的請求全部放隊列中等待
      return addRequestToQueue(response);
    }
    // 4、正常請求的接口進來token過期,並且不是刷新token的請求,而且也沒有在請求刷新token中
    if (response.data.code === 401 && !response.config._isRefreshToken && !isRefreshing) {
      try {
        // 開始刷新Token
        isRefreshing = true;
        // 刷新token,這裏請求刷新接口,又發了一次請求,所以這個請求必須Promise.resolve()返回,否者後面的邏輯不會執行
        await refreshToken();
        // 最初過期接口的重新請求
        response.config.headers["Authorization"] = "Bearer " + getAccessToken();
        const originalResponse = await service.request(response.config);
        // 處理隊列中的請求
        processQueue(null, getAccessToken());
        // 返回請求結果
        return originalResponse;
      } catch (refreshError) {
        // 刷新失敗處理
        console.error("刷新Token失敗:", refreshError);
        // 隊列裏的請求全部拋錯
        processQueue(refreshError);
        // 清除Token
        clearToken();
        // 其他邏輯,比如返回登錄頁...
        // ...
        return Promise.reject(refreshError);
      } finally {
        console.log("刷新結束");
        // 刷新完成
        isRefreshing = false;
      }
    }

    // 5、業務代碼錯誤處理
    console.log("請求失敗,其它code碼處理");
    return Promise.reject(response.data);
  },
  function (error: any) {
    console.log("響應報錯", error);
    return Promise.reject(error);
  }
);
export default service;

1、這段邏輯中,成功的接口先行,因為刷新token的接口也是一個新請求,如果不這樣做,刷新token的結果會被if (isRefreshing) {return addRequestToQueue(response);}截胡,導致sRefreshing狀態無法變更。
2、如果刷新token的接口也token過期,那就證明當前無可用token,可以返回到登錄頁。
3、刷新token中,新進來的接口全部進入隊列緩存,不返回結果,頁面的請求就會暫時等待,知道隊列緩存的接口resolvereject
4、這段邏輯就是刷新token和替換token的邏輯了,這裏面需要注意一點,最初的token過期接口需要優先返回,隊列的接口其次順序返回。
如果刷新token的接口報錯,則隊列裏的接口全部拋錯,之後的邏輯可以根據業務需求編寫,比如清除token返回登錄頁
最後就是其它狀態碼的邏輯處理,根據實際業務編寫。

頁面文件以及mock的接口

最後提供一下頁面文件和mock的接口,可以方便調試

<template>
  <div class="snow-page">
    <div class="snow-inner">
      <a-space>
        <a-button type="primary" @click="getToken">拿token</a-button>
        <a-button type="primary" status="danger" @click="onTokenPast">token過期</a-button>
        <a-button type="primary" status="warning" @click="onTokenRefreshPast">刷新token也過期</a-button>
        <a-button type="outline" @click="onTokenRequest">其它Atoken接口請求</a-button>
      </a-space>
    </div>
  </div>
</template>

<script setup lang="ts">
import { getTestTokenAPI, getTestTokenRefreshAPI, getTestRequestAPI } from "@/views/test/api";
import { setToken, getAccessToken, getRefreshToken } from "@/views/test/auth";

const getToken = async () => {
  let res = await getTestTokenAPI();
  let params = {
    accessToken: res.data.accessToken,
    refreshToken: res.data.refreshToken
  };
  setToken(params);
  console.log("token拿到", res, getAccessToken(), getRefreshToken());
};

const onTokenPast = async () => {
  let params = {
    accessToken: "unknown",
    refreshToken: "refreshToken-2"
  };
  setToken(params);
};

// 刷新token也過期
const onTokenRefreshPast = async () => {
  let res = await getTestTokenRefreshAPI({ refreshToken: getRefreshToken(), past: true });
  console.log("獲取過期刷新token數據", res);
};

let index = 100;
const onTokenRequest = async () => {
  index++;
  let res = await getTestRequestAPI({ index });
  console.log("其它接口請求", res);
};
</script>

API接口

import Mock from "mockjs";
import type { MockMethod } from "vite-plugin-mock";

/** 返回成功數據 */
export const resultSuccess = (data: unknown) => {
  return Mock.mock({
    code: 200,
    data,
    message: "請求成功",
    success: true
  });
};

/** 返回失敗數據 */
export const resultError = (data: unknown, message: string, code = 500) => {
  return Mock.mock({
    code,
    data,
    message,
    success: false
  });
};


export default [
  {
    url: "/mock/test/getTestToken",
    method: "get",
    timeout: 300,
    response: () => {
      let params = {
        accessToken: "accessToken-1",
        refreshToken: "refreshToken-2"
      };
      return resultSuccess(params);
    }
  },
  {
    url: "/mock/test/getTestTokenRefresh",
    method: "post",
    timeout: 3000, // 延遲返回時間
    response: ({ body }: any) => {
      let { refreshToken, past } = body;
      if (past) {
        return resultError(null, "刷新token也過期了", 401);
      } else {
        let params = {
          accessToken: "accessToken-3",
          refreshToken: "refreshToken-4"
        };
        return resultSuccess(params);
      }
    }
  },
  {
    url: "/mock/test/getTestRequest",
    method: "get",
    timeout: 300,
    response: ({ headers, query }: any) => {
      if (headers.authorization === "Bearer unknown") {
        return resultError(null, "token失效", 401);
      } else {
        let params = { text: "其它請求成功", index: query.index };
        return resultSuccess(params);
      }
    }
  }
] as MockMethod[];
user avatar autohometech Avatar
Favorites 1 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.