去年冬天有个电商客户找过来,说用户投诉率突然涨了一波,反馈都是同一句话:加购物车加着加着就被踢到登录页。
复现的时候很容易看出来。用户点进商品详情,页面一上来同时发三个请求:商品信息、用户是否收藏、购物车角标数量。恰好这时候 token 过期了,三个请求几乎同时收到 401。当时的拦截器是这么写的——收到 401 就调刷新接口,刷新成功后重放请求。逻辑看着没什么问题,问题在于三个 401 是同时到的,于是刷新接口被调了三次。
后端的刷新接口有个约束:refresh_token 是一次性的,刷新成功后旧的立刻失效。第一次刷新成功,返回了新 token;第二次带着同一个 refresh_token 去刷,后端判定重放攻击,直接返回失败;应用层一看刷新失败,立刻执行”清除登录态、跳登录页”。第三次同理。用户根本不知道发生了什么,只看到页面自己跳走了。
这个问题不是 uni-app 独有的,但 uni-app 里格外容易踩,因为小程序页面初始化时并发发起多个请求几乎是默认操作。下面把这套请求层重建的过程拆开讲一遍,代码可以直接抄。
一、简单的拦截器为什么扛不住
先把问题代码还原一下。大概是这样的:
uni.addInterceptor('request', {
success(res) {
if (res.statusCode === 401) {
refreshToken().then(() => {
// 重放请求,但这里根本拿不到原始 url、参数
})
}
}
})
这个写法有三处硬伤。
第一处是并发问题。拦截器是每个请求各自触发的,它没有一个全局视角。三个请求同时 401,就有三个拦截器实例同时决定去刷新 token。除非刷新操作本身是幂等的(大多数一次性的 refresh_token 都不是),否则必然出事。
第二处是上下文丢失。addInterceptor 的 success 回调只给你 res,原始请求的 url、method、data 全都不在里面。想重放,得自己维护一份请求快照表,非常别扭。
第三处是没有去重。用户快速切 tab,同一个用户信息接口可能在 300 毫秒内被发了五遍,五遍全部打到后端。小程序的并发请求上限只有 10 个,五遍用户信息就能把配额吃掉一半,后面的请求只能排队等。用户看到的界面就是卡顿。
所以与其修补拦截器,不如自己写一个薄薄的请求层,把 token 管理、并发控制、去重取消三件事收到一个函数里。uni-app 本来也推荐以 uni.request 为基础自行封装,这条路是官方认可的。
二、核心:刷新操作必须”单飞”
整个方案的支点就一个:同一时刻,全局最多只有一个刷新请求在飞。其他所有需要刷新的请求,都挂在一个等待队列上,等那个唯一的刷新请求成功之后,一起放行。
用一个 Promise 当锁就够了,不需要引入额外的锁库:
let refreshing = null // 正在进行的刷新 Promise,null 表示没有
let waiting = [] // 等待刷新的请求
function refreshToken() {
// 已经有人在刷了,排队等着
if (refreshing) {
return new Promise((resolve, reject) => {
waiting.push({ resolve, reject })
})
}
// 我是第一个,负责发起刷新
refreshing = doRefresh()
return refreshing
.then((token) => {
waiting.forEach((item) => item.resolve(token))
waiting = []
return token
})
.catch((err) => {
waiting.forEach((item) => item.reject(err))
waiting = []
throw err
})
.finally(() => {
refreshing = null
})
}
关键点在 refreshing 这个变量。第一个到达的请求看到它是 null,就把它设成自己的 Promise,然后去调刷新接口。第二个、第三个到达的时候看到它不是 null,就只是往 waiting 数组里塞一个 resolve/reject 对,然后安静地等着。
等到唯一的刷新请求有结果了,.then 会把队列里所有等待者的 Promise 挨个 resolve,它们拿到的是同一个新 token。整个过程中后端只收到一次刷新请求,refresh_token 不会被重复消费。
.finally 里把 refreshing 置回 null 也很关键。否则第一次刷新结束后,后面所有过期的 token 都会发现 refreshing 不是 null,然后一直挂在队列里等一个再也不会到来的 resolve。
三、把队列变成真正能用的请求层
上面是理论基础,落到实际代码要处理的细节就多了。先看整体的骨架:
// utils/http.js
import { useUserStore } from '@/stores/user'
const BASE_URL = 'https://api.example.com'
const REFRESH_PATH = '/auth/refresh'
let refreshing = null
let waiting = []
let seq = 0
const pending = new Map() // 去重用的请求表
// 换一个新 token,走的是裸 uni.request,不经过本层封装
async function doRefresh() {
const userStore = useUserStore()
const refreshToken = userStore.refreshToken
if (!refreshToken) throw new Error('no refresh token')
const res = await new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + REFRESH_PATH,
method: 'POST',
data: { refreshToken },
success: resolve,
fail: reject
})
})
if (res.statusCode !== 200 || !res.data?.data?.token) {
throw new Error('refresh rejected')
}
const token = res.data.data.token
userStore.setToken(token)
return token
}
这里有个容易被忽略的点:doRefresh 里不能调用封装好的 request 函数。因为那个函数本身会遇到 401 然后触发刷新,就会形成死循环。它必须直接调 uni.request,用最原始的方式发出去。
紧接着是 request 主体:
export function request(options) {
const {
url,
method = 'GET',
data,
header = {},
needAuth = true,
dedupe = true,
timeout = 15000,
...rest
} = options
return new Promise((resolve, reject) => {
const userStore = useUserStore()
const myId = ++seq
const key = dedupe ? makeKey({ method, url, data }) : null
// 去重:同一个请求正在飞行中,直接把它掐掉
if (key && pending.has(key)) {
const prev = pending.get(key)
if (prev && prev.id !== myId) {
try { prev.task.abort() } catch (e) {}
}
}
const finalHeader = { ...header }
if (needAuth && userStore.token) {
finalHeader.Authorization = `Bearer ${userStore.token}`
}
const task = uni.request({
url: /^https?:///.test(url) ? url : BASE_URL + url,
method,
data,
header: finalHeader,
timeout,
...rest,
success: async (res) => {
// 401 且没重试过,走刷新再重放
if (res.statusCode === 401 && needAuth && !rest._retried) {
try {
const token = await refreshToken()
const retried = await request({
...options,
header: { ...header, Authorization: `Bearer ${token}` },
_retried: true,
dedupe: false
})
resolve(retried)
} catch (e) {
reject(e)
}
return
}
if (res.statusCode >= 200 && res.statusCode < 300) {
// 这里按你的后端约定调整
if (res.data && res.data.code !== undefined && res.data.code !== 0) {
showError(res.data.message)
reject(res.data)
return
}
resolve(res.data?.data !== undefined ? res.data.data : res.data)
} else {
showError(describeStatus(res.statusCode))
reject(res)
}
},
fail: (err) => {
reject(err)
},
complete: () => {
if (key) {
const cur = pending.get(key)
if (cur && cur.id === myId) pending.delete(key)
}
}
})
if (key) {
pending.set(key, { id: myId, task })
}
})
}
去重这里绕了一个弯。pending 里存的不只是 task,还带了一个自增的 id。这是因为被取消的旧请求也会触发 complete,如果 complete 里不带判断地 pending.delete(key),会误删掉新请求在表里的记录。加上 id 比对之后,只有当前这条记录的主人才能删它。
四、请求去重:分三种情况处理
不是所有重复请求都该被掐掉。实际项目里得分三种情况:
第一种是”读”接口的重复。比如商品列表、用户信息、配置项。同一个 URL、同样的参数,在很短时间间隔内再次发起,把前一个 abort 掉是最合理的。这也是上面代码默认去重的场景。
第二种是”写”接口的重复。比如提交订单、发布评论。这种不应该 abort——你 abort 的是客户端的请求,服务器可能已经收到了。用户点两次提交按钮,你可能发出去了两单。正确做法是在 UI 层做按钮防抖,而不是靠请求层拦截。
第三种是同一路径不同参数的请求。比如搜索接口,用户边打字边搜,每次参数都不同。这种绝对不能去重,每个请求都得发出去。但你可以利用”取消上一次”的机制只保留最新一次:
function makeKey({ method, url, data }) {
const m = (method || 'GET').toUpperCase()
const d = typeof data === 'string' ? data : JSON.stringify(data || {})
return `${m}:${url}:${d}`
}
// 只按 URL 去重(不管参数),用于搜索、tab 切换这类场景
function makeKeyByPath({ method, url }) {
const m = (method || 'GET').toUpperCase()
return `${m}:${url}:*`
}
搜索场景可以在调用的时候显式指定用哪种 key:
// 用户每输入一个字就调一次
request({
url: '/search',
data: { q: keyword },
dedupe: true,
dedupeBy: 'path' // 相同 URL 只保留最新一次
}).then(result => {
// 只有最后一次的结果会真的 resolve
})
被 abort 掉的请求会走 fail 回调,里面会拿到一个 errMsg: "request:fail abort" 的错误。所以在 fail 里得区分”用户主动取消”和”网络真挂了”,前者不应该弹 toast:
fail: (err) => {
const msg = err?.errMsg || ''
if (msg.indexOf('abort') !== -1) {
// 被去重机制取消的,静默处理
reject({ __aborted: true })
return
}
showError('网络异常,请稍后重试')
reject(err)
}
五、多端登录统一封装
token 从哪来?三个平台拿登录凭证的方式完全不同,写散在各个页面里迟早会乱。集中到一个文件里:
// utils/auth.js
import { request } from './http'
function wxMiniLogin() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => resolve({ type: 'mp', code: res.code }),
fail: reject
})
})
}
function appWxLogin() {
return new Promise((resolve, reject) => {
uni.getProvider({
service: 'oauth',
success: (prov) => {
if (!prov.provider.includes('weixin')) {
reject(new Error('当前环境未安装微信'))
return
}
uni.login({
provider: 'weixin',
success: (res) => {
const auth = res.authResult || {}
resolve({
type: 'app',
openid: auth.openid,
accessToken: auth.access_token,
unionid: auth.unionid
})
},
fail: reject
})
},
fail: reject
})
})
}
async function h5WxLogin() {
// 从授权回调 URL 里取 code
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
if (!code) {
// 没有 code 就跳去授权页
const redirect = encodeURIComponent(window.location.href)
const target = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${APPID}&redirect_uri=${redirect}&response_type=code&scope=snsapi_userinfo&state=1#wechat_redirect`
window.location.replace(target)
return new Promise(() => {}) // 页面即将跳走,返回一个永不 resolve 的 Promise
}
return { type: 'h5', code }
}
export async function login() {
let credentials = null
// #ifdef MP-WEIXIN
credentials = await wxMiniLogin()
// #endif
// #ifdef APP-PLUS
credentials = await appWxLogin()
// #endif
// #ifdef H5
credentials = await h5WxLogin()
// #endif
if (!credentials) {
throw new Error('当前平台不支持登录')
}
// 三个端把凭证上报给同一个接口,由后端去换 openid
const result = await request({
url: '/auth/login',
method: 'POST',
data: credentials,
needAuth: false,
dedupe: false
})
const userStore = useUserStore()
userStore.setSession(result)
return result
}
H5 这里有个坑。h5WxLogin 在没拿到 code 的时候会执行页面跳转,跳转之后原来的 JS 上下文就没了,所以它返回的 Promise 永远不会有结果。这不是 bug,是设计——微信授权码的获取流程本来就是”跳走、回来、从 URL 里读”。所以调用 login() 的地方不要在 await 之后写任何重要逻辑,得靠页面重新加载后从 URL 里读到 code 再走一遍流程。
六、五个容易翻车的细节
第一,刷新失败后必须清理干净。刷新失败的原因很多:refresh_token 过期、网络抖动、后端 500。但无论是哪种,都必须把等待队列里的请求全部 reject 掉,并且清空本地登录态。挂在队列里的请求如果直接丢弃不 reject,上层 await 就会永远卡住,页面 loading 一直在转。我手上这套里刷新失败的处理是这样:
.catch((err) => {
waiting.forEach((item) => item.reject(err))
waiting = []
const userStore = useUserStore()
userStore.clearSession()
// 已经在登录页了就别再跳了,否则死循环
const pages = getCurrentPages()
const current = pages[pages.length - 1]
if (!current || !current.route.includes('login')) {
uni.reLaunch({ url: '/pages/login/index' })
}
throw err
})
那个”已经在登录页”的判断非常必要。之前有个版本没做这个,因为登录页本身也会调用接口(拿验证码配置),一旦后端刷新接口出问题,就会不停地 reLaunch,栈溢出直接闪退。
第二,重放请求要带 _retried 标记,且不能再去重。_retried 防止无限循环:如果重放的请求又收到 401,不应该再触发一次刷新,直接失败就好。至于去重,重放时如果还开着去重,可能会和别的请求撞车被 abort 掉,这个场景下宁可多打一次也不要出错。
第三,小程序的并发上限是 10。这是微信的硬限制,超过之后请求会排队。如果你在页面 onLoad 里一口气发十几二十个请求,超出的部分会等到前面的完成才发出去。你以为的”并发”其实是串行的。解决办法是延迟加载——把不着急的请求放到 onReady 之后、或者用户交互触发时再发。
第四,uploadFile 和 downloadFile 走的是另一条路。这两个 API 用的是 uni.uploadFile 和 uni.downloadFile,和 uni.request 不共享拦截器。如果项目里用它们上传头像、同步日志,得单独处理 token 注入。最常见的翻车是:用户改完头像上传,结果返回 401 因为没带 token。uploadFile 的 header 里是要手动塞 Authorization 的。
第五,App 端的 abort() 支持情况。requestTask.abort() 在微信小程序、H5 上早就有了,App 端是 3.6+ 版本才补上。如果你的 App 端跑在更老的基座上,task.abort 可能是 undefined,调它会直接报错。所以取消那一步要先判断:
if (prev && prev.task && typeof prev.task.abort === 'function') {
try { prev.task.abort() } catch (e) {}
}
包一层 try-catch 不是为了掩盖错误,而是因为 abort 在有些边缘情况下会抛异常,比如请求已经完成了正准备触发回调,这时候 abort 会失败。这不影响业务,报出来反而污染日志。
七、收尾
整套东西加起来不到 200 行,但把请求层里最容易出事的三个问题都收进去了:token 刷新不再并发、账号被踢的场景少了一多半;同接口重复请求被自动取消,低端机上滑动更顺;多端登录统一到一个入口,加新平台的时候只改一个文件。
上线之后有个意外收获。以前排查线上问题,看请求日志经常看到同一个接口在一秒内被打了七八遍,还以为是用户手快。改成去重之后,日志干净多了,真正的慢接口一眼就能看出来——因为日志里不再有冗余的重复项来干扰视线。
如果你手上也有类似的需求,建议从 refreshToken 那个单飞函数开始改。先把并发问题解决,其他两件事(去重、多端登录)可以分批推进。上来就大改容易出乱子,分步骤走更稳。

