先说个场景,估计做过小程序的人都不陌生。
首页 onLoad 里并发发了五个请求:用户信息、订单列表、优惠券、消息数、推荐位。用户手机放着没动,过了半小时再切回来,五个请求几乎同时到达服务端,全部返回 401。
如果没做任何处理,结果就是五次刷新 token 的请求一起打过去。而后端的 refresh token 是做轮换的——用一次就作废、发一个新的。于是五个请求里只有第一个能成功,剩下四个拿着已经失效的旧 refresh token 去换,全部被拒。前端拿到四个刷新失败,直接把人踢回登录页。
用户什么也没干,就看着页面闪了一下,重新登录。
这篇文章就把这个坑从头填一遍。代码基于 uni-app 的 Vue 3 项目,但思路放到任何前端框架里都通用。
先把 uni.request 的脾气摸一摸
有件事得先确认清楚:uni.request 调用之后返回的是一个 RequestTask 对象,它的主要用途是让你在需要的时候调 .abort() 把请求掐掉。至于 Promise 风格,不同平台、不同版本的表现并不完全一致,有些场景下能链式写,有些场景下会因为没传回调而行为诡异。
所以结论很直接:别指望原生的返回值,自己包一层 Promise,把所有分支都捏在自己手里。
第一步:一个能被复用的最小封装
先建两个文件,一个管 token 存取,一个管请求。
// utils/auth.js
const ACCESS_KEY = 'access_token'
const REFRESH_KEY = 'refresh_token'
export function getAccessToken() {
return uni.getStorageSync(ACCESS_KEY) || ''
}
export function getRefreshToken() {
return uni.getStorageSync(REFRESH_KEY) || ''
}
export function saveTokens(data) {
uni.setStorageSync(ACCESS_KEY, data.access_token)
if (data.refresh_token) {
uni.setStorageSync(REFRESH_KEY, data.refresh_token)
}
}
export function clearTokens() {
uni.removeStorageSync(ACCESS_KEY)
uni.removeStorageSync(REFRESH_KEY)
}
然后是请求主体。这一步先把非 401 的路径走通:正常返回、业务错误码、HTTP 错误、网络失败,四种情况分别落到 resolve 还是 reject。
// utils/request.js
import { getAccessToken, getRefreshToken, saveTokens, clearTokens } from './auth'
const BASE_URL = 'https://api.example.com'
const TIMEOUT = 15000
export function request(options) {
return send(options, false)
}
function send(options, isRetry) {
const token = getAccessToken()
return new Promise((resolve, reject) => {
uni.request({
url: options.url.startsWith('http') ? options.url : BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || TIMEOUT,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...options.header
},
success(res) {
const statusCode = res.statusCode
const data = res.data
if (statusCode === 401 || (data && data.code === 401)) {
if (isRetry) {
// 换成新 token 之后还是 401,说明这个身份已经不可信了
reject(makeError('UNAUTHORIZED', data))
logout()
return
}
enqueue(() => send(options, true), resolve, reject)
return
}
if (statusCode >= 200 && statusCode < 300) {
if (data && typeof data === 'object' && 'code' in data && data.code !== 0) {
reject(makeError(data.message || '业务异常', data))
return
}
resolve(data)
return
}
reject(makeError('HTTP ' + statusCode, data))
},
fail(err) {
reject(makeError(err.errMsg || '网络异常', err))
}
})
})
}
function makeError(message, raw) {
const err = new Error(message)
err.raw = raw
return err
}
这里 isRetry 是个关键参数。它标记「这一发是不是已经换过 token 重发的」。没有它,代码在刷新成功后仍返回 401 的情况下会无限重试,最后把浏览器页面卡死。
第二步:队列 + 单例刷新
这是整篇文章的核心,也是那个事故的解法。
思路只有两条:
- 同一时刻只允许存在一个刷新请求,其余的一律排队等着。
- 刷新成功之后,把排队的请求挨个用新 token 重发一遍。
代码如下:
// 刷新中的 Promise,非空表示正在刷新
let refreshTask = null
// 等待刷新的请求,每一项是 { retry, resolve, reject }
const waiting = []
// 防止并发跳登录页
let loggingOut = false
function enqueue(retry, resolve, reject) {
waiting.push({ retry, resolve, reject })
startRefresh()
}
function startRefresh() {
if (refreshTask) return
refreshTask = doRefresh().then(
newToken => {
refreshTask = null
flush(null, newToken)
},
err => {
refreshTask = null
flush(err, null)
logout()
}
)
}
function flush(err, token) {
const queue = waiting.splice(0)
queue.forEach(item => {
if (err) {
item.reject(err)
return
}
item.retry().then(item.resolve).catch(item.reject)
})
}
注意队列里存的是 retry 函数,不是请求参数。这个设计后面讲上传接口的时候会显出价值——因为上传没法简单地「用同样的参数重发一次」,它需要重新执行整个 uploadFile 调用。
refreshTask = null 的位置也值得说一下。我特意把它放在了 flush 之前,而不是塞到 finally 里。因为 flush 会触发一批新请求,万一其中某个又碰到 401,那时 refreshTask 已经是 null 了,能立刻开启下一轮刷新,不会卡住。
第三步:刷新接口本身必须另起炉灶
这一段如果不特别提醒,几乎人人都会踩。
刷新 token 的那个接口,绝对不能走上面那个 send 封装。否则一旦刷新接口自己返回 401(比如 refresh token 过期了),它会被再次送进队列,触发新的刷新,而新的刷新又走这条路……调用栈直接爆掉。
function doRefresh() {
const refreshToken = getRefreshToken()
if (!refreshToken) {
return Promise.reject(new Error('NO_REFRESH_TOKEN'))
}
// 这里用最原始的 uni.request,不走 send
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + '/auth/refresh',
method: 'POST',
data: { refresh_token: refreshToken },
header: { 'Content-Type': 'application/json' },
success(res) {
const body = res.data
if (res.statusCode === 200 && body && body.data && body.data.access_token) {
saveTokens(body.data)
resolve(body.data.access_token)
} else {
reject(new Error('REFRESH_FAILED'))
}
},
fail: reject
})
})
}
第四步:登出只做一次
五个请求同时失败,每个都会走到登出逻辑,如果不加锁,页面会跳五次。虽然 reLaunch 最终结果一样,但中间的过程会有明显闪烁,还可能因为页面栈变动触发一些奇怪的 onShow。
function logout() {
if (loggingOut) return
loggingOut = true
clearTokens()
uni.reLaunch({
url: '/pages/login/index',
complete() {
// 留个缓冲,等页面切换完再解锁
setTimeout(() => {
loggingOut = false
}, 1000)
}
})
}
用 reLaunch 而不是 navigateTo,是因为要清空页面栈。用户重新登录之后按返回键,不该还能退回到刚才那个已经失效的页面。
第五步:上传接口也得接进这套体系
这是个容易被忽略的地方。uni.uploadFile 和 uni.downloadFile 都不走 uni.request,所以前面写的封装对它们完全无效。
更麻烦的是,上传大文件动辄几十秒,token 过期几乎是必然发生的事。用户好不容易传到 80%,突然报个 401,体验非常差。
export function upload(filePath, name) {
const token = getAccessToken()
return new Promise((resolve, reject) => {
uni.uploadFile({
url: BASE_URL + '/api/v1/upload',
filePath,
name: name || 'file',
header: token ? { Authorization: 'Bearer ' + token } : {},
success(res) {
let body
try {
// 注意:uploadFile 的 res.data 是字符串,得自己 parse
body = JSON.parse(res.data)
} catch (e) {
reject(new Error('返回内容不是合法 JSON'))
return
}
if (res.statusCode === 401 || body.code === 401) {
// 把整个 upload 调用本身塞进队列,刷新完重新传一次
enqueue(() => upload(filePath, name), resolve, reject)
return
}
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(body)
return
}
reject(makeError('HTTP ' + res.statusCode, body))
},
fail(err) {
reject(makeError(err.errMsg || '上传失败', err))
}
})
})
}
队列里存 retry 函数的好处在这里体现得很清楚:() => upload(filePath, name) 会重新走一遍完整的上传流程,而不是试图去复用某个已经废弃的 RequestTask。
下载同理,把 downloadFile 包一遍,401 分支里 enqueue(() => download(url), resolve, reject) 就完事了。
第六步:业务层怎么用
封装完之后,业务代码应该是这个样子,跟没封装一样简单:
// api/order.js
import { request } from '@/utils/request'
export function fetchOrders(params) {
return request({ url: '/api/v1/orders', method: 'GET', data: params })
}
export function fetchProfile() {
return request({ url: '/api/v1/me', method: 'GET' })
}
export function fetchCoupons() {
return request({ url: '/api/v1/coupons', method: 'GET' })
}
// pages/index/index.vue
import { fetchOrders, fetchProfile, fetchCoupons } from '@/api/order'
async function loadHome() {
try {
const [orders, profile, coupons] = await Promise.all([
fetchOrders({ page: 1 }),
fetchProfile(),
fetchCoupons()
])
// 这里拿到的三个结果都是正常的
} catch (e) {
// 只有真的失败(网络断了、或者刷新也没救回来)才会走到这
uni.showToast({ title: e.message, icon: 'none' })
}
}
三个请求并发,如果 token 都过期了,只会发出一次刷新请求。刷新成功后三个请求自动重发,业务代码感知不到任何东西。这就是「无感」两个字的含义。
踩坑清单
refresh token 轮换的后端要留宽限期。 就算前端已经做了单例刷新,网络抖动的情况下仍然可能出现两个刷新请求同时到达服务端。如果后端是「用一次立刻作废旧的」,第二个请求就会失败。稳妥做法是让旧 refresh token 在签发后的几秒内仍然可用。
别把业务错误码和 HTTP 状态码混为一谈。 有些后端 401 是放在响应体里的 code 字段,HTTP 状态码给的是 200。上面代码里两种都判断了,但你的实际约定是什么,得回去翻一遍接口文档。
H5 端的 CORS 会导致 401 变成网络错误。 如果服务端在 401 响应上没有正确设置 Access-Control-Allow-Origin,浏览器会在 XHR 层面就把这个响应拦掉,前端 fail 回调收到的是一句「请求失败」,压根看不到 401。这种情况下你只能从服务端解决。
微信小程序要提前配好域名白名单。 request、uploadFile、downloadFile 的域名是分开配的,少配一个上传就会失败。开发阶段可以在开发者工具里勾上「不校验合法域名」,但上线前一定要一个个检查。
队列长度没有上限。 如果某一刻有大量请求同时 401,队列会被撑得很大。一般来说问题不大,但如果你的应用有轮询逻辑,最好给队列加个长度保护,超过阈值就直接全部 reject 并登出。
onShow 里重复发请求要留个心眼。 小程序从后台切回前台会触发 onShow,如果里面无条件重新拉数据,加上还没处理完的旧请求,很容易造出一堆并发的 401。加个简单的节流或者状态标记。
收尾
这套方案的核心其实就两张纸都写得下:一个刷新中的 Promise 当锁,一个等待数组当队列。但在实际项目里,它能把「切回前台被踢下线」这类投诉直接抹掉。
落地的时候建议分两步走:先把基础封装和错误分类接好,跑通正常流程;再把 401 分支和刷新逻辑接上。别一次性改完,不然出问题的时候你分不清是封装本身有 bug,还是刷新逻辑有问题。

