前端无感刷新token实现方案(附源码)

44 阅读2分钟

一、方案概述

无感刷新Token的核心原理是在用户Token过期前,自动使用Refresh Token获取新的Access Token,确保用户操作不中断。本方案包含完整的Token管理、请求拦截、刷新机制和错误处理。

二、完整代码实现

1. Token管理类 (tokenManager.js)

/**
 * Token管理器
 * 负责Token的存储、获取和刷新
 */
class TokenManager {
  constructor() {
    this.accessTokenKey = 'access_token';
    this.refreshTokenKey = 'refresh_token';
    this.tokenExpiryKey = 'token_expiry';
  }

  /**
   * 设置Token
   * @param {Object} tokenData - Token数据
   */
  setTokens(tokenData) {
    const { access_token, refresh_token, expires_in } = tokenData;
    
    // 计算Token过期时间(提前5分钟刷新)
    const expiryTime = Date.now() + (expires_in - 300) * 1000;
    
    localStorage.setItem(this.accessTokenKey, access_token);
    localStorage.setItem(this.refreshTokenKey, refresh_token);
    localStorage.setItem(this.tokenExpiryKey, expiryTime.toString());
  }

  /**
   * 获取Access Token
   * @returns {string|null}
   */
  getAccessToken() {
    return localStorage.getItem(this.accessTokenKey);
  }

  /**
   * 获取Refresh Token
   * @returns {string|null}
   */
  getRefreshToken() {
    return localStorage.getItem(this.refreshTokenKey);
  }

  /**
   * 清除所有Token
   */
  clearTokens() {
    localStorage.removeItem(this.accessTokenKey);
    localStorage.removeItem(this.refreshTokenKey);
    localStorage.removeItem(this.tokenExpiryKey);
  }

  /**
   * 检查Token是否即将过期
   * @returns {boolean}
   */
  isTokenExpiring() {
    const expiryTime = localStorage.getItem(this.tokenExpiryKey);
    if (!expiryTime) return true;
    
    return Date.now() >= parseInt(expiryTime);
  }

  /**
   * 检查是否已登录
   * @returns {boolean}
   */
  isLoggedIn() {
    return !!this.getAccessToken() && !!this.getRefreshToken();
  }
}

export const tokenManager = new TokenManager();

2. 请求拦截器封装 (httpClient.js)

import axios from 'axios';
import { tokenManager } from './tokenManager';
import { refreshToken } from './authService';

/**
 * 创建axios实例
 */
const httpClient = axios.create({
  baseURL: process.env.REACT_APP_API_BASE_URL || '/api',
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// 请求队列,用于处理刷新Token时的请求排队
let isRefreshing = false;
let failedQueue = [];

/**
 * 处理队列中的失败请求
 * @param {string} token - 新的Access Token
 */
const processQueue = (error, token = null) => {
  failedQueue.forEach(prom => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  failedQueue = [];
};

/**
 * 请求拦截器
 */
httpClient.interceptors.request.use(
  async (config) => {
    const token = tokenManager.getAccessToken();
    
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    
    // 如果Token即将过期,且不是刷新Token的请求,尝试刷新
    if (tokenManager.isTokenExpiring() && 
        !config.url.includes('/refresh-token') &&
        !config._retry) {
      
      if (!isRefreshing) {
        isRefreshing = true;
        config._retry = true;
        
        try {
          const newToken = await refreshToken();
          tokenManager.setTokens(newToken);
          
          // 更新当前请求的Authorization头
          config.headers.Authorization = `Bearer ${newToken.access_token}`;
          
          // 处理等待队列
          processQueue(null, newToken.access_token);
          isRefreshing = false;
        } catch (error) {
          // 刷新失败,清除Token并重定向到登录页
          processQueue(error, null);
          tokenManager.clearTokens();
          window.location.href = '/login';
          return Promise.reject(error);
        }
      } else {
        // 如果正在刷新,将请求加入队列
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then(token => {
          config.headers.Authorization = `Bearer ${token}`;
          return config;
        }).catch(err => {
          return Promise.reject(err);
        });
      }
    }
    
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

/**
 * 响应拦截器
 */
httpClient.interceptors.response.use(
  (response) => {
    return response;
  },
  async (error) => {
    const originalRequest = error.config;
    
    // 处理401错误(Token过期)
    if (error.response?.status === 401 && !originalRequest._retry) {
      
      // 如果是刷新Token接口返回401,直接跳转登录
      if (originalRequest.url.includes('/refresh-token')) {
        tokenManager.clearTokens();
        window.location.href = '/login';
        return Promise.reject(error);
      }
      
      originalRequest._retry = true;
      
      // 如果已经在刷新,将请求加入队列
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then(token => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return httpClient(originalRequest);
        }).catch(err => {
          return Promise.reject(err);
        });
      }
      
      isRefreshing = true;
      
      try {
        const newToken = await refreshToken();
        tokenManager.setTokens(newToken);
        
        // 更新当前请求的Authorization头
        originalRequest.headers.Authorization = `Bearer ${newToken.access_token}`;
        
        // 处理等待队列
        processQueue(null, newToken.access_token);
        
        // 重新发起原始请求
        return httpClient(originalRequest);
      } catch (refreshError) {
        // 刷新失败,清除Token并跳转登录
        processQueue(refreshError, null);
        tokenManager.clearTokens();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }
    
    return Promise.reject(error);
  }
);

export default httpClient;

3. 认证服务 (authService.js)

import httpClient from './httpClient';
import { tokenManager } from './tokenManager';

/**
 * 认证服务
 */
class AuthService {
  /**
   * 用户登录
   * @param {string} username - 用户名
   * @param {string} password - 密码
   * @returns {Promise}
   */
  async login(username, password) {
    try {
      const response = await httpClient.post('/auth/login', {
        username,
        password,
      });
      
      const tokenData = response.data;
      tokenManager.setTokens(tokenData);
      
      return tokenData;
    } catch (error) {
      throw this.handleAuthError(error);
    }
  }
  
  /**
   * 刷新Token
   * @returns {Promise}
   */
  async refreshToken() {
    const refreshToken = tokenManager.getRefreshToken();
    
    if (!refreshToken) {
      throw new Error('No refresh token available');
    }
    
    try {
      const response = await httpClient.post('/auth/refresh-token', {
        refresh_token: refreshToken,
      }, {
        _retry: true, // 标记为刷新Token请求
      });
      
      return response.data;
    } catch (error) {
      throw this.handleAuthError(error);
    }
  }
  
  /**
   * 用户注销
   */
  logout() {
    // 调用后端注销接口(可选)
    httpClient.post('/auth/logout').catch(() => {});
    
    // 清除本地Token
    tokenManager.clearTokens();
    
    // 跳转到登录页
    window.location.href = '/login';
  }
  
  /**
   * 获取当前用户信息
   * @returns {Promise}
   */
  async getCurrentUser() {
    try {
      const response = await httpClient.get('/auth/me');
      return response.data;
    } catch (error) {
      throw this.handleAuthError(error);
    }
  }
  
  /**
   * 处理认证错误
   * @param {Error} error - 错误对象
   * @returns {Error}
   */
  handleAuthError(error) {
    if (error.response?.status === 401) {
      tokenManager.clearTokens();
      window.location.href = '/login';
    }
    return error;
  }
}

export const authService = new AuthService();
export const refreshToken = () => authService.refreshToken();

4. React Hook 使用示例 (useAuth.js 其他框架原理相同)

import { useState, useEffect, useCallback } from 'react';
import { authService, tokenManager } from './authService';

/**
 * 认证Hook
 */
export function useAuth() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  /**
   * 登录
   */
  const login = useCallback(async (username, password) => {
    try {
      setLoading(true);
      setError(null);
      await authService.login(username, password);
      await loadUser();
    } catch (err) {
      setError(err.message || '登录失败');
      throw err;
    } finally {
      setLoading(false);
    }
  }, []);

  /**
   * 注销
   */
  const logout = useCallback(() => {
    authService.logout();
    setUser(null);
  }, []);

  /**
   * 加载用户信息
   */
  const loadUser = useCallback(async () => {
    if (!tokenManager.isLoggedIn()) {
      setLoading(false);
      return;
    }

    try {
      const userData = await authService.getCurrentUser();
      setUser(userData);
      setError(null);
    } catch (err) {
      setError(err.message);
      setUser(null);
    } finally {
      setLoading(false);
    }
  }, []);

  // 初始化加载用户信息
  useEffect(() => {
    loadUser();
  }, [loadUser]);

  return {
    user,
    loading,
    error,
    login,
    logout,
    isAuthenticated: !!user,
    reloadUser: loadUser,
  };
}

5. 全局配置和初始化 (app.js 其他框架原理相同)

import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { tokenManager } from './utils/tokenManager';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';

/**
 * 保护路由组件
 */
function PrivateRoute({ children }) {
  const { isAuthenticated, loading } = useAuth();
  
  if (loading) {
    return <div className="loading">加载中...</div>;
  }
  
  return isAuthenticated ? children : <Navigate to="/login" />;
}

/**
 * 应用主组件
 */
function App() {
  // 监听页面可见性变化,当页面从隐藏变为可见时检查Token
  useEffect(() => {
    const handleVisibilityChange = () => {
      if (!document.hidden && tokenManager.isLoggedIn()) {
        // 页面变为可见时,检查Token是否需要刷新
        if (tokenManager.isTokenExpiring()) {
          // 触发Token刷新
          authService.refreshToken().catch(() => {
            // 刷新失败可忽略,下次请求时会处理
          });
        }
      }
    };

    document.addEventListener('visibilitychange', handleVisibilityChange);
    
    return () => {
      document.removeEventListener('visibilitychange', handleVisibilityChange);
    };
  }, []);

  return (
    <AuthProvider>
      <Router>
        <Routes>
          <Route path="/login" element={<Login />} />
          <Route
            path="/dashboard/*"
            element={
              <PrivateRoute>
                <Dashboard />
              </PrivateRoute>
            }
          />
          <Route path="/" element={<Navigate to="/dashboard" />} />
        </Routes>
      </Router>
    </AuthProvider>
  );
}

export default App;

6. API接口使用示例 (userService.js)

import httpClient from './httpClient';

/**
 * 用户服务示例
 */
class UserService {
  /**
   * 获取用户列表
   */
  async getUsers(params = {}) {
    try {
      const response = await httpClient.get('/users', { params });
      return response.data;
    } catch (error) {
      this.handleRequestError(error);
    }
  }

  /**
   * 更新用户信息
   */
  async updateUser(userId, data) {
    try {
      const response = await httpClient.put(`/users/${userId}`, data);
      return response.data;
    } catch (error) {
      this.handleRequestError(error);
    }
  }

  /**
   * 处理请求错误
   */
  handleRequestError(error) {
    // 这里可以添加自定义的错误处理逻辑
    if (error.response?.status === 403) {
      // 权限不足
      throw new Error('权限不足,请联系管理员');
    }
    
    throw error;
  }
}

export const userService = new UserService();

三、说明

  1. 请求队列机制 当Token正在刷新时,新的请求会进入队列等待 Token刷新成功后,队列中的请求会使用新Token重新发起 防止并发刷新请求

  2. 提前刷新策略 Token过期前5分钟开始刷新 减少用户等待时间

  3. 页面可见性检测 当页面从后台切回前台时自动检查Token状态 保持Token始终有效

  4. 错误处理 401错误自动处理 刷新失败自动跳转登录页 提供友好的错误提示

  5. 安全性考虑 Refresh Token存储在安全的地方 支持服务端注销Refresh Token 防止Token泄露

四、注意事项

  1. 存储安全:生产环境中建议考虑更安全的存储方式,如httpOnly Cookie

  2. 并发控制:确保同一时间只有一个刷新请求

  3. 错误降级:网络异常时的降级处理

  4. 监控告警:Token刷新失败的监控和告警

  5. 跨域支持:确保后端支持CORS配置

在这里插入图片描述