去年接了一个工程巡检类的 App,用户大部分时间待在信号极差的地方——地下室、隧道口、刚封顶的楼里。上线第一周就收到反馈:巡检单填完点提交,转了两圈圈提示失败,退出去再进来,数据没了,得重填。
翻了一下原来的代码,请求是这样写的:
uni.request({
url: BASE + '/inspection/submit',
method: 'POST',
data: form,
success: () => uni.showToast({ title: '提交成功' }),
fail: () => uni.showToast({ title: '提交失败', icon: 'none' })
})
问题就出在这个 fail 上。弱网下 uni.request 默认 60 秒超时,用户在 20 秒的时候就把页面关了,这个回调根本没机会执行,表单数据随着页面卸载一起没了。就算回调执行了,除了弹个 toast 之外什么也没做,数据同样没落盘。
这篇把后来重写的那套请求层完整拆开讲。不复杂,但有几个地方不踩一遍是真的想不到。
一、先想清楚要解决哪些问题
在动手之前把需求列了一遍,一共四件事:
- 网络断了或者超时,请求不能凭空消失,得先存下来。
- 恢复网络之后自动补发,用户不用手动点重试。
- 列表页那种同一秒内被触发的重复请求,只发一次就够了。
- 页面同时打开好几个请求时,别一股脑全推出去把弱网堵死。
这四条对应四个模块:落盘队列、网络监听重放、请求合并、并发闸门。下面一个一个来。
二、addInterceptor 到底能干什么
很多人一看到「拦截器」三个字,第一反应就是把它当成 axios 的 interceptors,以为可以在这里统一重试、统一改返回值。这是个挺要命的误解,先把边界讲明白。
uni.addInterceptor('request', { ... }) 注册之后,每次调 uni.request 都会经过这几个钩子:
invoke(args):请求发出前调用,args就是这次请求的参数对象,你可以改它。返回false能直接拦下这次请求不发出。success(res):请求成功回调之前触发,返回false可以阻止后面的 success。fail(err):失败回调之前触发。complete(res):无论如何最后触发。
注意这里:它不能改变 uni.request 的返回值。你在拦截器里等不到结果,也没法把一次失败变成一次成功的重试。success 里就算你返回了别的东西,业务侧拿到的还是原始的 res。
所以正确的分工是:
- 拦截器负责横向的、所有请求都要做的事——比如统一打 traceId、统计耗时、注入 token。
- 重试、排队、落盘这些需要拿到结果的逻辑,放在自己封装的
request()函数里。
搞清楚这点之后,代码结构就不会拧巴了。
三、并发闸门:让同时飞出去的请求不超过 N 个
弱网环境下并发不是越多越好。实测在地下车库里,同时发 5 个请求,5 个一起超时的概率比一个一个发高得多,因为带宽被平分,每个都卡在超时边缘。
写一个最简单的信号量:
// concurrency.js
const MAX_CONCURRENT = 3
let running = 0
const waiters = []
function acquire() {
return new Promise((resolve) => {
if (running < MAX_CONCURRENT) {
running += 1
resolve()
return
}
waiters.push(resolve)
})
}
function release() {
running -= 1
const next = waiters.shift()
if (next) {
running += 1
// 异步调起,避免在同一个调用栈里递归展开
setTimeout(next, 0)
}
}
export { acquire, release }
为什么 release 里要包一层 setTimeout?因为释放和获取如果同步嵌套得太深,几十个排队的请求会形成一条很长的同步调用链,在某些基础库上会触发调用栈深度报警。包一层微任务就干净了。
另外 release() 必须放在 complete 里,不能只放 success 和 fail。原因下一节会说。
四、请求合并:同一秒内的重复提交只发一次
列表页下拉刷新、按钮被连点、页面 onShow 和 onLoad 都触发了同一个接口——这类重复请求在真机上比想象中常见得多。
思路是维护一个「正在飞行中的请求」的 Map,key 由 method + url + 序列化后的参数拼成:
// dedupe.js
const inflight = new Map()
// 关键点:把对象的 key 排好序再序列化,
// 否则 {a:1,b:2} 和 {b:2,a:1} 会算出两个不同的 key
function stableStringify(value) {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value)
}
if (Array.isArray(value)) {
return '[' + value.map(stableStringify).join(',') + ']'
}
const keys = Object.keys(value).sort()
return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}'
}
function requestKey(options) {
return [
(options.method || 'GET').toUpperCase(),
options.url,
stableStringify(options.data || {})
].join('|')
}
function withDedupe(options, factory) {
const key = requestKey(options)
if (inflight.has(key)) {
return inflight.get(key)
}
const task = Promise.resolve()
.then(factory)
.then(
(res) => {
inflight.delete(key)
return res
},
(err) => {
inflight.delete(key)
throw err
}
)
inflight.set(key, task)
return task
}
export { withDedupe }
这里没用 .finally()。微信小程序基础库 2.10 以下对 Promise.prototype.finally 支持不完整,而项目里还有一批老安卓机的用户卡在旧基础库上,所以老老实实写了 then 的双分支。
五、重试:哪些错值得重试,退避怎么算
不是所有失败都该重试。用户 token 过期返回 401,你重试一百次还是 401,只是白白消耗流量。
// retry.js
// 这些状态码值得再试一次
const RETRYABLE_STATUS = [408, 429, 500, 502, 503, 504]
function isNetworkError(err) {
// uni.request 网络层失败时,err 里通常只有 errMsg
// 形如 "request:fail timeout" / "request:fail abort"
const msg = (err && err.errMsg) || ''
return /timeout|fail/i.test(msg)
}
function shouldRetry(err, attempt) {
if (attempt >= 3) return false
if (isNetworkError(err)) return true
if (err && RETRYABLE_STATUS.includes(err.statusCode)) return true
return false
}
// 指数退避 + 抖动,避免断网恢复瞬间所有请求一起冲上去
function backoffDelay(attempt) {
const base = Math.min(1000 * Math.pow(2, attempt), 8000)
const jitter = Math.random() * base * 0.3
return Math.round(base + jitter)
}
export { shouldRetry, backoffDelay }
那个抖动(jitter)不是可选项。有一次做压力测试,20 个页面的补发请求在断网恢复的同一毫秒全部涌出,直接把服务端网关的 QPS 打到了限流阈值,结果全体 429,然后又全体退避,形成一个很规律的脉冲。加上随机抖动之后曲线就平了。
六、断网落盘:把没发出去的请求存到本地
重试三次还是失败,或者干脆 uni.getNetworkType 返回 none,这时候就得把它扔进持久化队列,等网络回来再补发。
// offline-queue.js
const QUEUE_KEY = 'offline_request_queue'
const MAX_QUEUE_SIZE = 50
function readQueue() {
try {
const raw = uni.getStorageSync(QUEUE_KEY)
return raw ? JSON.parse(raw) : []
} catch (e) {
return []
}
}
function writeQueue(list) {
try {
uni.setStorageSync(QUEUE_KEY, JSON.stringify(list))
} catch (e) {
// 存储写失败一般是被占满了,丢最老的一半再试一次
const half = list.slice(Math.floor(list.length / 2))
uni.setStorageSync(QUEUE_KEY, JSON.stringify(half))
}
}
function enqueue(options) {
const list = readQueue()
// 队列满了就丢最老的,保证新数据一定进得去
while (list.length >= MAX_QUEUE_SIZE) {
list.shift()
}
list.push({
url: options.url,
method: options.method || 'POST',
data: options.data || {},
header: options.header || {},
ts: Date.now()
})
writeQueue(list)
}
function dequeueAll() {
const list = readQueue()
writeQueue([])
return list
}
export { readQueue, enqueue, dequeueAll }
几个细节值得说:
- 只往队列里放写请求。GET 类的查询请求丢了就丢了,下次进页面自然会重新拉。把查询也塞进队列,补发的时候返回一堆过期数据,反而会覆盖掉新数据。
- 队列长度必须封顶。微信小程序单个 key 的上限是 1MB,巡检表单里带图片 base64 的话,十几条就能撑爆。超过上限的部分直接
shift丢掉。 - 补发时按时间戳顺序来。同一张单子的「新增」和「修改」如果顺序反了,服务端状态就乱了。
七、拼起来:完整的 request 封装
// request.js
import { acquire, release } from './concurrency'
import { withDedupe } from './dedupe'
import { shouldRetry, backoffDelay } from './retry'
import { enqueue } from './offline-queue'
const BASE_URL = 'https://api.example.com'
function rawRequest(options, attempt = 0) {
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 || {},
header: {
'Content-Type': 'application/json',
...(options.header || {})
},
timeout: options.timeout || 20000,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
} else {
reject({ statusCode: res.statusCode, data: res.data, errMsg: 'http error' })
}
},
fail: (err) => {
reject(err)
}
})
})
}
async function request(options) {
const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(
(options.method || 'GET').toUpperCase()
)
// 查询类请求直接走合并 + 重试,不走落盘
if (!isWrite) {
return withDedupe(options, () => sendWithRetry(options, false))
}
// 写请求:先合并,再排队
return withDedupe(options, async () => {
try {
return await sendWithRetry(options, true)
} catch (err) {
// 重试穷尽仍然失败,落到本地队列
enqueue(options)
throw err
}
})
}
async function sendWithRetry(options, allowOfflineSave) {
await acquire()
try {
let attempt = 0
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await rawRequest(options, attempt)
} catch (err) {
attempt += 1
if (!allowOfflineSave || !shouldRetry(err, attempt)) {
throw err
}
await new Promise((r) => setTimeout(r, backoffDelay(attempt)))
}
}
} finally {
// 一定要在 finally 里释放,否则一次中断就会永久占掉一个名额
release()
}
}
export default request
配套的网络恢复监听放在 App 启动的地方,比如 App.vue 的 onLaunch 里:
// App.vue 的 script 部分
import { dequeueAll } from '@/utils/offline-queue'
import request from '@/utils/request'
let flushing = false
async function flushOfflineQueue() {
if (flushing) return
flushing = true
const list = dequeueAll()
for (const job of list) {
try {
await request({ url: job.url, method: job.method, data: job.data, header: job.header })
} catch (e) {
// 补发又失败了,先放回去,剩下的下次再说
enqueue(job)
break
}
}
flushing = false
}
export default {
onLaunch() {
uni.getNetworkType({
success: (res) => {
if (res.networkType !== 'none') flushOfflineQueue()
}
})
uni.onNetworkStatusChange((res) => {
if (res.isConnected) flushOfflineQueue()
})
}
}
注意 flushOfflineQueue 里那个 break。补发到一半又断网了,如果继续往下循环,后面每一条都要走一遍超时重试,用户会在后台白白耗掉几十秒的电。断一条就停下来,等下一次网络事件再继续。
八、最后用拦截器收个尾
上面所有业务逻辑都不需要拦截器,那拦截器用来干嘛?用来做那些「所有请求都该有,但跟业务无关」的事。比如给每个请求打一个 traceId,方便后端排查问题:
// interceptor.js
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
export function setupInterceptor() {
uni.addInterceptor('request', {
invoke(args) {
args.header = args.header || {}
if (!args.header['X-Trace-Id']) {
args.header['X-Trace-Id'] = uuid()
}
}
})
}
之所以把 traceId 放在拦截器而不是封装函数里,是因为项目里还有一些历史代码和第三方 SDK 直接调 uni.request,走不到封装函数,但拦截器会全部兜住。如果只靠封装函数,那些漏网之鱼就没法追踪了。
九、几个我确实踩过的坑
坑 1:fail 回调拿不到原始请求参数
uni.request 的 fail(err) 里,err 基本只有一个 errMsg,没有 url,没有 data。你必须在外层的闭包里记住 options,才能知道是哪个请求失败了。uni.addInterceptor 的 fail 钩子也一样,它拿到的 err 是合并后的结果,别指望从里面翻出请求体。
坑 2:拦截器只对 request 生效
uni.uploadFile、uni.downloadFile、uni.connectSocket 都要单独注册拦截器。巡检单里带照片上传,第一版上线后照片上传的 traceId 全是空的,就是因为这个。
坑 3:release 忘了放在 finally 里
第一版的 release() 写在 while 循环外面的正常路径上,一旦 throw 出去就永远不执行。跑了两周之后用户反馈「用久了之后所有请求都卡住不动」,重启 App 才能恢复。查了半天才发现是并发名额被泄漏完了。这个坑非常隐蔽,因为它在测试阶段根本复现不出来,得连着失败几十次才会触发。
坑 4:JSON.stringify 的属性顺序会毁掉去重
列表页的参数对象如果是从不同地方拼出来的,{ page: 1, size: 20 } 和 { size: 20, page: 1 } 的序列化结果不一样,去重直接失效。stableStringify 里那次 sort() 不能省。
坑 5:setStorageSync 不是免费的
最开始把 enqueue 写在了每次请求失败的回调里,断网时几十个请求同时失败,同步写存储把主线程卡住了,页面肉眼可见地掉帧。后来改成用一个内存数组缓存,加 300ms 防抖再落盘,才算顺过来。
十、写在最后
这套东西加起来大概两百行,不算复杂,但把「请求消失」这个最要命的问题从根上堵住了。上线之后线上反馈的丢单率从每周十几单降到了零,虽然中间也补了几个小版本修上面那些坑。
如果你的项目也跑在信号不稳定的场景里,建议先把「失败后数据落到哪里」这一件事做扎实,剩下的重试、并发、去重都可以慢慢加。反过来先做花哨的,最后丢的还是数据。

