Alova 二次封装(带 401 自动刷新 Token)

34 阅读5分钟
// api/request.js

import { createAlova } from 'alova';
import GlobalFetch from 'alova/GlobalFetch';
import { message } from 'ant-design-vue'; // 假设使用 Ant Design Vue 的消息提示

// 刷新 Token 的接口地址
const REFRESH_TOKEN_URL = '/api/auth/refresh';

// 是否正在刷新 token 的标记
let isRefreshing = false;
// 等待刷新 token 的请求队列
let waitingQueue = [];

// 刷新 token 的函数
const refreshToken = async () => {
  // 防止多次刷新
  if (isRefreshing) {
    return new Promise((resolve) => {
      // 将请求加入队列
      waitingQueue.push(resolve);
    });
  }
  
  isRefreshing = true;
  
  try {
    // 获取刷新 token
    const refreshToken = localStorage.getItem('refreshToken');
    if (!refreshToken) {
      throw new Error('没有刷新令牌');
    }
    
    // 发起刷新 token 请求
    const response = await fetch(import.meta.env.VITE_API_BASE_URL + REFRESH_TOKEN_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ refreshToken })
    });
    
    const result = await response.json();
    
    if (result.code === 200 || result.code === 0) {
      // 更新存储的 token
      localStorage.setItem('token', result.data.token);
      localStorage.setItem('refreshToken', result.data.refreshToken);
      
      // 执行队列中的请求
      waitingQueue.forEach(resolve => resolve(result.data.token));
      waitingQueue = [];
      
      return result.data.token;
    } else {
      throw new Error(result.message || '刷新令牌失败');
    }
  } catch (error) {
    // 刷新失败,清空队列并返回失败
    waitingQueue.forEach(resolve => resolve(null));
    waitingQueue = [];
    
    // 清除登录状态
    localStorage.removeItem('token');
    localStorage.removeItem('refreshToken');
    
    // 跳转到登录页
    window.location.href = '/login';
    
    throw error;
  } finally {
    isRefreshing = false;
  }
};

// 创建 alova 实例
const alovaInstance = createAlova({
  // 请求基础路径
  baseURL: import.meta.env.VITE_API_BASE_URL || 'https://api.example.com',
  
  // 请求适配器
  requestAdapter: GlobalFetch(),
  
  // 全局的请求拦截器
  beforeRequest: (config) => {
    // 获取 token
    const token = localStorage.getItem('token');
    
    // 设置请求头
    config.headers = {
      ...config.headers,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      // 如果有 token,添加到请求头
      ...(token ? { 'Authorization': `Bearer ${token}` } : {})
    };
    
    // 请求日志
    console.log('请求配置:', config);
    
    return config;
  },
  
  // 全局的响应拦截器
  responsed: {
    // 请求成功的拦截器
    onSuccess: async (response, method, config) => {
      const json = await response.json();
      
      // 打印响应数据
      console.log('响应数据:', json);
      
      // 约定的接口响应格式: { code: number, data: any, message: string }
      if (json.code === 200 || json.code === 0) {
        // 成功响应,返回数据部分
        return json.data;
      } else if (json.code === 401) {
        // 未授权,尝试刷新 token
        try {
          // 如果不是刷新 token 的请求,则尝试刷新 token
          if (!config.url.includes(REFRESH_TOKEN_URL)) {
            // 刷新 token
            const newToken = await refreshToken();
            
            if (newToken) {
              // 使用新 token 重新发起原请求
              const newConfig = { ...config };
              newConfig.headers = {
                ...newConfig.headers,
                'Authorization': `Bearer ${newToken}`
              };
              
              // 重新发起请求
              const retryResponse = await fetch(newConfig.url, {
                method: newConfig.method,
                headers: newConfig.headers,
                body: newConfig.data ? JSON.stringify(newConfig.data) : undefined
              });
              
              const retryJson = await retryResponse.json();
              
              if (retryJson.code === 200 || retryJson.code === 0) {
                return retryJson.data;
              } else {
                message.error(retryJson.message || '请求失败');
                return Promise.reject(new Error(retryJson.message || '请求失败'));
              }
            }
          }
          
          // 如果是刷新 token 的请求失败,或者刷新 token 后重试仍然失败
          message.error('登录已过期,请重新登录');
          localStorage.removeItem('token');
          localStorage.removeItem('refreshToken');
          window.location.href = '/login';
          return Promise.reject(new Error(json.message || '登录已过期'));
        } catch (error) {
          console.error('刷新 token 失败:', error);
          message.error('登录已过期,请重新登录');
          return Promise.reject(error);
        }
      } else {
        // 其他业务错误
        message.error(json.message || '请求失败');
        return Promise.reject(new Error(json.message || '请求失败'));
      }
    },
    
    // 请求失败的拦截器
    onError: (error, method) => {
      console.error('请求错误:', error);
      message.error('网络错误,请稍后重试');
      return Promise.reject(error);
    }
  },
  
  // 请求超时时间,单位为毫秒
  timeout: 10000,
});

// 导出实例
export default alovaInstance;

// api/modules/auth.js
// 认证相关接口封装

import alovaInstance from '../request';

export const authApi = {
  // 用户登录
  login: (data) => {
    return alovaInstance.Post('/api/auth/login', data);
  },
  
  // 手动刷新 token(通常不需要直接调用,由拦截器自动处理)
  refreshToken: (refreshToken) => {
    return alovaInstance.Post('/api/auth/refresh', { refreshToken });
  },
  
  // 用户登出
  logout: () => {
    return alovaInstance.Post('/api/auth/logout');
  }
};

// 登录组件示例
<script setup>
import { ref } from 'vue';
import { authApi } from '@/api';
import { useRouter } from 'vue-router';

const router = useRouter();
const username = ref('');
const password = ref('');
const loading = ref(false);

const handleLogin = async () => {
  if (!username.value || !password.value) {
    return;
  }
  
  loading.value = true;
  try {
    const result = await authApi.login({
      username: username.value,
      password: password.value
    });
    
    // 存储 token 和 refreshToken
    localStorage.setItem('token', result.token);
    localStorage.setItem('refreshToken', result.refreshToken);
    
    // 登录成功,跳转到首页
    router.push('/dashboard');
  } catch (error) {
    console.error('登录失败:', error);
  } finally {
    loading.value = false;
  }
};
</script>

主要功能说明

1. Token 刷新机制

  1. Token 刷新功能

    • 当接口返回 401 时,自动尝试刷新 Token
    • 使用 refreshToken 调用刷新接口获取新的 Token
    • 刷新成功后,使用新 Token 重新发起原请求
  2. 并发请求处理

    • 使用 isRefreshing 标记防止多个请求同时触发刷新
    • 使用 waitingQueue 队列存储等待 Token 刷新的请求
    • Token 刷新成功后,自动执行队列中的所有请求
  3. 异常处理

    • 如果 Token 刷新失败,会清除登录状态并跳转到登录页
    • 如果刷新后的重试请求仍然失败,会返回错误信息

2. 使用说明

  1. 登录时需要保存两个 Token

    • token:用于日常接口认证
    • refreshToken:用于刷新获取新的 token
  2. 刷新 Token 接口约定

    • 接口地址:/api/auth/refresh
    • 请求参数:{ refreshToken: 'xxx' }
    • 返回格式:{ code: 0, data: { token: 'xxx', refreshToken: 'xxx' }, message: 'success' }
  3. 自动处理流程

    • 接口返回 401 → 尝试刷新 Token → 刷新成功 → 重新发起原请求 → 返回结果
    • 接口返回 401 → 尝试刷新 Token → 刷新失败 → 跳转登录页

这种方式可以让用户在 Token 过期时无感知地继续操作,大大提升用户体验。

这段代码确实是比较巧妙的并发处理机制,我来详细解释一下:

详细解释

当多个请求同时遇到 401 错误时,我们只希望发起一次 token 刷新请求,而不是每个请求都去刷新 token。这段代码的目的就是实现这一点。

// 防止多次刷新
if (isRefreshing) {
  return new Promise((resolve) => {
    // 将请求加入队列
    waitingQueue.push(resolve);
  });
}

让我们分析一下具体流程:

  1. 假设有多个请求(A、B、C)同时或几乎同时收到 401 错误

  2. 请求 A 先执行到这段代码,此时 isRefreshing = false,所以它会跳过这个 if 语句,继续执行下面的代码去刷新 token,并设置 isRefreshing = true

  3. 请求 B 执行到这段代码时,发现 isRefreshing = true(因为请求 A 已经在刷新 token),所以它会进入 if 语句

  4. 在 if 语句中,创建了一个新的 Promise,并将这个 Promise 的 resolve 函数添加到 waitingQueue 队列中

  5. 这个 Promise 会一直处于 pending 状态,直到有人调用它的 resolve 函数

  6. 当请求 A 成功刷新 token 后,会遍历 waitingQueue 队列,调用里面的每个 resolve 函数,并传入新的 token:

    waitingQueue.forEach(resolve => resolve(result.data.token));
    
  7. 这时,请求 B 的 Promise 状态从 pending 变为 fulfilled,它可以继续执行后续代码,使用新的 token 重新发起请求

简单来说

  • waitingQueue.push(resolve) 添加的是一个函数,这个函数可以让当前 Promise 从等待状态变为完成状态
  • 当第一个请求成功刷新 token 后,会调用队列中所有的这些函数,告诉其他等待的请求:"token 已经刷新好了,这是新 token,你们可以继续了"
  • 其他请求拿到新 token 后,就可以重新发起原来的请求,而不需要自己再去刷新 token