ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前端请求层统一治理:自动注入token与登录态管理实战

前端请求层统一治理:自动注入token与登录态管理实战

1. 从一次接口联调翻车说起:为什么“自动带 token”值得单独做一层

前阵子帮一个朋友排查他们团队的一个前端项目,现象很典型:登录之后首页数据能正常拉取,但点进二级页面、触发某些操作时,接口时不时返回 401,用户被迫重新登录,体验稀碎。翻代码一看,问题不在后端,也不在登录逻辑,而在于他们每个请求都是手写的,有的地方记得塞 token,有的地方忘了,有的地方塞了但塞错了 header 名字,还有的地方在 token 刷新之后拿的还是旧值。这种“散落式”的 token 处理方式,几乎是所有中大型前端项目都会踩的坑。

“让每个 Http 请求都自动带上 token”这句话听起来像是一句需求描述,实际上它背后是一整套请求层统一治理的思路。它要解决的核心问题很明确:把“身份凭证的携带”这件事从业务代码里彻底剥离出来,交给一个统一的拦截层去处理,让写业务的人不用再关心 token 从哪来、放哪个字段、什么时候该刷新、什么时候该丢弃。这件事做得好,整个项目的接口调用会变得干净、稳定、可维护;做得不好,就是无穷无尽的 401 和偶发的登录态丢失。

这篇文章适合几类人看:一是正在做前后端分离项目、被 token 管理搞得头大的前端开发者;二是想给自己的项目加一层统一请求封装、但不确定怎么设计的中级工程师;三是带团队、需要一套可复用规范的技术负责人。我会从整体设计思路讲到具体实现,再到踩坑排查,尽量把每一步“为什么这么做”讲清楚,让你看完能直接照着搭一套出来。核心关键词就三个:请求拦截、token 自动注入、登录态统一管理,这三个词贯穿全文。

需要先说明一点:token 的具体形态(是 JWT 还是普通字符串)、存储位置(内存、本地存储还是 Cookie)会因项目而异,本文讲的是通用思路和落地方法,具体选型我会在对应章节给出取舍建议。下面进入正题。

2. 整体设计思路:为什么要在请求层做统一拦截

2.1 散落式 token 处理的三个致命问题

先说说如果不做统一拦截,会面临什么。最直接的问题就是重复代码和遗漏。假设一个项目有 80 个接口调用,每个调用都手写一遍headers: { Authorization: 'Bearer ' + token },那么这行代码就出现了 80 次。某天后端说 header 名字要从Authorization改成X-Token,你就要改 80 个地方,漏一个就是一个隐藏 bug。这是典型的“散弹式修改”,违反 DRY 原则。

第二个问题是token 时效性错乱。token 是有生命周期的,可能两小时过期,也可能通过 refresh token 续期。如果每个请求各自去读 token,那么当 token 在某个时刻被刷新后,那些已经“捕获”了旧 token 的闭包或变量仍然在用旧值,导致请求失败。统一拦截层可以在每次请求发出的瞬间去读取最新 token,天然规避这个问题。

第三个问题是异常处理无法收敛。当接口返回 401 时,理想的做法是统一拦截、尝试刷新 token、重放原请求,或者跳转登录页。但如果 token 是散落处理的,401 的处理逻辑也会散落各处,有的地方处理了,有的地方没处理,用户就会看到各种奇怪的报错。把这件事收敛到拦截层,才能做到“一处配置,全局生效”。

2.2 拦截层应该放在哪一层

明确了要统一处理,接下来是选位置。常见的三个位置:业务代码里手动调用封装函数、HTTP 客户端拦截器、更底层的网络库拦截器。

手动封装函数(比如写一个request()工具函数,内部统一加 token)是最简单的方案,适合小项目。但它的缺点是,一旦有人绕过这个函数直接用原生fetch或axios,就失效了。而且它没法优雅地处理“请求发出后、响应回来前”这个中间环节的 token 刷新。

HTTP 客户端拦截器(比如 axios 的interceptors.request和interceptors.response)是目前最主流的方案。它能在请求真正发出前修改 config,在响应回来后统一处理错误,正好覆盖了 token 注入和 401 处理两个核心场景。绝大多数前端项目用这一层就够了。

更底层的网络库拦截器(比如某些框架提供的 fetch 包装)能力更强,但配置复杂、调试成本高,除非有特殊需求,一般不必下沉到这一层。我的建议是:优先用 HTTP 客户端拦截器,把请求拦截和响应拦截配对使用,一个负责注入 token,一个负责处理 token 失效。这样职责清晰,也方便测试。

2.3 核心流程拆解:一次请求的完整生命周期

把整个流程串起来看,一次带 token 的请求大致经历这几个阶段:

  1. 业务代码调用api.getUserInfo(),不传任何 token 相关参数。
  2. 请求拦截器被触发,从 token 管理器读取当前有效 token。
  3. 如果 token 存在,注入到请求 header;如果不存在,视接口类型决定是否放行(比如登录接口本身不需要 token)。
  4. 请求发出,服务端校验 token。
  5. 如果校验通过,正常返回数据。
  6. 如果返回 401,响应拦截器捕获,判断是 token 过期还是无效。
  7. 若是过期且存在 refresh token,则调用刷新接口,拿到新 token 后重放原请求。
  8. 若刷新失败或 token 无效,清除本地登录态,跳转登录页。

这个流程里有两个关键设计点:token 的读取时机和并发请求的处理。读取时机必须在请求发出的那一刻,而不是在业务代码调用时,否则又会回到“旧 token”问题。并发处理指的是:当多个请求同时返回 401 时,不能每个都去刷新 token,否则会刷新多次、互相覆盖。正确做法是让第一个 401 触发刷新,其余请求挂起等待刷新结果,刷新完成后一起重放。这个细节我在第 4 章会详细讲。

3. 核心细节解析:token 存储、注入与刷新的关键取舍

3.1 token 存哪里:内存、本地存储还是 Cookie

这是绕不开的第一个决策。三种常见方案各有优劣,我列个表对比一下。

存储位置优点缺点适用场景
内存变量最安全,页面关闭即消失,不易被脚本读取刷新页面即丢失,需要配合 refresh 机制安全性要求高的后台系统
本地存储刷新页面不丢,读写方便可被同源脚本读取,存在被注入脚本窃取的风险普通 Web 应用,配合短期 token
Cookie可设置 HttpOnly 防脚本读取,自动随请求发送需处理跨域、CSRF 防护,前端读取受限对安全要求高、有服务端配合的场景

我的实际经验是:大多数前后端分离项目用“内存 + 本地存储”组合。具体做法是 access token 放内存(响应快、相对安全),refresh token 放本地存储(用于刷新),页面刷新时先用 refresh token 换新的 access token。这样既保证了刷新页面不掉登录,又降低了 access token 被长期窃取的风险。

提示:无论存哪里,都不要把 token 拼进 URL 参数里。URL 会被浏览器历史、日志、代理服务器记录,泄露风险极高。token 只应该出现在请求 header 或 Cookie 中。

3.2 注入位置与格式:Authorization 还是自定义 header

token 注入到哪个 header,取决于后端约定。最常见的是标准的Authorization字段,格式为Bearer <token>。也有项目用自定义 header,比如X-Access-Token。这里没有对错,只有约定。但有几个细节要注意。

第一,Bearer后面必须有一个空格,这是规范要求,很多 401 就是因为漏了空格或者多写了空格。第二,如果后端用的是自定义 header,注意大小写问题——HTTP header 本身不区分大小写,但某些服务端框架在读取时可能对大小写敏感,最好和后端确认清楚。第三,如果同时存在多个 token(比如 access token 和租户 token),要明确哪个放哪个 header,不要混用。

在拦截器里注入的代码大致是这样(以 axios 为例):

instance.interceptors.request.use((config) => { const token = tokenManager.getAccessToken(); if (token && !config.skipAuth) { config.headers['Authorization'] = `Bearer ${token}`; } return config; });

注意那个config.skipAuth,这是给登录、注册、验证码这类不需要 token 的接口留的“后门”。没有这个开关,登录接口自己也会带上一个空 token,虽然通常不影响,但不够干净。

3.3 刷新机制:什么时候刷、怎么刷、刷几次

token 刷新是整个方案里最容易出 bug 的地方。核心问题有三个:触发时机、并发控制、失败兜底。

触发时机有两种策略:主动刷新和被动刷新。主动刷新是在 token 快过期前(比如剩余有效期小于 5 分钟)提前刷新,好处是用户无感知,坏处是需要定时器或每次请求前检查,增加复杂度。被动刷新是等接口返回 401 再刷新,实现简单,但用户可能会感知到一次请求延迟。我一般推荐被动刷新为主、主动刷新为辅:平时靠 401 触发,同时在应用启动时检查一次 token 是否即将过期,提前换掉。

并发控制是重点。假设页面同时发了 5 个请求,token 恰好过期,5 个请求都返回 401。如果不做控制,就会触发 5 次刷新请求,不仅浪费,还可能因为刷新接口本身的并发限制导致部分失败,甚至出现“新 token 被旧刷新结果覆盖”的诡异现象。正确做法是维护一个“刷新中”的标记和一个等待队列:

let isRefreshing = false; let waitQueue = []; instance.interceptors.response.use( (response) => response, async (error) => { const { config, response } = error; if (response?.status !== 401 || config._retry) { return Promise.reject(error); } if (isRefreshing) { // 已有刷新在进行,挂起当前请求 return new Promise((resolve) => { waitQueue.push((newToken) => { config.headers['Authorization'] = `Bearer ${newToken}`; config._retry = true; resolve(instance(config)); }); }); } isRefreshing = true; try { const newToken = await refreshToken(); waitQueue.forEach((cb) => cb(newToken)); waitQueue = []; config.headers['Authorization'] = `Bearer ${newToken}`; config._retry = true; return instance(config); } catch (e) { // 刷新失败,清登录态 tokenManager.clear(); redirectToLogin(); return Promise.reject(e); } finally { isRefreshing = false; } } );

这段代码里config._retry是个关键标记,防止重放后的请求再次 401 时陷入无限循环。刷新失败后的兜底也很重要:一定要清除本地 token 并跳转登录,否则用户会卡在一个“一直 401 但又不跳登录”的死循环里。

注意:刷新接口本身必须用独立的请求实例,不能走带拦截器的那个实例,否则刷新请求自己 401 了又去触发刷新,直接递归爆栈。这是新手最容易踩的坑之一。

4. 实操落地:从零搭一套自动带 token 的请求层

4.1 环境准备与依赖选择

这一章我以最常见的 axios 为例,讲一套可以直接抄的落地方案。为什么选 axios 而不是 fetch?因为 axios 的拦截器机制成熟、生态好、错误处理统一,而且支持请求取消、超时配置这些 fetch 需要自己封装的能力。当然,如果你用的是其他 HTTP 客户端,思路完全一样,只是 API 名字不同。

先装依赖:

npm install axios

然后规划目录结构。我习惯把请求层拆成三个文件:tokenManager.js负责 token 的存取和刷新,http.js负责创建实例和配置拦截器,api.js负责具体的接口定义。这样分层的好处是职责清晰,token 逻辑不会和业务接口混在一起。

src/ utils/ tokenManager.js http.js api/ user.js order.js

4.2 tokenManager:把 token 的读写和刷新收口

tokenManager是整个方案的地基,它要提供几个能力:读 access token、写 access token、读 refresh token、清除所有 token、执行刷新。把它单独抽出来,是为了让拦截器只依赖接口,不依赖具体存储实现,将来从本地存储换成内存或 Cookie,只改这一个文件。

// tokenManager.js const ACCESS_KEY = 'app_access_token'; const REFRESH_KEY = 'app_refresh_token'; let memoryToken = null; // access token 优先放内存 export const tokenManager = { getAccessToken() { return memoryToken || localStorage.getItem(ACCESS_KEY) || ''; }, setAccessToken(token) { memoryToken = token; localStorage.setItem(ACCESS_KEY, token); }, getRefreshToken() { return localStorage.getItem(REFRESH_KEY) || ''; }, setRefreshToken(token) { localStorage.setItem(REFRESH_KEY, token); }, clear() { memoryToken = null; localStorage.removeItem(ACCESS_KEY); localStorage.removeItem(REFRESH_KEY); }, };

这里有个细节:access token 同时存内存和本地存储,读取时优先内存。为什么?因为内存读取最快,且页面运行期间以内存为准;本地存储是为了刷新页面后还能恢复。刷新成功后要同时更新两处,保证一致。

4.3 请求拦截器:注入逻辑与白名单

接下来创建 axios 实例并配置请求拦截器。这里要处理白名单(不需要 token 的接口)和注入格式。

// http.js import axios from 'axios'; import { tokenManager } from './tokenManager'; const WHITE_LIST = ['/auth/login', '/auth/register', '/auth/captcha']; const instance = axios.create({ baseURL: '/api', timeout: 15000, }); instance.interceptors.request.use( (config) => { const url = config.url || ''; const isWhite = WHITE_LIST.some((path) => url.includes(path)); if (!isWhite) { const token = tokenManager.getAccessToken(); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } } return config; }, (error) => Promise.reject(error) );

白名单用includes匹配而不是全等,是为了兼容带 query 参数的 URL。但要注意,includes有误伤风险,比如/auth/login-log也会命中/auth/login。更严谨的做法是用正则或精确匹配路径部分,这个根据项目实际情况调整。

4.4 响应拦截器:401 处理与请求重放

响应拦截器是重头戏,前面第 3.3 节已经给了核心代码,这里补充几个实操细节。第一,刷新接口要用一个干净的 axios 实例,不带拦截器:

const rawInstance = axios.create({ baseURL: '/api', timeout: 15000 }); async function refreshToken() { const refresh = tokenManager.getRefreshToken(); if (!refresh) throw new Error('no refresh token'); const { data } = await rawInstance.post('/auth/refresh', { refreshToken: refresh }); tokenManager.setAccessToken(data.accessToken); if (data.refreshToken) tokenManager.setRefreshToken(data.refreshToken); return data.accessToken; }

第二,重放请求时要带上_retry标记,并且要重新走一遍请求拦截器(因为instance(config)会重新触发拦截器),这样新 token 会被正确注入。第三,如果刷新接口返回的也是 401,说明 refresh token 也失效了,直接清登录态跳登录,不要再尝试刷新。

4.5 业务层调用:干净到只剩业务语义

配置好之后,业务层写起来就非常舒服了。比如:

// api/user.js import { http } from '../utils/http'; export const getUserInfo = () => http.get('/user/info'); export const updateProfile = (data) => http.post('/user/profile', data);

调用方完全不用关心 token,getUserInfo()一调,token 自动就带上了。这就是这套方案的价值:把横切关注点从业务代码里彻底剥离。新人接手项目时,也不需要被告知“记得加 token”,因为框架层面已经保证了。

5. 常见问题与排查技巧实录

5.1 401 反复出现却查不出原因

这是最高频的问题。排查顺序建议这样走:先看请求 header 里到底有没有Authorization,值对不对(有没有 Bearer、有没有空格、token 是不是空字符串)。如果 header 正确,再看服务端日志,确认服务端读的是哪个 header、期望什么格式。很多时候是前后端 header 名字不一致,或者格式约定不同(有的要Bearer xxx,有的直接xxx)。

如果 header 正确、格式也对,但依然 401,那就要怀疑 token 本身是否有效。可以拿这个 token 去手动请求一个接口验证。如果手动请求也失败,说明 token 已过期或签发有问题,问题在刷新逻辑或登录逻辑,不在注入逻辑。

5.2 刷新 token 导致请求死循环

前面提过,刷新接口如果走了带拦截器的实例,刷新请求自己 401 又会触发刷新,无限递归。排查方法是看网络面板里是不是有大量重复的刷新请求。解决办法就是给刷新接口用独立的、不带拦截器的实例。另外,_retry标记一定要加,否则重放后的请求再次 401 也会继续触发刷新。

5.3 并发请求下的 token 覆盖

这个问题的表现很隐蔽:多个请求同时 401,各自触发刷新,后返回的刷新结果覆盖了先返回的,导致部分请求用了错误的 token。解决办法就是第 3.3 节的等待队列机制。判断是否生效,可以在刷新函数里打日志,看一次 401 风暴里刷新接口被调了几次——正常应该只有一次。

5.4 页面刷新后 token 丢失

如果 access token 只放内存,刷新页面必然丢失。解决办法是启动时用 refresh token 换一次 access token。在应用入口处加一段初始化逻辑:

async function bootstrap() { if (tokenManager.getRefreshToken() && !tokenManager.getAccessToken()) { try { await refreshToken(); } catch (e) { tokenManager.clear(); } } // 再挂载应用 }

5.5 常见问题速查表

现象可能原因排查方向解决方式
所有接口 401token 未注入或格式错看请求 header检查拦截器与格式
部分接口 401白名单配置错误对比接口路径修正白名单匹配规则
偶发 401token 刷新竞态看刷新请求次数加等待队列
刷新后仍 401重放未带新 token看重放请求 header重放前更新 header
刷新死循环刷新接口走了拦截器看重复请求用独立实例
刷新页面掉登录access token 仅存内存看初始化逻辑启动时刷新一次

提示:调试 token 相关问题时,浏览器开发者工具的 Network 面板是主战场。重点看 Request Headers 里的 Authorization 字段,以及响应状态码。不要只看 Console,很多 401 是静默处理的,Console 里未必有输出。

6. 几个容易被忽略的进阶细节

6.1 请求取消与 token 刷新的配合

当用户快速切换页面时,前一个页面的请求可能还在飞行中,此时如果触发 token 刷新,这些“已废弃”的请求也会被挂进等待队列,刷新完成后又被重放,造成无谓的请求。更好的做法是在请求配置里带上取消信号,页面卸载时取消请求,等待队列里也要过滤掉已取消的请求。这个细节在请求量大的后台系统里能明显减少无效流量。

6.2 多标签页下的 token 同步

用户开了多个标签页,在 A 标签页刷新了 token,B 标签页内存里还是旧 token,就会 401。解决办法是监听本地存储的变化事件,当检测到 token 被其他标签页更新时,同步更新本页内存。这个能力不是必须的,但对多标签页使用频繁的系统很有价值。

6.3 安全层面的几点提醒

token 自动注入虽然方便,但也要注意安全边界。第一,不要在任何日志里打印完整 token,打印时要脱敏。第二,token 的存储位置要结合项目安全等级选择,高安全场景优先考虑 HttpOnly Cookie。第三,刷新接口要做好频率限制,防止被恶意利用。第四,退出登录时一定要清除所有 token,包括内存和本地存储,不能只清一处。

6.4 测试与可维护性

最后说一句工程化的事。这套请求层建议配上单元测试,重点测三个场景:白名单接口不带 token、普通接口带 token、401 后刷新并重放。测试时把 tokenManager 和刷新函数 mock 掉,就能脱离后端独立验证逻辑。另外,拦截器里的魔法字符串(header 名、Bearer 前缀、白名单)建议抽成常量,将来改动只改一处。

我个人在实际项目里踩过最深的坑,就是刷新接口走了带拦截器的实例导致递归,当时排查了大半天才定位到。后来我把“刷新接口必须用独立实例”写进了团队规范,再没出过类似问题。这套方案搭好之后,接口层的稳定性会有质的提升,业务同学也能真正专注于业务逻辑本身。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进