
做过uniapp开发的人应该都遇到过这种尴尬用户正在小程序里填收货地址填到一半一个请求返回401页面被一脚踹回登录页刚才填的内容全没了。我前后调过好几个uniapp项目几乎每一个在token过期处理上都要重新踩一遍相同的坑。token过期这件事看起来基础但它背后藏着的方案取舍、并发竞态、体验设计直接决定了用户会不会因为一次“被登出”而流失。做App、小程序、H5跨端项目uniapp用起来确实省事但token的管理逻辑并不会因为跨端而变简单。登录态存在哪、过期怎么判断、401在哪里拦、要不要静默续签、多个请求同时过期怎么办这些问题没有一个标准答案只能根据业务场景去适配。这篇文章我会把自己在项目里用过的、看过的几种token过期处理方案完整梳理一遍从最基础的401跳转到体验更细腻的refresh_token静默续签再到并发场景下的刷新竞态处理、主动预刷新策略最后聊聊不同业务场景下怎么选型。无论你是刚开始用uniapp的初学者还是想把现有登录态优化一轮的开发者都应该能从中找到可以直接落地的东西。1. 先搞清楚token是怎么“过期”的从登录到失效的完整链路1.1 token的“出生”与“消亡”服务端和客户端各管哪一段token不是前端自己生成的它是用户通过账号密码登录或者通过微信授权登录之后服务端签发的一张临时身份凭证。登录接口验证通过服务端返回一串token前端把它存起来后续每个请求带上它服务端靠它识别请求来自哪个用户以及这个用户有没有权限。token的生命周期可以拆成两段来看。第一段是生成发生在登录成功那一刻服务端在token里写入身份信息和过期时间很多项目直接用JWT格式把过期时间放在payload的exp字段里。第二段是失效要么是到达过期时间之后服务端不再认这个token要么是用户主动改密码、管理员封号、token被吊销这些情况也会让token在有效期内提前作废。前端在这条链路里的职责其实只有三件事安全地把token存起来、在请求时按规范带上、感知“失效”的信号然后决定下一步怎么做。很多团队做uniapp的时候日志里看到401就临时改一下请求报错就手动清一下缓存完全没有把这套逻辑系统化。结果就是token过期的处理策略前后端各说各话出了问题只能靠人肉看日志猜。1.2 前端感知过期的三种信号不是只有401一条路一说到token过期很多人第一反应就是HTTP 401。但在真实项目里前端会接收到不止一种信号。HTTP状态码401最直接表示未授权。绝大多数后端在token失效时会返回这个状态码。HTTP 200但业务码表示登录失效有些团队为了网关转发的便利接口统一返回200用业务字段区分。比如code40101代表token过期。本地解码JWT判断如果token是JWT格式前端可以解析payload里的exp字段算出剩余时间提前做应对。有一个非常容易踩的坑如果前端只判断statusCode 401而后端的token过期是通过200业务码返回的那么拦截器根本不会触发。所以封装request之前第一件事是跟后端确认清楚“登录失效”的定义到底是什么。是状态码还是业务码不同接口是否一致这个不确认好后面所有方案都是沙滩上盖楼。我习惯把三种信号在request封装里归一化成一个判断函数方便后续统一处理function isTokenExpired(statusCode, data) { // 后端约定的业务码每个项目按实际情况调整 const bizCode data data.code; if (statusCode 401) return true; if (bizCode 40101 || bizCode 40102) return true; return false; }把判断逻辑收敛在一个地方后面不管是做401跳转还是静默刷新都只需要改这一处。2. 方案一401拦截后跳登录页——最稳妥但最伤体验的保底方案2.1 uniapp里的request统一封装与401拦截最简单、最容易被新手采用的方式是统一封装uni.request在响应里判断登录失效清掉本地登录态然后直接跳转登录页。我写过最朴素的版本是这样const request (options) { return new Promise((resolve, reject) { uni.request({ ...options, header: { Authorization: Bearer ${uni.getStorageSync(token)}, ...options.header }, success: (res) { if (res.statusCode 401) { uni.removeStorageSync(token); uni.removeStorageSync(userInfo); uni.reLaunch({ url: /pages/login/login }); return; } if (res.statusCode 200 res.statusCode 300) { resolve(res.data); } else { reject(res); } }, fail: (err) reject(err) }); }); };这段代码确实解决了“token过期后系统无反应”的问题但用户的体感很糟糕。原因很直观用户正在当前页面操作一个请求背后突然触发reLaunch整个页面上下文全部销毁。还需要注意一个细节uni.reLaunch不能直接跳转tabBar页面。如果你的登录页恰好是tabBar页面得用uni.switchTab。这个坑我在线上踩过页面白屏查了半天才发现是reLaunch打不开tabBar页面。注意401跳转方案并非一无是处。它的代码量最少、逻辑最直白适合内部管理系统、运营后台这类用户量小、容忍度高的场景。对大范围C端产品这个方案最好只当兜底。2.2 为什么“立即踢回登录页”会流失用户场景还原与补救你可以想象一个真实的用户场景顾客在小程序里浏览商品花了几分钟选规格、看评价终于摸到“加入购物车”按钮。点击的瞬间token恰好过期请求401页面秒回登录页。刚才选好的规格、填过的优惠券信息全部丢失。这时候指望他心平气和重新登录大概率是直接关掉小程序走人。所以对C端产品来说裸跳登录页不是一个合理的常规策略。但如果你确实只能做这个方案至少可以做得体面一点跳转前把当前页面路径和关键草稿数据存下来登录成功之后再回跳并恢复草稿。举个例子// 在401拦截里先保存现场 uni.setStorageSync(pendingRoute, { path: /pages/order/confirm, params: { orderId: 123 } }); // 登录页登录成功后尝试回跳 const pending uni.getStorageSync(pendingRoute); if (pending) { uni.redirectTo({ url: ${pending.path}?orderId${pending.params.orderId} }); }这只能挽回一部分损失。如果业务里有复杂表单、多步骤流程用户的操作现场很难靠简单的路径参数完整还原这时候就应该考虑静默刷新方案了。3. 方案二refresh_token静默刷新——体验最好的主流方案3.1 refresh_token机制的核心逻辑为什么是“一长一短”两个token静默刷新的思路很朴素用一个短期的access_token做日常请求再拿一个长期有效的refresh_token去换新的access_token。access_token过期后前端拦截到401带着refresh_token请求刷新接口拿到新的access_token然后重放刚才失败的请求。整个过程用户无感知。打个比方你办了张健身房的月卡有效期30天。你不需要每个月都重新登记全套个人信息卡到期后拿着身份证去前台续办一张就行。refresh_token就是那张身份证训练记录都还在不需要从零开始。后端为什么要设计一长一短两个token而不是直接把token有效期设得很长核心是安全。token在传输和存储过程中一定会被记录、被复制泄漏风险始终存在。有效期越短的token即使泄漏被滥用的窗口期也越短。所以access_token通常只有几小时甚至几十分钟refresh_token虽然有效期长但使用限制更严格比如要求只在服务端保存、做一次性轮换、绑定设备信息。在uniapp项目里接入这套方案先要和后端确认好四件事登录接口返回哪些字段至少包含access_token、refresh_token、过期时间。刷新接口的路径、请求方式和参数形式。refresh_token是否一次性使用用后轮换。token过期时返回的失效信号是401还是业务码。3.2 在uniapp里落地把刷新动作和请求重放串起来我把登录态相关逻辑收拢成一个模块避免每个页面各写一套。先是token的存取清理// utils/auth.js export function setTokens(accessToken, refreshToken) { uni.setStorageSync(access_token, accessToken); uni.setStorageSync(refresh_token, refreshToken); } export function getAccessToken() { return uni.getStorageSync(access_token); } export function getRefreshToken() { return uni.getStorageSync(refresh_token); } export function clearTokens() { uni.removeStorageSync(access_token); uni.removeStorageSync(refresh_token); uni.removeStorageSync(userInfo); }然后是刷新请求。刷新接口不能走统一封装否则401会无限递归。这里要单独调用原始uni.request// utils/auth.js export function refreshToken() { return new Promise((resolve, reject) { uni.request({ url: ${BASE_URL}/api/auth/refresh, method: POST, data: { refresh_token: getRefreshToken() }, success: (res) { if (res.statusCode 200 res.data res.data.code 0) { // 服务端返回新token覆盖本地旧值 setTokens(res.data.data.access_token, res.data.data.refresh_token); resolve(res.data.data.access_token); } else { reject(new Error(refresh failed)); } }, fail: (err) reject(err) }); }); }最关键的部分在request封装里普通请求如果触发token过期先尝试刷新刷新成功后再把原请求重新发起一次。function requestWithRefresh(options) { return new Promise((resolve, reject) { uni.request({ ...options, header: buildHeader(), success: (res) { if (!isTokenExpired(res.statusCode, res.data)) { resolve(res); return; } // token过期尝试静默刷新 refreshToken().then(() { // 用新token重新请求 uni.request({ ...options, header: buildHeader(), success: (res2) resolve(res2), fail: (err2) reject(err2) }); }).catch(() { // 刷新失败跳登录 clearTokens(); uni.reLaunch({ url: /pages/login/login }); reject(new Error(refresh token expired)); }); }, fail: (err) reject(err) }); }); }这个版本能跑通但会让“刷新动作”重复执行。一个页面同时发多个请求、同时返回401时每个请求都会触发一次refreshToken这就引出了下一节的并发竞态问题。3.3 refresh_token也会失效续期失败后的兜底处理refresh_token虽然有效期长但绝不是永久的。刷新接口返回失败时说明refresh_token已经过期或吊销这时候不能再尝试刷新必须跳登录。跳转之前最好弹一个轻提示告诉用户当前登录状态已失效需要重新登录不要让用户毫无防备地被踢走。uni.showModal({ title: 登录已过期, content: 身份信息已失效请重新登录, showCancel: false, confirmText: 去登录, success: () { clearTokens(); uni.reLaunch({ url: /pages/login/login }); } });这里还有一个特别容易翻车的地方很多后端在refresh_token使用过一次之后会做“轮换”旧refresh_token作废返回一个新的refresh_token。前端在刷新成功时如果只更新access_token忘了更新refresh_token那么下一次刷新时就会带着旧的refresh_token去请求服务端直接拒绝。这个问题非常难排查日志上看只是刷新失败实际上是你自己存了一个过期值。注意刷新接口一旦成功必须同步替换本地保存的access_token和refresh_token两个都要落盘不能只更新其中一个。4. 并发刷新竞态页面一次加载触发多个401的崩溃现场4.1 复现过程为什么4个请求会同时触发4次刷新我自己在做静默刷新时本地自测一切正常一上真机就出问题。后来排查了半天根因是并发。假设用户进首页onLoad同时发起4个请求查用户信息、查商品列表、查购物车数量、查消息未读数。这时access_token正好过期4个请求会同时返回401而我的拦截器逻辑是“遇到401就调refreshToken”于是200毫秒内连续调了4次刷新接口。这会带来几个连锁问题刷新接口被高频调用瞬时QPS冲高服务端压力直接上去。如果服务端做了refresh_token轮换第一个刷新请求成功后旧refresh_token已作废剩下3个刷新请求全部失败。即便服务端不做轮换前端也可能出现多个刷新loading提示、多次跳转体感和排查都很差。问题本质是一个过期的token在短时间内被多个并发请求重复发现而“刷新”这个修复动作不应该每个人都做一遍。正确的逻辑是让第一个发现者去刷新其他请求等着用同一个刷新结果。4.2 解法共享Promise单例刷新与请求重放解决思路用一句话说用一个全局Promise变量保证同一时间只会发一次刷新请求。第一个请求触发刷新时把Promise实例存到全局其他请求发现token过期时看到全局已有刷新Promise不再发新请求直接等待同一个Promise完成。let refreshPromise null; function getRefreshPromise() { if (!refreshPromise) { refreshPromise refreshToken().finally(() { refreshPromise null; }); } return refreshPromise; }关键点在最后一行刷新结束之后要把refreshPromise清空。否则下次token过期时拿到的是已经resolve的旧Promise直接绕过刷新步骤导致所有请求拿着旧token继续走又回到401死循环。封装进request之后长这样function requestWithConcurrentGuard(options) { return new Promise((resolve, reject) { uni.request({ ...options, header: buildHeader(), success: (res) { if (!isTokenExpired(res.statusCode, res.data)) { resolve(res); return; } getRefreshPromise().then(() { // 刷新成功后重放原请求 uni.request({ ...options, header: buildHeader(), success: (res2) resolve(res2), fail: (err2) reject(err2) }); }).catch(() { clearTokens(); uni.reLaunch({ url: /pages/login/login }); reject(new Error(refresh failed)); }); }, fail: (err) reject(err) }); }); }这个方案在绝大多数业务场景里足够用。它的局限是刷新成功后所有等待的请求会同时重放瞬间打一波请求到服务端一般业务完全扛得住不需要再额外做队列限流。4.3 刷新失败时的兜底避免死循环和重复跳转刷新失败有一种特别恼人的情况刷新接口本身也返回了401。如果你没有区分“普通业务请求401”和“刷新请求401”代码会重入拦截逻辑触发刷新再失败再重试直到超时。用户看到的是接口几十秒不返回以为是卡死实际上是死循环。我的处理方式是刷新请求绝不走统一request封装单独用原始uni.request刷新失败直接进入clearTokens和跳转流程不在刷新逻辑内部做任何递归。这样就从结构上断开了死循环的可能。跳转本身也要防重复。多个请求同时等待刷新时一旦刷新失败每个请求的catch都会执行一次跳转代码用户会看到页面闪一下又跳一下。可以对跳转加一个节流标记let isRedirecting false; function toLoginPage() { if (isRedirecting) return; isRedirecting true; uni.reLaunch({ url: /pages/login/login, complete: () { isRedirecting false; } }); }并发场景下任何一个疏忽都会被放大N倍。这个跳转节流看起来不起眼但在实际故障时能少收一堆奇怪的反馈。5. 主动刷新与持久化把“被动等过期”变成“提前做准备”5.1 解析JWT的payload确认token剩余寿命静默刷新虽然体验好但它是在“401已经发生”之后才补救的。能不能在token还没过期之前就把它换掉可以前提是你的token是JWT格式payload里带exp字段前端能解码。JWT是header.payload.signature三段式结构payload是base64编码后的JSON。解码本身不难但微信小程序的运行环境对atob支持并不稳定需要做一点兼容处理function parseJwt(token) { try { const parts token.split(.); if (parts.length ! 3) return null; const base64Url parts[1]; const base64 base64Url.replace(/-/g, ).replace(/_/g, /); const bytes decodeURIComponent( Array.prototype.map.call( (typeof atob function ? atob(base64) : decodeBase64(base64)), (c) % (00 c.charCodeAt(0).toString(16)).slice(-2) ).join() ); return JSON.parse(bytes); } catch (e) { return null; } }拿到exp之后剩余时间就是payload.exp * 1000 - Date.now()。有的系统里exp是秒级时间戳有的可能是毫秒这里一定要和后端确认清楚否则算出来的刷新时间全是错的。5.2 定时器预刷新H5、小程序、App的差异与注意事项知道了剩余时间就可以在过期之前提前触发刷新。我通常在剩余不足5分钟时做一次预刷新跑完再把下次刷新时间排上。let refreshTimer null; function schedulePreRefresh(token) { if (refreshTimer) clearTimeout(refreshTimer); const payload parseJwt(token); if (!payload || !payload.exp) return; const remainMs payload.exp * 1000 - Date.now(); // 提前5分钟留足网络延迟余量 const triggerMs Math.max(remainMs - 5 * 60 * 1000, 0); refreshTimer setTimeout(async () { try { await refreshToken(); } catch (e) { // 预刷新失败不用提示让401拦截兜底 } }, triggerMs); }这里有一个小程序端特有的大坑小程序切到后台后定时器会被系统挂起。你排了一个10分钟后执行的定时器用户把小程序切走再切回来可能已经过了一个小时定时器却还没有执行。所以每次App进入前台onShow时要重新检查一次当前token的有效期如果剩余时间已经接近过期立刻刷新一次。H5端也存在浏览器定时器节流的问题App端相对好一些。最靠得住的兜底仍然401拦截重放预刷新只能降低401的发生概率不能替代拦截。注意小程序切后台会挂起定时器跨端项目里预刷新必须配合onShow检查否则切前台后第一次请求大概率仍然拿的是过期token。5.3 客户端token存储的安全意识uni.setStorageSync的边界token在客户端存储最常规的路径就是uni.setStorageSync。小程序端对应wx.setStorageSyncApp端是本地存储或数据库H5端是localStorage。这些位置对服务端来说都是“不可信区域”拿到设备、打开浏览器控制台理论上都能看到token内容。几个实操建议不要把access_token和refresh_token放在同一个key里清理登录态时出错的风险会更高。敏感业务不能只靠前端存token要结合设备指纹、二次验证等手段。token不要出现在URL参数里。有些项目登录成功后用URL拼接传token日志系统一旦记录这类地址等于把身份凭证直接写进了日志。小程序端Storage有大小限制个别基础库有单Key容量上限。token本身很小但不要把整个userInfo、大量缓存数据全塞一个key里。还有一个容易忽略的问题当登录状态失效多个页面同时依赖Storage里的用户信息时清理脏数据必须一次到位。否则登录页已经打开旧用户数据还残留在Storage或globalData里新用户登录后个人页面会短暂闪出上一个用户的信息这就是涉及账号隐私的事故了。6. 生产环境选型建议与几个真实踩坑记录6.1 先判断你的用户能不能接受“重新登录”处理方案没有绝对的好坏只有适不适合。我按项目类型整理了一张快速参考表业务场景推荐方案原因内部管理系统、运营后台401跳登录页用户量小、可接受重登代码最简单C端小程序、商城类Apprefresh_token静默刷新并发守卫用户体验优先避免频繁登录流失用户金融、支付类高安全场景access_token短时效401跳登录或二次验证安全第一宁可牺牲流畅度混合敏感级别场景低敏页面静默刷新高敏操作强制验登录密码分层处理兼顾体验与安全对接第三方单点登录以第三方失效回调为准本地token只做接收登录状态由外部系统驱动核心就一句话先问业务方你的用户能不能接受“重新登录”。如果答案是“不能”就踏踏实实把静默刷新做完整如果答案是“可以”也别过度设计用最简单的方案留足兜底即可。6.2 复盘真实的坑字段错位、环境差异、重试死循环最后说几个我在不同uniapp项目里真实遇到的问题都很隐蔽网上资料也不多。第一个是“刷新接口返回200但token没换新”。有些后端刷新接口的返回字段跟登录接口不一致有的叫token有的叫accessToken有的直接嵌在data中。前端解构时字段写错setStorageSync存进去一个undefined后续请求带上undefined字符串去访问排查非常痛苦。我的建议是刷新成功后立刻做防御性校验token字符串存在且长度合理才落盘否则直接按刷新失败处理if (typeof token string token.length 20 typeof newRefreshToken string) { setTokens(token, newRefreshToken); } else { reject(new Error(invalid refresh response)); }第二个是“App端登录态和H5端不同步”。同一个uniapp工程同时发App和小程序两端的Storage不互通。用户在App登录了在H5页面又要求登录这本身是正常的但产品往往不理解以为是bug。开发阶段就要把预期沟通清楚否则改来改去都是白费。第三个是“401重试死循环”。只要重试逻辑没有次数上限一旦刷新接口故障或者后端时钟跳变所有请求都会进入“401→刷新→重放→401→刷新”的循环。我在request封装里加了一个retry标记只有第一次401才触发刷新重放重放后再次401就直接跳登录不再继续重试。function requestCore(options, { isRetry false } {}) { return new Promise((resolve, reject) { uni.request({ ...options, header: buildHeader(), success: (res) { if (isTokenExpired(res.statusCode, res.data) !isRetry) { getRefreshPromise().then(() { resolve(requestCore(options, { isRetry: true })); }).catch(() toLoginPage()); } else { resolve(res); } }, fail: reject }); }); }这层保护非常关键线上故障往往就是这样一个循环拖垮了整个接口层。第四个也是最后一个尽量在开发早期就把后端对token的“失效语义”统一好。前端关心的是“状态码加业务码”后端关心的是“JWT过期、token吊销、refresh过期”这些细分类别两边词汇不一致联调必然浪费大量时间。最好在接口文档里把登录失效的定义、刷新接口的成功失败返回结构、refresh_token的轮换机制全部写死前端就不用靠猜。如果一定要给一个最重要的建议token过期方案要在项目第一天就设计进去不要等联调甚至上线后收到“用户被踢下线”的投诉再补。schema层面定好访问令牌和刷新令牌的交互前端把拦截器、并发守卫、重试上限、跳转节流一套搭完后续业务只需要改配置。uniapp跨端项目尤其要早定小程序和H5的Storage差异、App端的生命周期差异后期返工成本远高于一开始就规划好。