去年冬天一个晚上,后端同事在群里发了一张监控截图:同一秒内有六个 /auth/refresh 请求打进来。我看了眼时间,正好是 token 过期的那一分钟。
原因不复杂。首页 onLoad 里同时发了商品列表、banner、用户信息三个请求,详情页预加载又发了两个,消息红点还有一个。token 过期后这六个请求全部返回 401,各自触发一次刷新逻辑,六个刷新请求并发出去,后五个拿到的 refreshToken 已经被第一个失效掉了,全部刷新失败,用户被踢回登录页。
修的时候才发现,项目里的”请求封装”其实只是给 uni.request 套了层 Promise,加了个统一错误提示。真正麻烦的事——去重、取消、续期——一件都没做。这篇文章就把这层补上。
uni.request 缺的是什么
直接看原生用法:
uni.request({
url: '/api/goods',
method: 'GET',
success: res => {},
fail: err => {},
complete: () => {}
})
问题有三个。
第一,它返回的不是什么都不给。很多人以为没有返回值,其实它会返回一个 requestTask 对象。这个对象上挂着 abort(),也就是取消能力。资料里经常一笔带过,但取消这件事全靠它。
第二,它没有拦截器。每个页面都要手写 header.token,处理一次 401,处理一次 500。写十次之后必然有不一致的地方。
第三,它不知道”这个请求和上一个请求是不是一回事”。这一点是造成那次事故的根源。
第一件事:把相同请求合并掉
先区分清楚两种”重复”。
一种是同一时刻发出的完全相同的请求。首页三个组件都在 onLoad 里调 getUserInfo(),它们想要的其实是同一份数据。这种情况不该取消防任何一个,而是让它们共享同一个网络请求。
另一种是先后发出、后一个要让前一个失效。搜索联想框是典型:用户输入”a”,请求发出;再输入”ab”,请求又发出;如果”a”的响应晚于”ab”回来,列表就会闪一下旧数据。这种情况必须取消前一个。
先处理第一种,用一个 Map 缓存正在飞行中的 Promise。
const inflight = new Map()
function requestKey(config) {
const method = (config.method || 'GET').toUpperCase()
let body = ''
if (config.data) {
try {
body = JSON.stringify(config.data)
} catch (e) {
body = String(config.data)
}
}
return `${method}::${config.url}::${body}`
}
function request(config) {
const key = requestKey(config)
if (inflight.has(key)) {
// 别的调用方已经在请求同一个东西,直接复用
return inflight.get(key)
}
const p = doRequest(config).finally(() => {
inflight.delete(key)
})
inflight.set(key, p)
return p
}
有几个细节值得留意。
JSON.stringify 的顺序问题。{a:1, b:2} 和 {b:2, a:1} 字符串化之后不一样,会被当成两个不同的请求。项目里如果没人乱写 key 顺序,通常不用管;真在意的话可以写个按 key 排序的序列化,但那属于过度设计。
为什么不用 finally 之外的写法。finally 在成功和失败都会执行,能保证 Map 里的条目一定会被清掉。如果放在 then 里,请求失败时就会残留,后续相同请求会拿到一个已 reject 的 Promise,永远失败。这个坑我踩过一次,排查了很久。
只对幂等请求合并。POST 创建订单、提交表单这类请求,重复调用本身就不该发生,但万一发生了,合并掉会让用户以为”提交了两次只算一次”。这类请求建议排除在合并之外。做法很简单,在 config 里加个 allowMerge: false,判断一下就行。
第二件事:主动取消,用 requestTask.abort()
搜索联想、页面离开时中止请求、切换 tab 时放弃上一个 tab 的请求——这些都需要真取消。uni.request 在小程序端和 App 端返回的 requestTask 上都有 abort(),H5 端底层是 XMLHttpRequest,同样支持。
function doRequest(config) {
return new Promise((resolve, reject) => {
const task = uni.request({
url: BASE_URL + config.url,
method: config.method || 'GET',
data: config.data || {},
header: buildHeader(config),
timeout: config.timeout || 15000,
success: res => resolve(handleResponse(res, config)),
fail: err => reject(normalizeError(err)),
complete: () => {}
})
// 把 task 挂到外部,供 abort 使用
if (config.cancelKey && task) {
registerCancelable(config.cancelKey, task)
}
})
}
然后维护一张”取消键到 task”的表。同一个 cancelKey 来了新请求,旧的就 abort 掉。
const cancelable = new Map()
function registerCancelable(key, task) {
const old = cancelable.get(key)
if (old && typeof old.abort === 'function') {
old.abort()
}
cancelable.set(key, task)
}
// 请求结束时清理,避免内存泄漏
function clearCancelable(key, task) {
if (cancelable.get(key) === task) {
cancelable.delete(key)
}
}
使用侧:
let searchSeq = 0
function onKeywordChange(kw) {
if (!kw.trim()) return
request({
url: '/api/search/suggest',
data: { kw },
// 所有联想请求共用一个取消键,
// 新的一次进来就会把上一次干掉
cancelKey: 'search-suggest'
})
.then(list => {
suggestList.value = list
})
.catch(err => {
// 被 abort 的请求会走 fail,这里要判断一下
if (err && err.aborted) return
suggestList.value = []
})
}
这里有个很容易翻车的点:被 abort 的请求,fail 回调照样会触发,errMsg 通常是 "request:fail abort" 或者包含 “abort” 字样。如果不做区分,用户每输入一个字就会弹一次”网络异常”的提示。
所以在归一化错误的时候,要把这种情况单独标出来:
function normalizeError(err) {
const msg = (err && err.errMsg) || ''
const aborted = /abort/i.test(msg)
return {
code: aborted ? 'ABORTED' : 'NETWORK',
aborted,
message: aborted ? '请求已取消' : '网络连接失败',
raw: err
}
}
然后在统一错误处理里,凡是 aborted 为 true 的,一律静默跳过,不弹提示、不打日志、不重试。
还有一个细节:cancelKey 是共享的,如果两个不同页面都用了 'search-suggest',会互相取消。命名带上页面或组件前缀,比如 'home-search-suggest',能避免这类交叉污染。
第三件事:401 续期,只能有一个人去刷新
这才是那次事故真正的起因。核心思路是”单飞”——同一时刻只允许一个刷新流程在跑,其他所有等着它,拿到新 token 后再重放自己原来的请求。
let refreshing = null
function refreshToken() {
// 已经在刷新了,挂到队列上等结果
if (refreshing) return refreshing
refreshing = rawRefresh()
.then(newToken => {
setToken(newToken)
return newToken
})
.finally(() => {
// 无论成功失败,都要释放锁,
// 否则后续所有请求会永远卡在等待里
refreshing = null
})
return refreshing
}
// 独立的一支请求,不能走业务请求函数,否则递归
function rawRefresh() {
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + '/auth/refresh',
method: 'POST',
data: { refreshToken: getRefreshToken() },
success: res => {
const data = res.data || {}
if (data.code === 0 && data.token) {
resolve(data.token)
} else {
reject(new Error('refresh failed'))
}
},
fail: reject
})
})
}
关键在于 refreshing 这个变量存的是 Promise 而不是布尔值。这样后来者不需要自己排队,直接 return refreshing 就搭上了同一班车,拿到的是同一个结果。用布尔值加队列也能做,但代码量翻倍,还得自己处理成功和失败两条回执路径。
然后是 401 的处理逻辑,嵌在响应处理里:
async function handleResponse(res, config) {
const body = res.data || {}
if (res.statusCode === 401 || body.code === 401) {
// 已经重试过一次,不再重试,直接踢出
if (config._retried) {
clearLoginState()
redirectToLogin()
throw { code: 'UNAUTHORIZED', message: '登录已过期' }
}
try {
await refreshToken()
} catch (e) {
clearLoginState()
redirectToLogin()
throw { code: 'UNAUTHORIZED', message: '登录已过期' }
}
// 刷新成功,拿新 token 重放原请求
return doRequest(Object.assign({}, config, { _retried: true }))
}
if (body.code !== 0) {
uni.showToast({ title: body.message || '请求失败', icon: 'none' })
throw { code: body.code, message: body.message }
}
return body.data
}
_retried 这个标记非常重要。新 token 本身也可能是过期的(比如服务端时间不同步,或者用户手动改了本地存储)。没有这个标记,重放请求又拿到 401,又去刷新,又重放,直接死循环,把服务端刷爆。加了这个标记,第二次 401 直接走登出,干净利落。
重放时要用 doRequest 而不是 request。因为 request 会走去重逻辑,如果原请求还在 inflight 里,重放会直接被当成重复请求返回旧的 Promise,那就白搭了。doRequest 是最底层的请求函数,绕开所有缓存层。
还有一个细节:config._retried 不能污染原对象。上面用了 Object.assign 生成新对象,如果直接 config._retried = true 再重放,原对象被改了,后续如果同一个 config 被复用会出问题。
登出的并发问题
六个请求同时 401,其中一个把用户踢到登录页,另外五个也在跑登出逻辑,可能弹五次提示、跳五次页面。做法是加一个标记:
let loggingOut = false
function clearLoginState() {
if (loggingOut) return
loggingOut = true
uni.removeStorageSync('token')
uni.removeStorageSync('refreshToken')
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/index' })
loggingOut = false
}, 800)
}
延时跳转是为了让 toast 有机会显示出来。reLaunch 会清掉页面栈,立刻跳的话 toast 会被一起销毁。
第四件事:图片上传时的并发限流
这个需求跟前面三个不太一样,但也经常和它们一起出现。用户一次选十张图,如果同时发十个上传请求,小程序的并发数限制会直接让部分请求失败,H5 端则可能把浏览器连接池占满,导致页面上其他请求卡住。
做一个简单的并发调度器,代码不到二十行。
function createLimiter(max) {
let running = 0
const queue = []
function next() {
if (running >= max || !queue.length) return
running++
const task = queue.shift()
task.fn()
.then(task.resolve, task.reject)
.finally(() => {
running--
next()
})
}
return function schedule(fn) {
return new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject })
next()
})
}
}
用起来:
const uploadLimiter = createLimiter(3)
function uploadOne(file) {
return uploadLimiter(() => new Promise((resolve, reject) => {
uni.uploadFile({
url: BASE_URL + '/upload',
filePath: file.path,
name: 'file',
header: buildHeader(),
success: res => {
try {
const data = JSON.parse(res.data)
data.code === 0 ? resolve(data.data) : reject(data)
} catch (e) {
reject(new Error('返回格式异常'))
}
},
fail: reject
})
}))
}
// 十张图,任何时候只有三个在跑
async function uploadAll(files) {
const results = await Promise.allSettled(files.map(uploadOne))
// 汇总成功和失败,交给 UI 展示
return results
}
Promise.allSettled 在这里比 Promise.all 合适。十张图错了一张,用户希望的是”九张成功、一张失败可以重试”,而不是整个批次作废。用 allSettled 拿到全部结果,UI 上把失败的那张标红让用户重传就行。
顺带说一句,uni.uploadFile 返回的 uploadTask 上有 onProgressUpdate 回调,可以做单张图片的上传进度。如果整个批次要显示总进度,就得自己在进度回调里汇总,按已上传字节数除以总字节数来算。
多端差异清单
这套封装写完,三端跑一遍,还是会遇到一些行为不一致的地方。列几个印象最深的。
数组参数的序列化
GET 请求传 data: { ids: [1, 2, 3] },小程序端默认会序列化成 ids[0]=1&ids[1]=2&ids[2]=3,而 H5 端在某些情况下会变成 ids=1&ids=2&ids=3。后端如果只认其中一种格式,就会拿不到参数。
稳妥做法是自己处理,把它转成逗号分隔的字符串:
function normalizeQuery(data) {
const out = {}
Object.keys(data || {}).forEach(k => {
const v = data[k]
if (Array.isArray(v)) {
out[k] = v.join(',')
} else if (v !== undefined && v !== null) {
out[k] = v
}
})
return out
}
后端拿到 "1,2,3" 自己 split 一下,三端行为就统一了。
响应头的读取
小程序端的 requestTask 上有 onHeadersReceived 方法,可以在响应头到达时立刻拿到,不用等 body。App 端部分版本没有,H5 端也没有。如果有”根据响应头里的分页信息做无限滚动”这类需求,得做条件编译,或者在 H5 端改用别的方案。
大部分项目其实用不上,写在这里是提醒一句:看到别人代码里用 onHeadersReceived,先确认下自己的目标端支不支持。
超时的表现
H5 端超时后 fail 里的 errMsg 是 "request:fail timeout",小程序端可能是 "request:fail timeout" 也可能是具体的网络错误。这两种在归一化的时候都归到 NETWORK 就行,不用细分。
Content-Type 的自动处理
小程序端如果 header 里不写 Content-Type,默认会带 application/json,POST 的 data 对象会被自动序列化成 JSON 字符串。H5 端如果显式写成 application/x-www-form-urlencoded,uni-app 会帮你把对象序列化成表单格式。这两种行为最好在封装里统一掉,别让业务代码去关心。
function buildHeader(config) {
const header = Object.assign({}, config.header)
if (!header['Content-Type']) {
header['Content-Type'] = config.form
? 'application/x-www-form-urlencoded'
: 'application/json'
}
const token = getToken()
if (token && !header.Authorization) {
header.Authorization = 'Bearer ' + token
}
return header
}
把上面这些拼起来
完整的调用顺序大概是这样:
function request(config) {
// 1. 参数归一
const finalConfig = Object.assign({}, config, {
data: normalizeQuery(config.data),
header: buildHeader(config)
})
// 2. 合并相同请求
const key = requestKey(finalConfig)
if (finalConfig.allowMerge !== false && inflight.has(key)) {
return inflight.get(key)
}
// 3. 发请求,内部处理 401 续期和重放
const p = doRequestWithRetry(finalConfig).finally(() => {
inflight.delete(key)
})
if (finalConfig.allowMerge !== false) {
inflight.set(key, p)
}
return p
}
async function doRequestWithRetry(config) {
const res = await doRequest(config)
return handleResponse(res, config)
}
看起来层次有点多,但每一层的职责是清晰的:request 管去重和整体流程,doRequest 只管把请求发出去、把 task 管好,handleResponse 只管判断业务码和 401。
拆分带来的好处,是在加新需求时不用动老代码。比如最近要加”请求耗时埋点”,只需要在 doRequest 里加两个时间戳,别的层完全不用碰。
最后
回到开头那次事故,最后改动的代码量其实不大,核心就是那个存 Promise 而不是布尔值的 refreshing 变量。但它解决的问题是系统性的:无论页面并发多少个请求,无论 token 在什么时刻过期,都只会有一个刷新流程,用户的登录态不会因为并发而崩掉。
如果项目里的请求封装现在只是”套了层 Promise”,建议按这个顺序补:先去重,再取消,最后处理 401。前两个改动小、收益立竿见影,第三个改动稍大,但那是唯一会引发线上事故的一环,值得花半天时间把它做对。

