封装fetch请求 带401请求重试

js 复制代码
import { getReToken, getToken, setReToken, setToken, removeReToken, removeToken } from '@/utils/auth';
import useEnterStore from '@/store/modules/enterprise';
import { refreshToken } from '@/api/login';

// --- 状态和配置  ---
let isRefreshing = ref(false);
let failedQueue = [];

// --- 队列处理器 ---
const processQueue = (error, token = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  failedQueue = [];
};

// --- 核心 fetch 封装 ---
async function fetchWithAuth(url, options = {}) {
  const enterStore = useEnterStore();
  // 1. 获取 token 并添加到请求头
  const headers = new Headers(options.headers || {});
  if (getToken()) {
    headers.set('Authorization', `Bearer ${getToken()}`);
  }
  options.headers = headers;
  // 2. 发起原始请求
  const response = await fetch(url, options);
  // 3. 检查是否是 token 过期错误 (401)
  if (response.status === 401) {
    if (isRefreshing.value) {
      // 如果已经在刷新 token,则将当前请求加入队列等待
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      }).then((newToken) => {
        // 等待刷新成功后,用新 token 重试
        headers.set('Authorization', `Bearer ${newToken}`);
        options.headers = headers;
        return fetch(url, options);
      });
    }
    isRefreshing.value = true;
    try {
      if (!getReToken()) {
        // 换取新token失败,跳转到登出页面
        location.href = '/app/mobile/logout';
        return Promise.reject(new Error('Session expired. No refresh token.'));
      }
      // 4. 发起刷新 token 的请求
      let res = await refreshToken(enterStore.enterInfo.authAddress, enterStore.enterInfo.id, enterStore.enterInfo.clientId);
      const { access_token, expires_in, refresh_token, refresh_expires_in } = res;
      // 5. 更新 token 并处理等待队列
      setToken(access_token, expires_in);
      setReToken(refresh_token, refresh_expires_in);
      processQueue(null, access_token);
      // 6. 用新 token 重试原始请求
      headers.set('Authorization', `Bearer ${access_token}`);
      options.headers = headers;
      return fetch(url, options);
    } catch (error) {
      // 刷新失败,登出并拒绝所有等待的请求
      processQueue(error, null);
      location.href = '/app/mobile/logout';
      return Promise.reject(error);
    } finally {
      isRefreshing.value = false;
    }
  }
  // 如果不是 401 错误,直接返回响应
  return response;
}

// --- 导出的 Composable 函数 ---

export function useApi() {
  // 返回一个可以直接使用的 fetch 实例
  return {
    apiFetch: fetchWithAuth,
  };
}

关键代码块详解

1. 状态和队列 (isRefreshing, failedQueue)

JavaScript

ini 复制代码
// --- 状态和配置  ---
let isRefreshing = ref(false);
let failedQueue = [];
  • isRefreshing: 这是一个"锁"或"标志位"。它的作用是告诉整个应用:"当前是否已经有请求正在刷新 token?"。ref(false) 意味着它是一个响应式变量,虽然在这里主要用于逻辑判断。
  • failedQueue: 这是一个"等待队列"。当 isRefreshingtrue 时,所有后续失败的请求都会被暂时存放到这个队列里,等待被"唤醒"。

2. 队列处理器 (processQueue)

JavaScript

javascript 复制代码
// --- 队列处理器 ---
const processQueue = (error, token = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error); // 如果刷新失败,就通知所有等待的请求:你们也失败了
    } else {
      prom.resolve(token); // 如果刷新成功,就用新 token "唤醒"所有等待的请求
    }
  });
  failedQueue = []; // 处理完后清空队列
};

这个函数是 failedQueue 的配套工具。它的职责是在 token 刷新操作结束后(无论成功或失败),去处理所有在队列中等待的请求。

3. 核心请求函数 (fetchWithAuth)

  • 步骤 1 & 2: 正常请求

    JavaScript

    javascript 复制代码
    const headers = new Headers(options.headers || {});
    if (getToken()) {
      headers.set('Authorization', `Bearer ${getToken()}`);
    }
    // ...
    const response = await fetch(url, options);

    这部分很简单:为请求附加 Authorization 头,然后正常发送。

  • 步骤 3: 拦截 401 错误

    JavaScript

    ini 复制代码
    if (response.status === 401) {
      // ...
    }

    这是整个逻辑的触发点。

  • 并发处理逻辑

    JavaScript

    javascript 复制代码
    if (isRefreshing.value) {
      // 如果已经在刷新 token,则将当前请求加入队列等待
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      }).then((newToken) => {
        // ...用新 token 重试
      });
    }
    isRefreshing.value = true; // 第一个失败的请求,加锁!
    • 当一个请求收到 401 时,它首先检查 isRefreshing
    • 如果为 true,说明"有人已经在路上去拿新钥匙了",于是它就通过 new Promise把自己挂起,并将 resolvereject 函数存入 failedQueue,然后安静地等待。
    • 如果为 false,说明它是第一个发现问题的请求。它会立刻把 isRefreshing 设为 true,相当于"锁门",防止其他人重复去拿钥匙。然后它继续往下执行刷新 token 的逻辑。
  • 步骤 4: 刷新 Token

    JavaScript

    csharp 复制代码
    try {
      // ...
      let res = await refreshToken(...);
      // ...
    } catch (error) {
      // ...
    } finally {
      isRefreshing.value = false; // 无论成功失败,最后都要"开锁"
    }
    • 这个 try...catch...finally 结构非常健壮。
    • 它调用 refreshToken API。如果成功,就继续往下走。
    • 如果 refreshToken 本身也失败了(例如 refresh token 也过期了),catch 块会执行,处理所有等待的请求(通知它们失败),然后强制用户登出。
    • finally 确保了在所有操作结束后,isRefreshing 这个"锁"一定会被重置为 false,以便下次能正常工作。
  • 步骤 5 & 6: 更新与重试

    JavaScript

    scss 复制代码
    // 5. 更新 token 并处理等待队列
    setToken(access_token, expires_in);
    setReToken(refresh_token, refresh_expires_in);
    processQueue(null, access_token); // "唤醒"所有等待的请求
    
    // 6. 用新 token 重试原始请求
    headers.set('Authorization', `Bearer ${access_token}`);
    return fetch(url, options);
    • 刷新成功后,它会用新的 token 更新本地存储。
    • 调用 processQueue,把新的 access_token 传递给所有在队列里等待的 Promise,触发它们的 .then((newToken) => ...) 逻辑,让它们也用新 token 重新发起请求。
    • 最后,这个"领头"的请求自己也用新 token 重新发起了一次它最初失败的请求。
相关推荐
原则猫5 小时前
HOOKS 背后机制
前端
码语智行5 小时前
首页导航跳转功能深度解析-系统内和系统外
前端
阿猫的故乡5 小时前
Vue过渡动画从入门到装X:淡入淡出、滑动、列表动画、第三方库全搞定
前端·javascript·vue.js
IManiy6 小时前
总结之Vibe Coding前端骨架
前端
JS菌6 小时前
AI Agent 沙箱双层防护体系:从权限过滤到内核隔离的完整实现
前端·人工智能·后端
Aphasia3116 小时前
从输入URL到页面展示全流程
前端·面试
我叫黑大帅6 小时前
前端如何竖屏固定视口背景
前端·javascript·面试
abcy0712136 小时前
python pandas csv异步后台清洗前端优先返回成功信息
前端·python·pandas
IT_陈寒7 小时前
Vite这个坑我帮你踩了,动态导入居然这样才生效
前端·人工智能·后端
swipe7 小时前
Mem0 x Agent 实战系列:分层记忆 + 三路召回,搭建真正可用的长期记忆层
前端·javascript·面试