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

1,534 阅读5分钟
// api/request.js

import { createAlova } from 'alova';
import GlobalFetch from 'alova/GlobalFetch';
import { message } from 'ant-design-vue';

const REFRESH_TOKEN_URL = '/api/auth/refresh';
let isRefreshing = false;
// 将 Promise 的 resolve 函数存入队列
// 当 token 刷新成功后,调用这些 resolve 函数
let waitingQueue = [];

// 刷新 token 的函数
const refreshToken = async () => {
  // 利用 Promise 将并发的刷新请求锁定
  if (isRefreshing) {
    return new Promise(resolve => {
      waitingQueue.push(resolve);
    });
  }

  isRefreshing = true;

  try {
    const storedRefreshToken = localStorage.getItem('refreshToken');
    if (!storedRefreshToken) {
      throw new Error('没有刷新令牌');
    }

    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: storedRefreshToken })
    });

    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(true)); // 传递 true 表示成功
      return true;
    } else {
      throw new Error(result.message || '刷新令牌失败');
    }
  } catch (error) {
    // 刷新失败,通知所有等待的请求
    waitingQueue.forEach(resolve => resolve(false)); // 传递 false 表示失败

    // 清理并跳转到登录页
    localStorage.removeItem('token');
    localStorage.removeItem('refreshToken');
    window.location.href = '/login';
    return Promise.reject(error);
  } finally {
    // 重置状态
    isRefreshing = false;
    waitingQueue = [];
  }
};

// 创建 alova 实例
const alovaInstance = createAlova({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'https://api.example.com',
  requestAdapter: GlobalFetch(),

  beforeRequest: method => {
    // alova 中 method.config 是请求配置
    const token = localStorage.getItem('token');
    method.config.headers = {
      ...method.config.headers,
      'Content-Type': 'application/json',
      Accept: 'application/json',
      ...(token ? { 'Authorization': `Bearer ${token}` } : {})
    };
    console.log('请求配置:', method.config);
  },

  responsed: {
    // 修改:第二个参数是 method 实例,可以直接用它来重发请求
    onSuccess: async (response, method) => {
      const json = await response.json();
      console.log('响应数据:', json);

      if (json.code === 200 || json.code === 0) {
        return json.data;
      } else if (json.code === 401) {
        // 如果是刷新 token 的接口自己返回了 401,则直接跳转登录页,防止死循环
        if (method.config.url.includes(REFRESH_TOKEN_URL)) {
            message.error('登录已过期,请重新登录');
            localStorage.removeItem('token');
            localStorage.removeItem('refreshToken');
            window.location.href = '/login';
            return Promise.reject(new Error('刷新令牌失败'));
        }

        // 尝试刷新 token
        const refreshSuccess = await refreshToken();

        if (refreshSuccess) {
          // 刷新成功后,使用 method.send() 重新发送原始请求
          // aolva 会自动携带新的 token (因为 beforeRequest 会再次执行)
          // 并且重试的请求会再次进入此响应拦截器
          return method.send();
        } else {
          // 刷新失败,Promise 会在 refreshToken 函数内部被 reject
          // 这里可以抛出一个错误,防止继续执行
          return Promise.reject(new Error("Token refresh failed"));
        }
      } else {
        // 其他业务错误
        message.error(json.message || '请求失败');
        return Promise.reject(new Error(json.message || '请求失败'));
      }
    },

    onError: (error, method) => {
      console.error('请求错误:', error);
      // 网络错误等会进入这里
      message.error(error.message || '网络错误,请稍后重试');
      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