☰
前端请求自动携带Token与无感刷新完整方案
2026/10/17 8:50:01 网站建设 项目流程

1. 请求鉴权这件事,为什么值得单独拎出来做

做前端或者客户端开发的人,迟早都会碰到同一个问题:登录之后拿到的那个 token,怎么让它跟着后面每一个请求自动跑出去。手动加?一个项目几十个接口,加着加着就漏了,漏了就是 401,401 就得重新登录,用户体验直接崩。更麻烦的是,token 还有过期时间,过期之后还得刷新,刷新的时候如果并发好几个请求同时打过来,处理不好就是连环 401,甚至把刷新接口本身也给拦了,直接死循环。

所以“让每个 Http 请求都自动带上 token”这件事,看起来只是加个请求头,实际上它牵扯到请求拦截、响应拦截、token 存储、无感刷新、并发控制、重试队列这一整套链路。我见过不少项目,前期就是简单粗暴地在每个请求里手写 headers,等到接口数量上来、token 机制一改,改到怀疑人生。也见过一些项目上了拦截器,但刷新逻辑写得有问题,测试环境偶尔复现、线上偶发,排查起来非常痛苦。

这篇内容就是把这套东西从头到尾拆一遍。不管你是用 Axios、Fetch 还是自己封装的请求库,不管你是 Vue、React 还是原生项目,核心思路是通用的。我会把每一步为什么这么做、参数怎么定、坑在哪里都讲清楚,让你看完能直接照着搭一套,而不是只抄个拦截器代码回去发现跑不通。

适合谁看?刚接触前端鉴权的新手可以把它当成一份完整的落地指南;有一定经验但没系统整理过这块的开发者,可以对照检查自己项目里的实现有没有隐患;做客户端或者跨端项目的同学,思路同样适用,只是具体 API 换一下而已。

2. 整体方案设计:拦截器 + 存储 + 刷新队列

2.1 为什么选请求拦截器作为主入口

要让 token 自动带上,最直接的想法是在发请求的地方统一处理。但“发请求的地方”可能有很多个,你不可能保证每个人都记得加。所以正确的做法是找到一个所有请求都会经过的“关口”,在这个关口统一注入。这个关口就是请求拦截器。

以 Axios 为例,它提供了interceptors.request.use和interceptors.response.use两个钩子。请求拦截器在请求真正发出之前执行,你可以在这里改 config、加 header、加时间戳、加签名。响应拦截器在收到响应之后、业务代码拿到数据之前执行,你可以在这里统一处理错误码、判断 token 是否过期、触发刷新。

选拦截器而不是封装一个request函数,核心原因是拦截器是“无感”的。业务代码里还是正常调axios.get('/api/xxx'),不需要关心 token 从哪来、怎么加。封装函数虽然也能做到,但一旦有人绕过封装直接用 axios,就漏了。拦截器是从库的层面卡住的,只要用的是同一个 axios 实例,就跑不掉。

这里有个细节:一定要用同一个 axios 实例。很多人项目里到处import axios from 'axios',然后各自axios.create(),这样拦截器只挂在其中一个实例上,其他实例的请求不会走拦截器。正确做法是创建一个统一的request.js,在里面axios.create()出一个实例,挂上拦截器,然后导出这个实例,所有地方都从这个文件引入。

2.2 token 存哪里:内存、localStorage 还是 Cookie

token 的存储位置直接决定了安全性和可用性。常见的有三种:内存变量、localStorage、Cookie。

内存变量最安全,页面刷新就没了,但用户体验差,刷新一下就得重新登录。localStorage 持久化好,但容易被 XSS 攻击读取。Cookie 可以设置 HttpOnly,JS 读不到,相对安全,但需要后端配合,而且跨域场景下配置麻烦。

我的建议是:access token 放内存 + localStorage 双份,内存里用于快速读取,localStorage 用于刷新页面后恢复。refresh token 如果后端支持,最好放 HttpOnly Cookie,前端拿不到,只能由后端在刷新接口里读取。如果后端不支持,那 refresh token 也只能放 localStorage,但要做好 XSS 防护,比如对用户输入严格转义、上 CSP。

这里有个容易忽略的点:localStorage 是同步的,读写会阻塞主线程。虽然 token 字符串很短,影响微乎其微,但如果你在拦截器里每次都读 localStorage,高频请求下还是有一点点开销。所以更好的做法是启动时从 localStorage 读一次到内存变量,之后拦截器直接读内存,token 更新时同时写内存和 localStorage。

2.3 刷新 token 的时机:主动 vs 被动

token 过期有两种处理方式。一种是主动刷新,比如 token 有效期 2 小时,你每隔 1 小时 50 分就调一次刷新接口。另一种是被动刷新,等接口返回 401 了再去刷新。

主动刷新的问题是,如果用户一直不操作,你定时刷新就是浪费请求;如果用户刚好在刷新那一刻发请求,还是可能用到旧 token。被动刷新更常见,但要注意并发问题:同时有 5 个请求返回 401,你不能调 5 次刷新接口,只能调一次,其他 4 个等刷新完成后重试。

所以完整的方案是:响应拦截器里判断 401,如果当前没有在刷新,就发起刷新请求,并把后续的 401 请求挂起,放进一个队列;刷新成功后,把队列里的请求重新发一遍,带上新 token;刷新失败,清空 token,跳转登录页。

这个队列机制是整个方案里最容易写错的地方。常见错误包括:刷新接口本身也走了拦截器,结果刷新失败又触发刷新,死循环;队列里的请求重试时没有更新 token,还是用旧的;刷新成功后队列没有清空,导致重复请求。

3. 核心细节拆解:从存储到注入到刷新

3.1 token 的读取与注入逻辑

请求拦截器里要做的事情其实很简单:从存储里拿 token,如果有,就加到请求头里。但这里有几个细节要注意。

第一,请求头的字段名要和后端约定好。常见的是Authorization: Bearer <token>,也有用X-Token、token的。这个必须和后端对齐,否则加了等于没加。

第二,有些请求不需要 token,比如登录接口、注册接口、公开的配置接口。如果无脑给所有请求都加 token,登录接口带上一个过期的 token,后端可能会直接拒绝。所以需要一个白名单机制,或者在请求配置里加一个标记,比如config.skipAuth = true,拦截器里判断这个标记,跳过注入。

第三,token 可能不存在。用户没登录就访问公开页面,这时候拦截器拿不到 token,不能报错,直接放行即可。等用户登录后再发请求,自然就带上了。

第四,要注意请求头的合并。Axios 的 config.headers 可能已经有其他自定义头,你不能直接覆盖,要用展开或者set方法。比如config.headers = { ...config.headers, Authorization: 'Bearer ' + token },或者 Axios 1.x 里用config.headers.set('Authorization', ...)。

3.2 响应拦截器里的 401 判断

响应拦截器收到 401 时,不能直接跳登录页,因为可能是 token 过期,刷新一下就能继续。所以要先判断:是不是刷新接口本身返回的 401?如果是,说明 refresh token 也过期了,只能跳登录。如果不是,就尝试刷新。

怎么判断是不是刷新接口?可以在刷新请求的 config 里加一个标记,比如config.isRefreshToken = true,响应拦截器里判断这个标记。或者更简单,刷新接口用另一个 axios 实例,不挂响应拦截器,这样就不会被拦截。

刷新的时候要加锁。用一个变量isRefreshing记录当前是否在刷新。如果为 false,就把它设为 true,发起刷新请求;如果为 true,说明已经有刷新在进行,当前请求就挂起,放进队列。

队列里存什么?存的是一个函数,调用这个函数就能重新发起原请求。比如new Promise(resolve => { queue.push(token => { config.headers.Authorization = 'Bearer ' + token; resolve(instance(config)); }); })。刷新成功后,遍历队列,把新 token 传进去,执行每个函数。

刷新失败的处理也要小心。要把isRefreshing重置为 false,清空队列,把队列里的请求全部 reject,然后跳转登录页。如果不 reject,那些挂起的请求会一直 pending,业务代码里的 loading 永远不会消失。

3.3 并发请求下的队列管理

并发场景是这块最考验实现的地方。假设页面加载时同时发了 6 个请求,token 刚好过期,6 个请求都返回 401。第一个 401 触发刷新,isRefreshing设为 true;后面 5 个 401 进来,发现isRefreshing为 true,就把自己挂起。

刷新成功后,isRefreshing设为 false,遍历队列,把 5 个请求重新发出去。这里要注意,重新发的时候要用新的 config,把旧 token 替换掉。如果直接instance(config),config 里的 headers 还是旧的,重试还是会 401。

还有一个细节:队列里的请求重试时,如果又返回 401 怎么办?理论上刷新后的 token 是有效的,不应该再 401。但如果后端有 bug 或者 token 被提前失效,可能会再次 401。这时候如果再次触发刷新,就会死循环。所以可以加一个重试次数限制,比如每个请求最多重试一次,超过就跳登录。

另外,刷新请求本身也要设置超时。如果刷新接口挂了,一直不返回,队列里的请求会一直挂着。设置一个合理的超时时间,比如 10 秒,超时后按刷新失败处理。

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

4.1 创建统一请求实例与基础配置

先建一个request.js文件,内容大致如下。这里用 Axios 举例,其他库思路一样。

import axios from 'axios'; const instance = axios.create({ baseURL: process.env.API_BASE_URL || '/api', timeout: 15000, headers: { 'Content-Type': 'application/json', }, }); export default instance;

baseURL从环境变量读,方便不同环境切换。timeout设 15 秒,太短容易误杀慢接口,太长用户等太久。Content-Type默认 JSON,文件上传等场景可以在具体请求里覆盖。

然后建一个token.js管理 token 的存取:

const ACCESS_TOKEN_KEY = 'app_access_token'; const REFRESH_TOKEN_KEY = 'app_refresh_token'; let memoryToken = null; export function getToken() { if (memoryToken) return memoryToken; memoryToken = localStorage.getItem(ACCESS_TOKEN_KEY); return memoryToken; } export function setToken(token) { memoryToken = token; if (token) { localStorage.setItem(ACCESS_TOKEN_KEY, token); } else { localStorage.removeItem(ACCESS_TOKEN_KEY); } } export function getRefreshToken() { return localStorage.getItem(REFRESH_TOKEN_KEY); } export function setRefreshToken(token) { if (token) { localStorage.setItem(REFRESH_TOKEN_KEY, token); } else { localStorage.removeItem(REFRESH_TOKEN_KEY); } } export function clearTokens() { memoryToken = null; localStorage.removeItem(ACCESS_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); }

内存变量memoryToken作为一级缓存,减少 localStorage 读取。setToken同时更新内存和 localStorage,保证一致性。

4.2 请求拦截器的注入实现

在request.js里加上请求拦截器:

import { getToken } from './token'; instance.interceptors.request.use( (config) => { if (config.skipAuth) { return config; } const token = getToken(); if (token) { config.headers = config.headers || {}; config.headers['Authorization'] = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error) );

skipAuth是自定义标记,登录、注册等接口在调用时传{ skipAuth: true }即可跳过。注意这里用config.headers['Authorization']直接赋值,Axios 1.x 里 headers 是 AxiosHeaders 实例,也支持这种写法,但更规范的是用config.headers.set('Authorization', ...)。两种都能跑,看团队规范。

4.3 响应拦截器与无感刷新完整代码

这是核心部分,直接上完整实现:

import { getToken, setToken, getRefreshToken, clearTokens } from './token'; let isRefreshing = false; let requestQueue = []; function onTokenRefreshed(newToken) { requestQueue.forEach((callback) => callback(newToken)); requestQueue = []; } function onRefreshFailed(error) { requestQueue.forEach((callback) => callback(null, error)); requestQueue = []; } instance.interceptors.response.use( (response) => response.data, async (error) => { const { config, response } = error; if (!response || response.status !== 401 || config.skipAuth) { return Promise.reject(error); } if (config.isRefreshToken) { clearTokens(); redirectToLogin(); return Promise.reject(error); } if (isRefreshing) { return new Promise((resolve, reject) => { requestQueue.push((newToken, err) => { if (err) { reject(err); } else { config.headers['Authorization'] = `Bearer ${newToken}`; resolve(instance(config)); } }); }); } isRefreshing = true; try { const refreshToken = getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token'); } const res = await instance.post('/auth/refresh', { refreshToken }, { skipAuth: true, isRefreshToken: true, }); const newToken = res.data.accessToken; setToken(newToken); onTokenRefreshed(newToken); config.headers['Authorization'] = `Bearer ${newToken}`; return instance(config); } catch (refreshError) { onRefreshFailed(refreshError); clearTokens(); redirectToLogin(); return Promise.reject(refreshError); } finally { isRefreshing = false; } } );

这段代码有几个关键点。config.skipAuth判断放在最前面,登录接口返回 401 不触发刷新。config.isRefreshToken判断刷新接口自身的 401,直接清 token 跳登录。isRefreshing为 true 时,把请求包成 Promise 塞进队列,等刷新结果。刷新成功后,先更新队列里所有请求的 header,再重试当前请求。刷新失败后,队列里的请求全部 reject,避免一直 pending。

redirectToLogin函数根据项目路由实现,比如window.location.href = '/login'或者用路由实例跳转。注意跳转前可以带一个redirect参数,登录后跳回原页面。

4.4 刷新接口的防循环与超时处理

刷新接口本身也可能出问题。比如 refresh token 过期、后端刷新接口挂了、网络超时。这些情况都要有兜底。

防循环方面,上面代码里config.isRefreshToken已经处理了刷新接口返回 401 的情况。但还有一种情况:刷新接口返回 200,但返回的数据里没有新 token,或者新 token 是空的。这时候如果直接setToken(undefined),后续请求还是 401,又会触发刷新。所以要在拿到新 token 后校验一下,如果为空就按刷新失败处理。

超时方面,刷新请求可以单独设置更短的 timeout,比如 8 秒。在instance.post的 config 里加timeout: 8000。如果超时,会走 catch 分支,按刷新失败处理。

还有一个细节:刷新过程中用户可能手动退出登录。这时候应该取消所有挂起的请求。可以在clearTokens里加一个逻辑,把requestQueue清空并 reject。或者用一个全局的取消令牌,退出登录时取消所有请求。

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

5.1 请求头加了但后端收不到

这是最常见的问题。原因通常有几个:一是请求头字段名和后端约定不一致,比如前端用Authorization,后端读的是token。二是跨域场景下,Authorization属于非简单头,会触发预检请求,如果后端 CORS 配置没有允许这个头,请求会被浏览器拦截。三是用了代理或者网关,某些头被过滤掉了。

排查方法:先在浏览器 Network 面板看请求的 Request Headers 里有没有这个头。如果没有,说明拦截器没生效或者被覆盖了。如果有,但后端说收不到,就检查 CORS 配置和网关规则。跨域时后端需要设置Access-Control-Allow-Headers: Authorization,否则预检不通过。

5.2 刷新 token 时并发请求全部失败

这个问题的表现是:token 过期后,页面同时发多个请求,结果全部 401,而且没有自动重试。原因通常是队列逻辑写错了,比如isRefreshing没有正确重置,或者队列里的请求没有用新 token 重试。

排查方法:在刷新成功和失败的分支里打日志,看requestQueue的长度和内容。如果刷新成功后队列是空的,说明请求没有正确入队。检查isRefreshing的判断时机,确保在第一个 401 进来时就设为 true,后续 401 进来时能读到 true。

另一个常见错误是:刷新成功后,队列里的请求重试时用的是旧的 config 对象,header 没更新。上面的代码里是config.headers['Authorization'] = ...,直接改原 config,这样重试时就是新 token。如果用的是{ ...config, headers: { ...config.headers, Authorization: ... } },也可以,但要确保新 config 传给了instance。

5.3 刷新接口本身返回 401 导致死循环

这个问题的表现是:token 过期,触发刷新,刷新接口也返回 401,然后又触发刷新,无限循环,控制台疯狂打印请求。

原因就是刷新接口走了同一个响应拦截器,401 被拦截后又触发刷新。解决方法就是上面说的,给刷新请求加isRefreshToken标记,拦截器里判断这个标记,直接跳登录,不再刷新。

还有一种变体:刷新接口返回的不是 401,而是 200 但业务码表示失败。这时候拦截器不会触发刷新,但业务代码拿到失败结果,可能又去调刷新,也会循环。所以刷新接口的失败判断要同时看 HTTP 状态码和业务码。

5.4 token 过期时间与刷新时机的权衡

access token 的有效期设多长,是个需要权衡的问题。设太短,刷新频繁,增加后端压力;设太长,安全性降低,token 泄露后风险窗口大。常见的是 15 分钟到 2 小时。refresh token 可以设 7 天到 30 天。

如果后端支持滑动过期,比如每次刷新后 refresh token 也续期,那用户体验最好,只要在有效期内有操作就不会掉线。如果不支持,refresh token 过期后只能重新登录。

前端这边可以在 token 快过期时提前刷新,比如解析 token 里的 exp 字段,发现还有 5 分钟过期就主动刷新。这样能减少 401 的发生。但解析 token 需要后端用 JWT 格式,且前端能拿到 exp。如果是 opaque token,就只能被动刷新。

5.5 常见问题速查表

问题现象可能原因排查方向解决方案
请求头没有 token拦截器未生效或 skipAuth 误设检查 axios 实例是否统一统一实例,检查 skipAuth 标记
后端收不到 token字段名不一致或 CORS 拦截Network 面板看请求头对齐字段名,配置 CORS
并发请求全部 401队列逻辑错误打印 isRefreshing 和队列长度修正入队和重试逻辑
刷新死循环刷新接口走了拦截器看刷新请求是否带标记加 isRefreshToken 标记
刷新后仍 401新 token 未更新到 config检查重试时的 header重试前更新 config.headers
刷新请求超时无响应未设超时或后端挂看刷新请求的 timeout设 8-10 秒超时,失败跳登录

6. 几个我踩过的坑和实操心得

第一个坑是 localStorage 的跨标签页同步。用户在 A 标签页登录,B 标签页的 token 还是旧的。如果 B 标签页发请求,用的旧 token 会 401,触发刷新,刷新成功后 B 标签页的 token 更新了,但 A 标签页不知道。解决方法是监听storage事件,当 localStorage 里的 token 变化时,同步更新内存变量。这样多标签页之间能保持一致。

第二个坑是刷新请求的并发控制。上面代码里用isRefreshing变量控制,但这是模块级变量,如果项目里有多个 axios 实例,每个实例都有自己的isRefreshing,就会导致多个实例同时刷新。所以要么统一用一个实例,要么把刷新逻辑抽到一个独立的模块里,用单例模式管理。

第三个坑是 token 刷新的竞态条件。假设刷新请求发出去了,还没返回,用户手动点了退出登录。这时候刷新成功返回,又把 token 设回去了,用户以为退出了其实没退。解决方法是在退出登录时设置一个标志位,刷新成功后检查这个标志位,如果已退出就不设 token。

第四个坑是错误处理里的 Promise 链。响应拦截器里如果返回Promise.reject(error),业务代码的 catch 能捕获到。但如果返回Promise.resolve(error),业务代码会以为成功了。所以刷新失败时一定要 reject,不能 resolve。队列里的请求也要 reject,否则业务代码的 loading 永远不消失。

第五个坑是 token 的编码问题。有些 token 里包含特殊字符,直接放在 header 里可能被截断或者转义。虽然 JWT 通常是 base64url 编码,不会有问题,但如果是自定义 token,最好确认一下。必要时可以做一次 encodeURIComponent。

第六个坑是刷新接口的返回结构。不同后端的刷新接口返回格式不一样,有的返回{ accessToken, refreshToken },有的返回{ data: { token } },有的直接返回字符串。前端要按实际结构解析,不能想当然。最好在对接时先用手动请求测一下,确认返回结构再写代码。

最后分享一个小技巧:在开发环境里,可以把 token 的有效期设得很短,比如 30 秒,这样能快速验证刷新逻辑是否正常。测试的时候打开 Network 面板,观察 401 出现后是否自动刷新、是否重试成功、队列里的请求是否都恢复了。这个验证过程比看代码靠谱得多,很多隐藏的并发问题只有实际跑起来才能发现。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询