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 出现后是否自动刷新、是否重试成功、队列里的请求是否都恢复了。这个验证过程比看代码靠谱得多,很多隐藏的并发问题只有实际跑起来才能发现。