前端面试:无感刷新Token的深度解析与异常处理

520 阅读4分钟

前端面试:无感刷新Token的深度解析与异常处理

背景回顾

在最近一次前端面试中,面试官提出问题:"如何实现无感刷新Token?” 我回答后进一步提问:“如果Refresh Token失败报错,同时在A页面有10个业务接口也报错了,例如同时10个异常提示,你会怎么处理?"。异常处理没有回答好(当时考虑-拦截器+状态码,实际上异常提示有间隔时间怎么会同时报错呢-短时间触发只会提示一次?而且应该都是401状态码加路由守卫 是能处理好的)。现重新梳理一下,这个问题考察了Token刷新的实现流程,以及并发请求的异常处理。

原理和实践

一、无感刷新Token的实现原理

无感刷新的核心在于双Token机制:通过短时效的Access Token(AT)和长时效的Refresh Token(RT)配合,实现用户无感知的凭证更新。其实现流程分为以下步骤:

  1. 登录阶段

    • 用户输入账号密码后,前端发送登录请求至/login接口。
    • 后端验证成功后,返回Access Token(例如有效期2h)Refresh Token(有效期7天)
    • 前端将Token存储在localStorage
  2. 请求拦截阶段

    • 使用Axios拦截器自动为所有请求添加Authorization: Bearer ${accessToken}头。
    • 示例代码:
      axios.interceptors.request.use(config => {
        const token = localStorage.getItem('accessToken');
        if (token) config.headers.Authorization = `Bearer ${token}`;
        return config;
      });
      
  3. 响应拦截阶段

    • 当后端返回401 Unauthorized时,触发Token刷新流程。
    • 关键逻辑:
      • 检查是否存在isRefreshing标记,避免重复刷新。
      • 将后续请求存入队列,等待新Token返回后重试。
  4. Token刷新阶段

    • 发送POST请求至/refresh接口,携带refreshToken
    • 成功时更新本地Token并重试队列请求;失败时清除Token并跳转登录页。

二、并发请求异常处理方案

当Refresh Token失败且存在多个并发请求时,需通过请求队列+状态管理实现优雅降级:

  1. 问题场景还原

    • 用户Token过期时,A页面同时发起10个接口请求,均返回401
    • 若未处理并发,会导致10次重复刷新请求,增加服务器压力。
  2. 解决方案步骤

    • 步骤1:引入请求队列管理器
      使用类管理刷新状态和待重试请求:
// 刷新Token管理器
class TokenRefreshManager {
  constructor() {
    this.subscribers = []; // 订阅者队列:存储等待Token刷新的请求回调
    this.isRefreshing = false; // 是否正在刷新Token
    this.refreshPromise = null; // 存储当前刷新Token的Promise,用于避免重复刷新
  }
  
// 订阅Token刷新事件
  subscribe(callback) {
    this.subscribers.push(callback);
  }

// 当Refresh Token失败时,统一处理所有错误
  onRefreshFailed(error) {
    this.subscribers.forEach(cb => cb(null, error)); // 通知所有订阅者刷新失败
    this.subscribers = []; // 清空队列
    this.isRefreshing = false;
    this.refreshPromise = null;
    
    // 1. 清除本地无效的Token
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
    
    // 2. 显示一个统一且友好的错误提示(而非10个)
    this.showFriendlyError(error);
    
    // 3. 跳转到登录页
    setTimeout(() => {
      window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
    }, 2000); // 延迟2秒跳转,让用户看到提示信息
  }

  // 显示友好错误提示(使用Element UI的Message组件)
  showFriendlyError(error) {
    let errorMessage = '网络或服务异常,请重新登录。2秒后自动跳转...';
    
    // 根据错误类型显示不同的友好信息
    if (error?.response?.status === 403) {
      errorMessage = '登录已过期,请重新登录。2秒后自动跳转...';
    } else if (error?.code === 'ECONNABORTED' || error?.message?.includes('timeout')) {
      errorMessage = '网络请求超时,请检查网络连接后重新登录。2秒后自动跳转...';
    } else if (!navigator.onLine) {
      errorMessage = '网络连接已断开,请检查网络设置。2秒后自动跳转...';
    }
    
    // 使用Element UI的Message组件显示错误提示
    Message({
      message: errorMessage,
      type: 'error',
      duration: 2000, // 设置为2000毫秒,与跳转延迟一致
      showClose: true, // 显示关闭按钮,允许用户手动关闭
      customClass: 'global-error-message', // 可选,添加自定义样式类
      onClose: () => {
        // 消息关闭时的回调(可选)
      }
    });
 }
 // 当Token刷新成功后,通知所有订阅者
  onRefreshed(token) {
    this.subscribers.forEach((cb) => cb(token));
    this.subscribers = [];// 清空队列
  }

  async refresh() {
    // 如果正在刷新,返回同一个Promise,避免重复请求
    if (this.isRefreshing) {
      return this.refreshPromise;
    }

    this.isRefreshing = true;
    try {
      const rt = localStorage.getItem('refreshToken');
      const res = await axios.post('/refresh', { rt });
      const newToken = res.data.at;
      this.onRefreshed(newToken);
      return newToken;
    } catch (error) {
     // 统一处理刷新失败
      this.onRefreshFailed(error);
      throw error;
    } finally {
      this.isRefreshing = false;
    }
  }
}
// 全局单例
export const tokenManager = new TokenRefreshManager();
  • 步骤2:修改响应拦截器逻辑

    axios.interceptors.response.use(
      response => response,
      async error => {
        const { config, response } = error;
       if (error.response?.status === 401 && !config._retry) {
    	  config._retry = true; // 标记请求已处理
          const newToken = await tokenManager.refresh();
          config.headers.Authorization = `Bearer ${newToken}`;
          return axios(config);
        }
        return Promise.reject(error);
    	}
    );
    
  • 步骤3:流程图说明

    graph TD
      A[用户发起请求] --> B{Token有效?}
      B -- 是 --> C[正常返回数据]
      B -- 否 --> D{存在刷新请求?}
      D -- 否 --> E[发起刷新请求]
      E --> F{刷新成功?}
      F -- 是 --> G[更新Token并重试队列]
      F -- 否 --> H[跳转登录页]
      D -- 是 --> I[加入请求队列]
      G --> I
    

总结

现在想来当时面试官提问的"同时10个异常提示"问题,本质考察的是并发请求的队列管理能力。我的原始理解有误——并发请求不会导致10次独立提示,而是通过统一队列机制合并处理。关键在于:所有401请求会被收集到同一个刷新流程中,刷新失败后仅触发一次错误提示。