我的项目实战(十一)- 双 Token无感刷新:前端如何优雅处理 JWT 过期问题

0 阅读6分钟

在上一篇文章《[我的项目实战(十)] 现代 Web 鉴权体系:从登录到双 Token 机制的全流程实践》中,我们完整梳理了后端如何通过 JWT + Refresh Token 构建安全、可扩展的身份认证系统。但一个完整的鉴权闭环,不仅需要服务端的设计,更离不开前端对 token 生命周期的精细管理。

今天这篇文章,我们将聚焦于那个“用户正在写笔记,突然请求失败跳回登录页”的经典痛点,深入探讨:前端如何实现真正意义上的“无感刷新”?

我们将一步步解决并发控制、请求重试、状态同步等一系列工程难题,构建出一套稳定、低侵入、适用于真实项目的响应拦截机制,并解析其背后的设计哲学。


一、问题的本质:401 不等于“请登录”

当我们使用 JWT 做身份认证时,总会遇到这样一个场景:

用户 A 登录成功,拿到有效期为 15 分钟的 access_token
第 16 分钟,他点击保存草稿,接口返回 401 Unauthorized

此时,如果直接弹窗提示“登录已过期,请重新登录”,体验是非常割裂的——用户明明一直在操作,凭什么说没就没?

但如果我们能在这背后悄悄完成一次 token 刷新,并把刚才失败的请求自动重试,整个过程用户完全无感知,这就叫 “无感刷新”

而实现它的关键,在于正确理解 401 的语义差异

场景含义应对策略
access_token 过期可恢复的状态异常自动刷新,重试请求
refresh_token 失效 / 用户登出身份彻底失效引导重新登录

我们的目标,就是精准识别第一种情况,并在不干扰用户的前提下完成自救。


二、初步尝试:最简单的刷新逻辑

先来看一个朴素版本:

axios.interceptors.response.use(
  res => res.data,
  async error => {
    const { config, response } = error;

    if (response.status === 401) {
      // 尝试刷新 token
      const newToken = await refreshToken();
      // 修改原请求 header
      config.headers.Authorization = `Bearer ${newToken}`;
      // 重新发送
      return axios(config);
    }

    return Promise.reject(error);
  }
);

看起来没问题?其实隐患重重。

❌ 问题 1:并发请求导致多次刷新

假设页面同时发出三个请求 A、B、C,它们几乎同时因 token 过期返回 401。那么这三条都会进入上述逻辑,结果是:

  • 三次调用 /auth/refresh
  • 可能触发服务端防重放机制导致后续失败
  • 最终状态混乱

❌ 问题 2:B 和 C 请求永远丢失

A 开始刷新,B 和 C 不应该立即重试,而应等待 A 拿到新 token 后再继续。否则会出现:

  • B 在 A 完成前就发起重试 → 仍用旧 token → 再次 401
  • 陷入无限循环或重复刷新

所以,我们必须引入一种“协调机制”。


三、核心设计:锁 + 队列模式

为了应对并发与依赖关系,我们采用经典的 “锁 + 请求队列” 模式。

1. 全局状态定义

let isRefreshing = false; // 是否正在刷新 token
let requestsQueue: Array<(token: string) => void> = []; // 待处理请求队列
  • isRefreshing 是一把互斥锁,确保同一时间只有一个 refresh 流程在执行。
  • requestsQueue 存放那些需要等待新 token 的请求。

2. 拦截器中的分流处理

当某个请求遇到 401:

if (response.status === 401 && !config._retry) {
  if (isRefreshing) {
    // 已有刷新任务在进行,当前请求加入队列等待
    return new Promise(resolve => {
      requestsQueue.push((token: string) => {
        config.headers.Authorization = `Bearer ${token}`;
        resolve(axios(config));
      });
    });
  }

  // 否则由我来主导刷新
  isRefreshing = true;
  config._retry = true;

  try {
    const { access_token } = await axios.post('/auth/refresh', {
      refresh_token: getRefreshToken()
    });

    // 更新全局状态
    setAuthToken(access_token);

    // 执行队列中所有等待的请求
    requestsQueue.forEach(callback => callback(access_token));
    requestsQueue = [];

    // 重发当前请求
    config.headers.Authorization = `Bearer ${access_token}`;
    return axios(config);

  } catch (e) {
    logout();
    redirectToLogin();
    return Promise.reject(e);
  } finally {
    isRefreshing = false;
  }
}

这个结构看似复杂,实则层次清晰:

  1. 判断是否首次触发:避免重复刷新;
  2. 如果是竞争者,则排队等候:利用闭包保存请求上下文;
  3. 如果是发起者,则承担刷新职责:获取新 token、通知队友、重发自己;
  4. 统一清理资源:释放锁,防止死锁。

四、关键技术点剖析

1. _retry 标志位的作用

你可能会问:为什么需要 _retry

设想没有它,会发生什么?

  • 请求 A 收到 401,开始刷新流程;
  • 刷新完成后,用新 token 重发 A;
  • 新的 A 请求再次进入拦截器;
  • 此时又是一个 401?→ 继续刷新 → 无限循环!

因此,我们在第一次捕获时打上标记:

config._retry = true;

这样下次再遇到同一个请求就不会重复处理。

💡 注意:_retry 是自定义字段,不会被发送到后端,仅用于内部流程控制。


2. 队列中的回调函数是如何工作的?

重点看这一段:

requestsQueue.push((token) => {
  config.headers.Authorization = `Bearer ${token}`;
  resolve(axios(config));
});

这里形成了一个典型的 Promise + 闭包 结构:

  • resolve 来自当前请求的 Promise;
  • config 是当前请求的配置对象;
  • 当 future 某个时刻传入新 token 时,就能动态修改 header 并重新发起请求。

本质上,我们把“将来要用新 token 重试”的能力封装成了一个可延迟执行的函数。


3. 为什么要在 finally 中释放锁?

finally {
  isRefreshing = false;
}

这是极其重要的一步。如果刷新过程中发生网络错误、服务端异常或代码抛错,try...catch 会中断执行流,跳过后续逻辑。

如果不放在 finally,就会造成:

  • isRefreshing 一直为 true
  • 后续所有请求都被卡在队列里
  • 整个应用无法发起任何受保护接口请求

这就是典型的 资源泄漏 问题。务必保证锁的释放是原子且必然的。


五、边界情况与容错设计

再稳健的机制也要面对现实世界的不确定性。以下是几个必须考虑的边缘场景:

场景 1:refresh_token 也已过期

用户长时间未操作,连 refresh_token 都失效了。此时 /auth/refresh 请求也会返回 401。

我们应在 catch 块中统一处理:

useUserStore.getState().logout();
window.location.href = '/login';

并清除本地存储的所有凭证。


场景 2:多个标签页共享登录状态

用户在一个标签页登出,其他页面仍可能持有旧 token。虽然本次机制无法主动感知,但我们可以通过监听 localStorage 变化来辅助清理:

window.addEventListener('storage', (e) => {
  if (e.key === 'auth' && e.newValue === null) {
    // 检测到登出事件
    if (!window.location.pathname.startsWith('/login')) {
      window.location.href = '/login';
    }
  }
});

前提是登出时写入 storage 事件。


场景 3:网络不稳定导致刷新失败

偶尔的网络抖动可能导致 refresh 请求失败。是否应该重试?

取决于业务敏感度:

  • 普通系统:直接登出即可;
  • 高可用要求:可在 catch 中加入指数退避重试(最多 2 次);
let retries = 0;
const maxRetries = 2;

async function tryRefresh() {
  try {
    // ...正常刷新
  } catch (err) {
    if (retries < maxRetries) {
      retries++;
      await sleep(500 * Math.pow(2, retries));
      return tryRefresh();
    } else {
      logout();
    }
  }
}

但要注意不要过度重试,以免加重服务器负担。


六、优势总结:不只是“能用”

这套方案之所以能在生产环境中长期稳定运行,源于以下几个核心特性:

特性说明
真正的无感体验用户无需感知 token 刷新过程,操作连续
防并发刷新通过布尔锁保证最多只有一个 refresh 请求
支持并发请求恢复所有因 token 过期而失败的请求都能被正确重放
低耦合高内聚完全封装在拦截器中,业务代码零感知
可维护性强结构清晰,易于调试和扩展

更重要的是,它体现了一种 面向失败编程 的思维方式:我们不是在写“理想路径”,而是在设计“异常路径”的逃生通道。


七、潜在改进方向

没有完美的架构,只有持续演进的系统。以下是一些可以进一步优化的方向:

1. 使用 AbortController 实现请求取消

如果用户快速切换页面,部分已排队的请求可能已无意义。可通过注入 cancelToken 来支持取消机制。

2. 添加刷新日志上报

记录 refresh 成功率、耗时、失败原因等指标,有助于发现潜在问题(如频繁刷新可能是前端时间不同步导致)。

3. 支持多实例共存

在大型项目中,可能存在多个 axios 实例(例如文件上传专用实例),需确保拦截器逻辑能复用或继承。


八、结语:把异常当作常态来设计

JWT 的最大优点是“无状态”,但这也意味着一旦签发就难以收回。正因为如此,我们才更需要在客户端建立一套可靠的容错机制。

本文所展示的“无感刷新”方案,不是一个炫技式的黑科技,而是一种务实的工程实践。它不追求极致性能,也不依赖复杂框架,而是用最朴素的锁和队列,解决了最真实的问题。

当你下次面对“token 过期怎么办”这个问题时,希望你能想到:

“我不是要阻止错误发生,而是要让它发生得悄无声息。”

这才是现代前端应有的成熟姿态。


📌 适用建议

  • ✅ 单页应用(SPA)、管理系统、内容平台
  • ✅ 使用 JWT + Refresh Token 的认证体系
  • ✅ 对用户体验要求较高的产品
  • ❌ 不适用于基于 Session-Cookie 的传统架构

如果你也在构建类似的鉴权系统,欢迎留言交流你的实现方式。共勉!