摘要:你有没有过这样的经历?自己开发的项目上线后,突然收到用户反馈:“我在你们平台选了半小时商品,填完收货地址点提交,直接跳登录页了!之前填的全没了!” 看到这条反馈时,你是不是瞬间头皮发麻?
你有没有过这样的经历?自己开发的项目上线后,突然收到用户反馈:“我在你们平台选了半小时商品,填完收货地址点提交,直接跳登录页了!之前填的全没了!” 看到这条反馈时,你是不是瞬间头皮发麻?
其实这不是用户操作的问题,更不是产品设计的漏洞,大概率是你没处理好 Token 过期的问题。作为互联网软件开发人员,我们总想着把核心功能实现,但往往就是这种 “小细节”,让用户体验直接跌到底,甚至把用户推向竞品 —— 毕竟没人愿意反复填信息、反复登录。今天就跟你聊聊,我们团队是怎么用请求拦截和队列控制,实现 Token 无感刷新的,让用户再也不会遇到 “填单一半被踢登” 的崩溃场景。
想实现无感刷新,第一步得让后端兄弟配合,用 “双 Token 认证机制”—— 这是基础中的基础,你可别嫌麻烦,少了这一步,后面再怎么改都是白搭。
这里有两个关键 Token,作用完全不一样:
第一个是accessToken,就是我们每次调业务接口时,要在请求头里带的令牌。它的生命周期特别短,一般设 1 小时就行 —— 为啥这么短?因为它每次请求都要暴露,生命周期短能降低被劫持的风险,安全性更高。
第二个是refreshToken,它就一个活儿:帮我们拿新的 accessToken。所以它的生命周期可以长点,比如 7 天,而且必须存在安全的地方,比如 HttpOnly Cookie 里,别随便存在 LocalStorage 里,不然容易被 XSS 攻击。
具体流程很简单:用户登录成功后,后端会同时返回这两个 Token。我们把 accessToken 存在内存或者 LocalStorage 里,后续发请求就带它;等它过期了,就用 refreshToken 悄悄去换个新的 accessToken—— 整个过程用户完全感知不到,这才是 “无感” 的关键。
咱们前端实现无感刷新的核心,就是用 axios 的拦截器 —— 它就像个 “守门人”,能在请求发出去前、响应回来后,帮我们做一些自动处理。你不用自己写复杂的监听逻辑,跟着下面的步骤来,很快就能搞定。
先明确我们的目标:当后端返回 401 状态码(说明 accessToken 过期)时,自动暂停所有后续请求,用 refreshToken 换个新的 accessToken,更新本地存储后,再把之前失败的请求和暂停的请求重新发出去。
下面是我们项目里实际在用的伪代码,你可以直接抄过去改改参数用:
import axios from 'axios';// 1. 创建一个axios实例,统一配置基础路径和超时时间const api = axios.create({ baseURL: '/api', // 换成你们项目的基础路径 timeout: 5000, // 超时时间根据业务调整,一般5秒够了});// 2. 请求拦截器:每次发请求前,自动带上accessTokenapi.interceptors.request.use(config => { const accessToken = localStorage.getItem('accessToken'); if (accessToken) { // 注意格式!一般是Bearer + 空格 + Token,后端要对应 config.headers.Authorization = `Bearer ${accessToken}`; } return config;}, error => { // 请求发送失败时,直接返回错误 return Promise.reject(error);});// 3. 响应拦截器:处理Token过期逻辑// 3.1 用两个变量控制:是否正在刷新Token、存储被暂停的请求let isRefreshing = false; let requestsQueue = ; api.interceptors.response.use( // 响应成功时,直接返回数据 response => response, // 响应失败时,处理401错误 async error => { const { config, response } = error; // 只处理401状态码的错误(排除其他错误,比如网络问题) if (response && response.status === 401) { // 3.2 如果没在刷新Token,就发起刷新请求 if (!isRefreshing) { isRefreshing = true; // 加锁,防止并发请求 try { // 调用后端的刷新Token接口,传refreshToken const { data } = await axios.post('/refresh-token', { refreshToken: localStorage.getItem('refreshToken') }); // 拿到新的accessToken,更新本地存储 const newAccessToken = data.accessToken; localStorage.setItem('accessToken', newAccessToken); // 3.3 把之前暂停的请求,用新Token重新发出去 requestsQueue.forEach(cb => cb(newAccessToken)); requestsQueue = ; // 清空队列 // 3.4 把本次失败的请求也重新发一次 config.headers.Authorization = `Bearer ${newAccessToken}`; return api(config); } catch (refreshError) { // 3.5 如果刷新Token也失败,说明refreshToken也过期了 // 这时候只能清空存储,跳登录页,没法无感了 console.error('刷新Token失败:', refreshError); localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); window.location.href = '/login'; // 跳登录页 return Promise.reject(refreshError); } finally { isRefreshing = false; // 不管成功失败,都解锁 } } else { // 3.6 如果正在刷新Token,就把当前请求存进队列 // 返回一个pending的Promise,等刷新完成后再执行 return new Promise((resolve) => { requestsQueue.push((newAccessToken) => { config.headers.Authorization = `Bearer ${newAccessToken}`; resolve(api(config)); }); }); } } // 不是401错误,直接返回错误 return Promise.reject(error); });export default api;这段代码里有两个 “关键点”,面试时面试官经常问,你得记牢:
第一个是isRefreshing 状态锁:如果一个页面同时发 3 个请求,刚好 accessToken 过期,这 3 个请求都会收到 401。没有这个锁的话,会同时发 3 次刷新请求,既浪费资源,还可能让后端出问题。有了锁,只有第一个请求会去刷新 Token,其他的等着就行。
第二个是requestsQueue 请求队列:正在刷新 Token 时,后面来的 401 请求不能直接丢,得存进队列里。等新 Token 拿到后,再逐个用新 Token 重新发,这样用户的操作就不会中断 —— 比如用户点了 “提交订单” 又点了 “查看物流”,两个请求都会正常执行,不会丢一个。
无感刷新 Token 这个功能,做好了用户完全感知不到,做不好用户就会骂 “这 APP 什么垃圾”。但恰恰是这种 “看不见的细节”,能区分开 “能干活的开发者” 和 “能做好活的开发者”。
我们写代码不只是实现功能,更要考虑用户用起来舒不舒服,考虑系统稳不稳定。你想啊,用户因为 Token 过期丢了半小时的操作数据,他下次还会用你的产品吗?大概率不会了。而你花半天时间实现这个无感刷新,就能保住这些用户,这多值?
所以建议你,看完这篇文章就去检查下自己的项目:Token 过期是怎么处理的?有没有用双 Token?有没有做请求队列?如果还没做,赶紧把这个功能加上 —— 不仅能提升用户体验,面试时跟面试官聊起这个细节,也能让他觉得你 “懂技术更懂用户”,加分不少。
如果在实现过程中遇到问题,比如后端不配合搞双 Token,或者请求队列有 bug,都可以在评论区留言,咱们一起讨论解决!
来源:从程序员到架构师
