去年接手一个 uni-app 项目,首页有 6 个卡片模块,每个卡片各自发自己的接口。上线后接到用户反馈:切到别的页面再切回来,流量跑得特别快。抓包一看,同一个 /home/summary 在 800ms 内被请求了 4 次——因为 4 个卡片各自在 onMounted 里调了一次。
更麻烦的是登录。后端 token 有效期 2 小时,refresh token 一次性。用户如果刚好在 token 过期那一秒打开首页,6 个并发请求同时返回 401,6 个都去调刷新接口,第一个刷新成功、后面 5 个拿着已经作废的 refresh token 全部失败,用户被踢回登录页。
这篇文章把这套请求层从零写一遍。代码基于 uni-app + Vue3 + TypeScript,微信小程序、App、H5 三端验证过。
一、为什么不直接在 uni.request 上加拦截器
很多人第一反应是用 uni.addInterceptor('request', {...})。这个 API 能用,但它只能改调用参数,拿不到返回值也改不了响应体,更没法在 401 的时候挂起请求、等刷新完再重放。所以请求层该自己写还是得自己写。
另一个常见做法是简单包一层 Promise:
export const request = (options) => new Promise((resolve, reject) => {
uni.request({ ...options, success: resolve, fail: reject })
})
这层壳解决的是”回调转 Promise”,业务问题一个没解决。下面按问题一个个加能力。
二、先明确要解决什么
- 并发去重:相同 method + url + 参数,在响应回来之前只发一次,其它调用方共享同一个 Promise。
- TTL 缓存:GET 接口可以声明”N 秒内命中就直接返回”,减少切页面时的无谓请求。
- 网络重试:超时、断网这类可恢复错误自动重试,业务错误绝不重试。
- 取消请求:页面已销毁、用户快速切 tab 的场景,把飞行中的请求掐掉。
- Token 无感刷新:并发 401 只触发一次刷新,其余请求排队等结果然后重放。
三、类型定义与取消令牌
先写基础类型。这里没有把 uni.request 的原始参数全部透传,只暴露业务真正需要的那几个,避免之后 API 面越滚越大。
// utils/http/types.ts
export interface RequestConfig {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: Record<string, any>
header?: Record<string, string>
timeout?: number
auth?: boolean // 是否携带 token,默认 true
cache?: number // 缓存毫秒数,仅 GET 生效,0 表示不缓存
retry?: number // 网络类错误的重试次数,默认 0
cancelToken?: CancelToken
}
export class CancelToken {
aborted = false
private queue: Array<() => void> = []
cancel() {
if (this.aborted) return
this.aborted = true
// splice(0) 一次性取出并清空,防止重复执行
this.queue.splice(0).forEach(fn => fn())
}
onCancel(fn: () => void) {
if (this.aborted) fn()
else this.queue.push(fn)
}
}
为什么不用 AbortController?因为小程序端不保证有。自己写一个几十行的 CancelToken,三端行为一致,可控性反而更高。
四、去重与缓存
去重的关键是”怎么判断两个请求是同一个”。直接 JSON.stringify(data) 是不行的,因为 {a:1, b:2} 和 {b:2, a:1} 会得到不同字符串,实际上它们是同一个请求。
// utils/http/index.ts
function stableStringify(val: any): string {
if (val === null || typeof val !== 'object') return JSON.stringify(val)
if (Array.isArray(val)) return `[${val.map(stableStringify).join(',')}]`
const keys = Object.keys(val).sort()
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(val[k])}`).join(',')}}`
}
const inflight = new Map<string, Promise<any>>()
const memoryCache = new Map<string, { data: any; expireAt: number }>()
function cacheKeyOf(cfg: RequestConfig, uid: string) {
// 注意 uid 必须参与计算,否则 A 用户退出、B 用户登录后会读到 A 的缓存
return `${uid}:${cfg.method || 'GET'}:${cfg.url}:${stableStringify(cfg.data || {})}`
}
缓存做了两级:内存 Map 做热点命中,uni.setStorageSync 做冷启动兜底。这里有个坑——微信小程序单个 key 上限 1MB,列表数据超过 200KB 就只留在内存里,别往 storage 塞,否则第一次能写进去,第二次直接抛异常。
function readCache(key: string) {
const mem = memoryCache.get(key)
if (mem) {
if (mem.expireAt > Date.now()) return mem.data
memoryCache.delete(key)
}
try {
const raw = uni.getStorageSync('http_cache:' + key)
if (!raw) return undefined
if (raw.expireAt > Date.now()) {
memoryCache.set(key, raw)
return raw.data
}
uni.removeStorageSync('http_cache:' + key)
} catch (e) { /* storage 异常不影响主流程 */ }
return undefined
}
function writeCache(key: string, data: any, ttl: number) {
const entry = { data, expireAt: Date.now() + ttl }
memoryCache.set(key, entry)
try {
const size = JSON.stringify(data)?.length ?? 0
if (size < 200 * 1024) uni.setStorageSync('http_cache:' + key, entry)
} catch (e) { /* 忽略配额异常 */ }
}
入口函数 request 负责串联去重和缓存:
export function request<T = any>(cfg: RequestConfig): Promise<T> {
const uid = getUserId() || 'anonymous'
const key = cacheKeyOf(cfg, uid)
const isGet = (cfg.method || 'GET').toUpperCase() === 'GET'
if (isGet && (cfg.cache ?? 0) > 0) {
const cached = readCache(key)
if (cached !== undefined) return Promise.resolve(cached as T)
}
// 只有 GET 去重。POST 可能有副作用,合并掉会丢数据
if (isGet && inflight.has(key)) {
return inflight.get(key) as Promise<T>
}
const p = doRequest<T>(cfg, key).finally(() => {
inflight.delete(key)
})
if (isGet) inflight.set(key, p)
return p
}
五、重试、取消与真正的发送逻辑
接下来是核心。重试要区分错误类型:网络超时、DNS 失败、连接中断可以重试;4xx 业务错误重试一万次也没用,只会加重服务端负担。退避策略用指数 + 随机抖动,避免同一批客户端在同一毫秒集中重试。
export class HttpError extends Error {
code: number
detail?: any
constructor(code: number, message: string, detail?: any) {
super(message)
this.code = code
this.detail = detail
}
}
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
const BASE_URL = 'https://api.yourservice.com'
function send(cfg: RequestConfig, token: string, method: string) {
return new Promise<any>((resolve, reject) => {
const header: Record<string, string> = {
'Content-Type': 'application/json',
...(cfg.header || {})
}
if (token) header.Authorization = `Bearer ${token}`
const task = uni.request({
url: /^https?:/.test(cfg.url) ? cfg.url : BASE_URL + cfg.url,
method: method as any,
data: cfg.data,
header,
timeout: cfg.timeout ?? 15000,
success: resolve,
fail: (err: any) => {
// 小程序和 App 在 abort 之后都会走 fail,errMsg 里带 "abort"
const msg = err?.errMsg || ''
if (msg.includes('abort')) reject(new HttpError(-2, '请求已取消'))
else reject(err)
}
})
cfg.cancelToken?.onCancel(() => {
task?.abort?.()
})
})
}
然后是 doRequest。这个函数参数里有一个 afterRefresh,是防止刷新完 token 重放请求后又 401 导致死循环的关键。
async function doRequest<T>(
cfg: RequestConfig,
key: string,
attempt = 0,
afterRefresh = false
): Promise<T> {
const method = (cfg.method || 'GET').toUpperCase()
const useAuth = cfg.auth !== false
const tokenAtSend = useAuth ? getToken() : ''
const retryLimit = cfg.retry ?? 0
if (cfg.cancelToken?.aborted) throw new HttpError(-2, '请求已取消')
try {
const res = await send(cfg, tokenAtSend, method)
const { statusCode, data } = res
if (statusCode >= 200 && statusCode < 300) {
const body: any = data
// 按后端约定判断业务码,这里假设 { code, message, data }
if (body && typeof body.code === 'number' && body.code !== 0) {
throw new HttpError(body.code, body.message || '业务异常', body)
}
const payload = body && 'data' in body ? body.data : body
if (method === 'GET' && (cfg.cache ?? 0) > 0) {
writeCache(key, payload, cfg.cache!)
}
return payload as T
}
if (statusCode === 401 && useAuth) {
if (afterRefresh) {
clearAuth()
uni.reLaunch({ url: '/pages/login/index' })
throw new HttpError(401, '登录已过期')
}
// 并发 401 时,先到的那个请求可能已经刷新完 token 了
if (getToken() && getToken() !== tokenAtSend) {
return doRequest<T>(cfg, key, attempt, false)
}
await refreshTokenOnce()
return doRequest<T>(cfg, key, attempt, true)
}
if (statusCode >= 500 && attempt < retryLimit) {
return retryLater<T>(cfg, key, attempt, afterRefresh)
}
throw new HttpError(statusCode, `HTTP ${statusCode}`, data)
} catch (err: any) {
if (err instanceof HttpError) throw err
if (attempt < retryLimit) {
return retryLater<T>(cfg, key, attempt, afterRefresh)
}
throw new HttpError(-1, err?.errMsg || '网络异常,请稍后重试')
}
}
async function retryLater<T>(
cfg: RequestConfig,
key: string,
attempt: number,
afterRefresh: boolean
): Promise<T> {
const delay = Math.min(300 * 2 ** attempt, 3000) + Math.floor(Math.random() * 200)
await sleep(delay)
return doRequest<T>(cfg, key, attempt + 1, afterRefresh)
}
六、Token 无感刷新
网上很多实现是给刷新接口加个 isRefreshing 标志,其他请求轮询等待。轮询有两个毛病:等待时长不好定,而且容易漏掉刷新失败的情况。用一个共享 Promise 更干净——所有并发请求 await 同一个 Promise,成功就都往下走,失败就都一起抛。
let refreshPromise: Promise<void> | null = null
export function refreshTokenOnce(): Promise<void> {
if (!refreshPromise) {
refreshPromise = doRefresh().finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
// 注意:这里用裸的 uni.request,不能走封装,
// 否则刷新失败会再次触发 401 处理,形成递归
async function doRefresh(): Promise<void> {
const refreshToken = getRefreshToken()
if (!refreshToken) throw new HttpError(401, '缺少 refresh token')
try {
const res: any = await new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + '/auth/refresh',
method: 'POST',
data: { refreshToken },
header: { 'Content-Type': 'application/json' },
timeout: 10000,
success: resolve,
fail: reject
})
})
if (res.statusCode !== 200 || res.data?.code !== 0) {
throw new Error('refresh rejected')
}
setToken(res.data.data.accessToken, res.data.data.refreshToken)
} catch (e) {
clearAuth()
uni.reLaunch({ url: '/pages/login/index' })
throw new HttpError(401, '登录已过期,请重新登录')
}
}
这里最容易被忽略的是 tokenAtSend 这个快照。没有它,6 个并发 401 会依次进入刷新分支,虽然共享了 Promise 不会重复请求刷新接口,但每个请求都会多绕一圈。有了它,后到的请求发现 token 已经变了,直接重放,省一次 await。
七、页面里怎么用
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { request, CancelToken, HttpError } from '@/utils/http'
const list = ref<any[]>([])
const loading = ref(false)
const cancelToken = new CancelToken()
async function loadFeed() {
loading.value = true
try {
const data = await request<{ list: any[] }>({
url: '/home/feed',
data: { page: 1, size: 20 },
cache: 30 * 1000,
retry: 2,
cancelToken
})
list.value = data.list
} catch (e: any) {
if (e instanceof HttpError && e.code === -2) return // 主动取消,不弹提示
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
// 下拉刷新时要用新的 token,旧的那个已经 cancel 了
function onPullDownRefresh() {
cancelToken.cancel()
loadFeed().finally(() => uni.stopPullDownRefresh())
}
onUnmounted(() => cancelToken.cancel())
</script>
页面 A 和页面 B 同时请求 /user/profile,只会真实发出一次网络请求。第二个页面拿到的是同一个 Promise 的结果,耗时基本为零。首页那几个卡片模块的问题就此消失。
八、几个真正翻过车的地方
1. 缓存必须隔离用户。这是我最开始偷懒埋的雷:cacheKey 没带 userId。测试同学切换账号后,发现新账号看到了上个账号的订单数据。后来在 key 里拼了 userId,并在退出登录时调用一次 clearAllCache() 清掉 storage 前缀为 http_cache: 的 key。
2. POST 不要参与去重。曾经为了”优化”,给 POST 也加了去重。结果用户连续提交两次表单,第二次被合并成了第一个请求的返回结果,”提交成功”的 toast 弹了两次但只有一条数据入库。涉及写操作的接口,宁可多发也不要去重。
3. abort 之后 fail 回调照常触发。小程序端调用 requestTask.abort() 之后,fail 依然会被调用,errMsg 是 request:fail abort。如果不识别这个字符串,用户快速切页时就会看到莫名其妙的”网络异常”提示。上面的实现里统一转成了 code === -2,业务层直接忽略即可。
4. 重试要设置上限。指数退避如果只写 2 ** attempt 不封顶,第 6 次重试就要等 32 秒,用户早就以为 App 卡死了。所以 Math.min(base * 2 ** attempt, 3000) 这个封顶值是必须的。
5. H5 端注意跨域与预检。加 Authorization 头会让简单请求变成非简单请求,浏览器会先发 OPTIONS 预检。后端如果没放行 OPTIONS,H5 端会一片红。小程序和 App 没有这个问题,容易在联调阶段漏掉。
九、还可以继续加的东西
目前这套大概 200 行,覆盖了日常项目里 90% 的场景。如果想继续往下做,有几个方向:
- SWR 模式:缓存命中时不直接返回,而是先返回旧数据、同时后台发起请求,拿到新数据后通过回调通知页面刷新。适合资讯流这种对时效性不那么敏感的场景。
- 请求埋点:在
send里记录耗时、状态码、重试次数,统一上报。线上排查接口慢的时候特别有用。 - 按环境切换 baseURL:开发、测试、预发、生产四套地址,配合构建变量注入。
- 请求优先级:用户主动触发的请求优先于后台静默刷新,可以做一个简单的队列调度。
请求层这种东西,前期多花半天写好,后面能省掉几十次”为什么这个接口又请求了两遍”的排查。别等到线上出问题了再回头补。

