去年年底,我们的 App 上线新版本之后,客服那边接到了一批奇怪的反馈。用户说”点一下首页就闪一下登录页,然后自己又回到首页了”。闪现时间很短,但确实存在,眼尖的用户能看见。
定位这个问题花了大概半天。复现路径是这样的:用户进首页,首页同时发起三个请求——用户信息、消息未读数、订单列表。这时 access_token 刚好过期。三个请求几乎同时返回 401,然后各自触发一次刷新动作。刷新接口被调用三次,第一次成功,access_token 换了新的。后两次用的是已经过期的 refresh_token,服务器拒绝,返回 401。而客户端的逻辑是”刷新失败就登出”,于是就有了那个闪现。
这个问题在 Web 端有一堆成熟的解法和现成的库,但在 uni-app 里,很多人第一次写请求封装的时候不会想到它,等发现了又不知道从哪儿下手。这篇文章就把整个方案拆开讲一遍。
一、为什么小程序端的处理比 Web 端复杂
先说说这个问题的”地形”。
Web 端最常用的方案是 axios 拦截器。axios 内部有完善的请求生命周期管理,你只要在响应拦截器里判断 401,处理完刷新再重放请求就行。整个流程都在拦截器这一层完成,业务层感知不到。
uni-app 里的 uni.request 没有这套东西。它只有 success、fail、complete 三个回调,你需要自己包一个 Promise,自己设计拦截点,自己处理重放。这还没完,uni-app 要跨好几端跑,端和端之间的差异会渗透到拦截器里来。
列几条常见的坑:
H5 端走的是浏览器 XHR,Cookie 可以自动携带;小程序端和 App 端不认 Cookie,一切认证信息都得靠请求头。H5 端的 401 可能伴随跨域相关行为,比如预检请求也会走一遍;小程序端没有预检这一步。App 端在 iOS 和 Android 上对请求超时的默认行为不同,Android 上超时时间如果设太短,用户从后台切回前台时请求很容易超时。
这些差异看着零碎,但它们会影响你的刷新逻辑——特别是”什么时候算一次刷新失败”这个判断。
二、拦截器的骨架:三层结构
我要讲的方案分三层,从上到下依次是业务层、拦截层、传输层。
业务层调用 request.get('/user/profile') 这种形式,完全不知道刷新逻辑存在。拦截层负责统一注入 Authorization、判断 401、管理刷新状态、重放请求。传输层是对 uni.request 的最薄封装,保证返回 Promise、处理平台差异。
先从传输层写起:
// utils/transport.js
export function rawRequest(options) {
return new Promise((resolve, reject) => {
const task = uni.request({
...options,
success: (res) => resolve(res),
fail: (err) => reject(err),
});
// 把 task 挂到 options 上,方便上层做超时取消
if (options.__attachTask) {
options.__attachTask(task);
}
});
}
这里为什么不直接用 uni.request 的 Promise 模式?因为 Promise 模式拿不到 RequestTask,也就没法中途 abort。有些业务需要这个能力,比如用户切走页面时取消进行中的请求,所以从一开始就保留这个入口。
拦截层是核心。先写一个最简版的:
// utils/request.js
import { rawRequest } from './transport';
import { getToken, refreshToken, clearAuth, gotoLogin } from './auth';
let refreshing = null;
export async function request(options) {
const token = getToken();
const res = await rawRequest({
...options,
header: {
'Content-Type': 'application/json',
...options.header,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (res.statusCode !== 401 || options.__retried) {
return res;
}
// 已经在刷新中,等其他请求发起的那次刷新
if (!refreshing) {
refreshing = refreshToken().finally(() => {
refreshing = null;
});
}
try {
await refreshing;
} catch (err) {
clearAuth();
gotoLogin();
throw err;
}
return request({ ...options, __retried: true });
}
这段代码里有三个关键点。
第一点:refreshing 是 Promise,不是布尔值
如果用一个 isRefreshing = true 的布尔标志,其他请求遇到 401 之后只能轮询等待,或者干脆拒绝。用 Promise 就不一样了——所有等待者可以 await 同一个 Promise,刷新完成的那一刻它们一起被唤醒。这个用法在 Promise 里是合法的,一个 Promise 可以被任意多个消费者 await。
注意 .finally() 里把 refreshing 置回 null 的时机。它必须在 await refreshing 之后仍然有效,也就是要在 Promise settle 之后。因为 Promise 的 settle 是异步的,.finally 回调会在所有 await 的等待者被唤醒之后才执行。这个顺序保证了后续新来的 401 请求能看到干净的 null 状态。
第二点:__retried 标记
重放请求时带上 __retried: true,逻辑里遇到 401 就直接返回。这样避免了两个麻烦场景:刷新接口本身返回 401 时死循环;服务端因为某些非认证原因(比如权限被收回)持续返回 401 时,无限重放。
用属性名加双下划线是个人习惯,任何前缀都行,关键是要保证业务层不会误传这个字段。也可以作为第二个参数传,但那样得改函数签名,不如挂在 options 上干净。
第三点:刷新失败时立即清状态
clearAuth() 和 gotoLogin() 这两个动作不能等到跳转完成,必须同步执行。因为可能有其他请求也在 await refreshing,刷新失败后它们会抛异常。如果清理是异步的,这些请求可能会在下一次刷新尝试时,把已经失效的 Token 又带上去。
另外 gotoLogin 需要做去重。多个等待者同时抛异常,每个都调一次跳转,页面栈会连续 push 多个登录页。常见做法是在 gotoLogin 里加一个时间窗口去重,或者判断当前页面是否已经是登录页。
// utils/auth.js
let jumpLock = false;
export function gotoLogin() {
if (jumpLock) return;
jumpLock = true;
const pages = getCurrentPages();
const current = pages[pages.length - 1];
if (current?.route === 'pages/login/login') {
jumpLock = false;
return;
}
uni.reLaunch({
url: '/pages/login/login',
complete: () => {
setTimeout(() => { jumpLock = false; }, 500);
},
});
}
用 reLaunch 而不是 navigateTo,是为了把页面栈清干净。用户登出之后按返回键不应该能回到需要登录的页面。
三、刷新接口本身也需要处理
上面这段代码有个隐藏问题:refreshToken() 内部如果也用同一个 request 函数去调接口,它会不会触发 401 拦截?
会。而且一旦触发,就会出现刷新接口请求刷新接口的递归。虽然 refreshing 当前不为 null,不会发起新的刷新,但这次的 await refreshing 会死锁——它在等自己完成。
解决办法是让刷新接口走一条独立的通道:
// utils/auth.js
import { rawRequest } from './transport';
let refreshing = null;
export function refreshToken() {
if (refreshing) return refreshing;
refreshing = (async () => {
const refresh = getRefreshToken();
if (!refresh) {
throw new Error('NO_REFRESH_TOKEN');
}
const res = await rawRequest({
url: `${BASE_URL}/auth/refresh`,
method: 'POST',
data: { refresh_token: refresh },
header: { 'Content-Type': 'application/json' },
});
if (res.statusCode !== 200 || !res.data?.access_token) {
throw new Error('REFRESH_FAILED');
}
setToken(res.data.access_token);
if (res.data.refresh_token) {
setRefreshToken(res.data.refresh_token);
}
return res.data.access_token;
})().finally(() => {
refreshing = null;
});
return refreshing;
}
注意这里用了 rawRequest 而不是 request,直接跳过拦截层。同时刷新的单例状态也移到了 auth.js 里,因为现在是它自己管自己。
对应的,request.js 里就不需要 refreshing 变量了,只要在遇到 401 时 await refreshToken() 就行。两个模块之间的耦合更少,测试也更方便。
// utils/request.js 更新后的核心逻辑
import { rawRequest } from './transport';
import { getToken, refreshToken, clearAuth, gotoLogin } from './auth';
export async function request(options) {
const token = getToken();
const res = await rawRequest({
...options,
header: {
'Content-Type': 'application/json',
...options.header,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (res.statusCode !== 401 || options.__retried) {
return res;
}
try {
await refreshToken();
} catch (err) {
clearAuth();
gotoLogin();
throw err;
}
return request({ ...options, __retried: true });
}
这样拆分之后,还有一个额外的好处:refreshToken() 可以单独测试,不需要构造完整的请求场景。
四、一个容易被忽略的细节:请求的排队问题
写到这里,刷新逻辑基本正确了。但还有一个隐藏问题没处理。
假设用户进入页面后发起了 5 个请求,其中 2 个先返回正常数据,另外 3 个返回 401。refreshToken 开始执行,需要 300 毫秒。这 300 毫秒里,第 4 个请求刚好发出去,带的还是老 token,自然也是 401。它会 await refreshToken()——此时 refreshing 已经有了,直接复用,没问题。
但是再假设,刷新在第 300 毫秒完成,第 4 个请求在第 301 毫秒发出呢?这时候 token 已经更新了,请求会带新 token,一切正常。问题不大。
真正的麻烦在另一头:在第 300 毫秒完成刷新,但第 4 个请求在 299 毫秒发出,同时在 301 毫秒收到 401 响应。这时请求里的 token 是老 token,但刷新已经完成、refreshing 被清空了。这个请求会再触发一轮刷新,白白多调一次接口。
这个问题在实际项目里不常见,因为请求往返时间通常远大于这个窗口。但如果你的后端响应特别快、网络特别好,就有可能撞上。
处理方式是在请求发出之前记录当前 token,401 回来之后比一下:如果发现 token 已经变了,说明其他请求已经刷新过,直接用新 token 重放就行,不需要再刷。
export async function request(options) {
const tokenBefore = getToken();
const res = await rawRequest({
...options,
header: {
'Content-Type': 'application/json',
...options.header,
...(tokenBefore ? { Authorization: `Bearer ${tokenBefore}` } : {}),
},
});
if (res.statusCode !== 401 || options.__retried) {
return res;
}
const tokenNow = getToken();
// token 变了,说明别的请求已经刷过一轮,直接用新 token 重放
if (tokenNow && tokenNow !== tokenBefore) {
return request({ ...options, __retried: true });
}
try {
await refreshToken();
} catch (err) {
clearAuth();
gotoLogin();
throw err;
}
return request({ ...options, __retried: true });
}
这段判断代价很小,但确实解决过问题。我印象里它第一次起作用是在一个内网环境里,后端响应时间大概是 8ms 左右——比 refreshToken 之前那一轮 window 还快。
五、多端差异的处理
前面说过,uni-app 的端差异会影响拦截器。这里集中说一下跟 Token 刷新直接相关的几条。
Header 的大小写问题
H5 端 header 的键名比较宽容,服务器一般会按 HTTP 规范做大小写不敏感匹配。但微信小程序端有一次变更之后,header 的键名会按原样发送,如果服务器严格区分大小写,可能会出问题。
稳妥的做法是统一用 Authorization 这个标准写法,别写成 authorization 或者 AUTHORIZATION。这个看起来是小事,但见过有项目因为这个问题在微信小程序端认证失败。
Token 存储的位置
// 跨端统一的存储抽象
// utils/storage.js
const KEY_TOKEN = 'auth_access_token';
const KEY_REFRESH = 'auth_refresh_token';
export function getToken() {
try {
return uni.getStorageSync(KEY_TOKEN) || '';
} catch (e) {
return '';
}
}
export function setToken(token) {
uni.setStorageSync(KEY_TOKEN, token);
}
uni.setStorageSync 在所有端都是同步的,底层分别是 localStorage、wx.setStorageSync、原生存储。它不区分小程序和 App,用起来很省事。
但它有一个要注意的地方:同步存储在小程序端会在主线程执行,如果内容很大,会阻塞渲染。Token 一般不长,几百字节以内没问题。但 uni.getStorageSync 在 App 端每次都会走原生桥,如果拦截器里每个请求都调一次,请求量大的时候开销会显现出来。
优化方式是在内存里缓存一份,只在刷新时更新:
let memToken = null;
export function getToken() {
if (memToken !== null) return memToken;
try {
memToken = uni.getStorageSync(KEY_TOKEN) || '';
} catch (e) {
memToken = '';
}
return memToken;
}
export function setToken(token) {
memToken = token;
uni.setStorageSync(KEY_TOKEN, token);
}
内存缓存和存储的同步逻辑在这里是安全的,因为只有一个写入点——就是 setToken 本身。清除 Token 时也要记得同时清内存里的 memToken。
请求超时时间
uni.request({
timeout: 15000,
// ...
});
默认超时时间在不同端的表现不一样。App 端的默认值比较长,H5 端大概是浏览器默认值。建议显式指定,因为超时时间太长会让 refreshToken 卡住很久,导致后续请求都在排队。
如果刷新接口本身超时了,refreshToken 会抛异常,走 clearAuth 分支。这是我们期望的行为——刷新失败的时候不应该继续等。
不过要注意一个细节:在弱网环境下,一次刷新超时可能只是暂时的。直接登出用户会显得很粗暴。可以考虑对刷新失败做一次重试,或者进入”离线模式”而不是直接登出。这个策略取决于业务场景,没有标准答案。
六、六个实际踩过的坑
坑一:把 refreshing 放在 Options 对象上
// 错误写法
options.__refreshing = true;
有人会把刷新状态存在请求的 options 里,想让同一批请求共享。问题是 options 对象是每次调用时新建的,不同请求拿到的不是同一个对象。结果是刷新状态完全不共享,跟没写一样。
刷新状态必须是模块级的单例,写在文件顶部。
坑二:refreshToken 里 catch 了但没抛
// 错误写法
export async function refreshToken() {
try {
// ... 刷新逻辑
} catch (err) {
console.error(err);
// 没有 throw,导致调用方的 await 正常返回
}
}
这种写法会静默吞掉错误。调用方 await refreshToken() 之后以为刷新成功了,重放请求时带着老 token,又收到 401,进入 __retried 分支直接返回失败响应。业务层拿到一个奇怪的 401 却不知道为什么。
规则:吞异常的函数不能出现在异步流程的中间环节。要么抛,要么返回一个明确的状态值,让调用方判断。
坑三:刷新成功但业务接口再次 401
这种情况出现过一次,原因是后端的一次升级——refresh_token 换出去的 access_token 被签成了错误的过期时间。前端这边因为 __retried 保护,没有进入死循环,但用户会看到”登录成功之后点什么都提示未登录”的幻觉。
处理方式是在 __retried 分支下加日志上报:
if (res.statusCode === 401 && options.__retried) {
reportError({
type: 'retry_401',
url: options.url,
tokenChanged: tokenNow !== tokenBefore,
});
}
这个日志在排查线上问题时特别有用,因为它能告诉你”是不是刷新成功的 token 是无效的”。
坑四:跳转登录页时的页面栈
uni.navigateTo({ url: '/pages/login/login' });
如果此时页面栈已经很深(用户点了很多层),navigateTo 会失败——小程序端最多十层,App 端也有限制。而且登录成功之后用户按返回键会回到之前那个需要登录的页面。
用 reLaunch 是标准做法。但如果项目里登录页和主页面是同一个应用(比如 H5 部署在同一个域名下),还需要考虑登录成功后跳回原页面的需求。这种场景下需要在跳转前记下当前路径,登录成功后 replace 回去。
坑五:并发刷新时 UI 上的”闪一下”
前面说过 gotoLogin 里去重了跳转,但还有一种”闪”是提示。比如刷新失败时弹一个 toast”登录已过期”,如果三个请求同时失败,可能同时弹三个。或者一个弹完关闭,另一个又弹。
解决方案是把”是否已经提示过”当成状态的一部分,跟 gotoLogin 的锁定逻辑放在一起。或者干脆用 uni.hideToast() 先清掉再弹。
坑六:Token 在新旧版本 App 之间不兼容
App 端有个特殊情况:用户更新了 App,但手机上的存储里还留着老版本写的 Token 格式。如果新版本的解析逻辑假定了某种数据结构(比如 access_token 一定是 JWT),老数据就会解析失败。
稳健的做法是在读取 Token 时做一次格式校验,发现不对就当没有 Token 处理,直接走登录流程。宁可让用户重新登录一次,也不要因为格式错乱导致奇怪的行为。
function isValidToken(token) {
if (typeof token !== 'string') return false;
if (token.length < 10) return false;
// 如果确定是 JWT,可以检查分段数
if (token.split('.').length !== 3) return false;
return true;
}
七、怎么验证这套逻辑
光看代码正确不够,这套流程有很多交错的状态,必须实际测。
最简单的做法是后端加一个测试开关,可以通过请求头 X-Force-401 让任何接口返回 401。这样前端可以在一次交互中触发多个 401,观察刷新逻辑是否按预期合并。
后端也可以把 Token 的有效期设得极短,比如 5 秒。用户操作两下就会过期,自动化测试里跑一遍完整的交互路径就能覆盖大部分场景。
验证的重点有三个:刷新接口被调用的次数(应该少于等于 401 请求的数量);并发等待者是否都被唤醒了(不能有请求永远挂着);刷新失败后的登出是否只跳一次登录页。
另外强烈建议加一行日志,记录每次刷新的时间戳和触发原因。上线之后看这个日志,能立刻发现”某段时间内刷新频率异常”,这往往意味着后端 Token 签发的有效期出了问题,或者前端在某个地方漏了 token 判断。
八、写在最后
Token 无感刷新这件事,业务层写起来没有半点感觉,但拦截层里藏了不少状态机的复杂度。这篇文章里很多坑,都是当时一个个查出来的。
回头看,最关键的设计决定是三点:把刷新状态做成 Promise 而不是布尔;把 refreshToken 抽到独立的模块、走独立的传输通道;在 401 处理里加 token 前后比对。前两条是所有 Web 端教程都会提的,第三条是实践里才发现的。
如果你正在写一个新的 uni-app 项目,建议从第一天就把这套逻辑搭起来。等到线上出问题再补,用户已经跑光了一批。
顺带说一句,这套模式不只用在 Token 刷新上。任何”某个错误需要先完成一次副作用再重试”的场景——比如隐私授权、动态权限申请、设备指纹获取——都能套用同样的骨架。看懂了刷新这块,其他的改写起来会很快。

