前端请求自动携带Token与无感刷新完整方案

发布时间:2026/10/17 8:50:06

前端请求自动携带Token与无感刷新完整方案 1. 请求鉴权这件事为什么值得单独拎出来做做前端或者客户端开发的人迟早都会碰到同一个问题登录之后拿到的那个 token怎么让它跟着后面每一个请求自动跑出去。手动加一个项目几十个接口加着加着就漏了漏了就是 401401 就得重新登录用户体验直接崩。更麻烦的是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 还是 Cookietoken 的存储位置直接决定了安全性和可用性。常见的有三种内存变量、localStorage、Cookie。内存变量最安全页面刷新就没了但用户体验差刷新一下就得重新登录。localStorage 持久化好但容易被 XSS 攻击读取。Cookie 可以设置 HttpOnlyJS 读不到相对安全但需要后端配合而且跨域场景下配置麻烦。我的建议是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 出现后是否自动刷新、是否重试成功、队列里的请求是否都恢复了。这个验证过程比看代码靠谱得多很多隐藏的并发问题只有实际跑起来才能发现。
延伸阅读

更多相关文章

2026/10/17 8:50:06

2026 AI Agent元年:开发者从零入门、手写实战与避坑指南

2026年是AI Agent元年这个话题,最近在技术群和朋友圈里刷屏刷得有点猛。作为一个常年折腾技术选型、也踩过不少Agent坑的开发者,我对"XX元年"这种带年份的论断向来习惯性打个折扣——但如果你让我在2026年的技术趋势里只押一个方向&#xff0c…

2026/10/17 8:50:06

零基础学网络爬虫:从HTTP请求到数据采集实战全攻略

最近总有朋友问我:“零基础学网络爬虫到底怎么入手?网上教程一大堆,不是上来就讲正则,就是直接甩框架,看完更懵。”这个提问我很能理解。爬虫确实是个“看起来门槛低、实际坑不少”的方向,如果只是照着代码…

2026/10/17 8:50:06

【Bug已解决】How to fine tune BERT on unlabeled data? 解决方案

【Bug已解决】How to fine tune BERT on unlabeled data? 解决方案问题标签:BERT、无标签数据、微调、半监督学习、自监督学习、领域适应适用版本:Transformers 4.x、PyTorch 1.8难度等级:⭐⭐⭐⭐(较高)一、问题描述…

2026/10/17 12:45:24

在HTML页面嵌入Excel在线编辑:从渲染到存回xlsx的完整实现

简介:这份资源面向需要在网页端实现表格在线编辑的前端开发者与企业应用团队,围绕HTML页面集成Excel编辑能力这一常见需求,提供可运行的示例工程与配套实现思路。压缩包共41个文件,约1.44MB,以css样式表、js脚本、png图…

2026/10/17 12:45:24

矩阵置零原地算法:从暴力解到O(1)复杂度的完整推导

刷算法题的人应该都遇到过这样一道“看起来简单,写起来全是坑”的矩阵题:给定一个 mn 的矩阵,一旦发现某个元素等于 0,就要把它所在的整行和整列全部变成 0。题目编号 73,通常被叫做“矩阵置零”。第一眼看到时你会觉得…

2026/10/17 12:45:24

Linux内核内存泄漏排查:从free告警到meminfo与slabinfo实战

1. 从一次线上告警说起:为什么free内存充足却频繁OOM凌晨两点被电话叫醒,监控面板上free -m显示还有3个G的可用内存,但业务进程已经被内核OOM Killer干掉了。这种"内存明明够用却报内存不足"的场景,做过几年运维或后端开…

2026/10/17 12:45:24

WSL2下安装配置Claude Code实战:从零到稳定使用的完整指南

先说结论:如果你手头有一台Windows电脑,日常又要在Linux环境里写代码、跑脚本、对接服务器,那在WSL2里装Claude Code基本是当前体验最好的组合之一。这篇文章不是官网文档的复读,是我把WSL2环境从零搭到Claude Code能日常稳定使用…

2026/10/17 12:45:24

Git仓库批量镜像下载器:备份、迁移与增量同步实践

简介:围绕“repository下载”场景整理的离线依赖资源包,主要面向Java开发者、Maven使用者,以及需要搭建本地或内网依赖仓库的团队。压缩包为rar格式,共收录2000个文件,包体约324MB,核心内容以jar依赖包、po…

2026/10/17 12:40:24

PyCharm 接入 DeepSeek:用 Continue 插件实现 AI 代码补全与对话调试

1. 为什么要在 PyCharm 里接入 DeepSeek1.1 从“写代码”到“聊代码”的转变我用了七八年 PyCharm,从最早的社区版一路用到专业版,说实话,真正让我觉得“回不去”的,不是某个重构功能,而是把大模型接进 IDE 之后的那种…

2026/10/16 5:33:43

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/16 4:03:48

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/15 13:42:16

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/17 0:04:32

PlanarSLAM:面向曼哈顿世界的点线面联合SLAM系统

简介:本资源是面向机器人视觉与SLAM研究者的C开源RGB-D SLAM系统实现,专为结构化室内场景(如办公室、走廊)设计,通过融合点、线、平面三类几何特征并引入“曼哈顿世界”假设,显著提升位姿跟踪鲁棒性与稠密地…

2026/10/17 0:04:32

TDH Workflow调度实战:从DAG依赖驱动到任务编排与故障排查

1. 从一个调度脚本说起:TDH Workflow到底在解决什么问题第一次接触TDH的Workflow,是在一个数据清洗项目里。当时团队每天要跑几十个ETL任务,任务之间有严格的先后依赖——A表清洗完才能跑B表的关联,B表跑完才能触发C表的聚合。最开…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑