这个问题的暴露方式很典型。
有个售后工单页面,顶部一排状态筛选:全部、待处理、处理中、已完成。做的时候挺顺利,点点点一切正常。上线之后客服那边反馈说,偶尔会看到列表和筛选条件对不上——明明点的是「已完成」,列表里却混着好几条待处理的单子。
一开始怀疑后端接口返回有问题,抓了包看,每个请求的数据都是对的。又怀疑是列表渲染的 key 有问题,检查了一遍也没毛病。后来客服描述得更具体了一点:她说这种情况一般出现在手快的时候,连着点两三个筛选,然后就串了。
到这里基本就确定了,是请求竞态。
一、竞态是怎么发生的
把时间线拉出来看就很清楚了。
用户在 0 毫秒点了「待处理」,浏览器发出请求 A。80 毫秒后用户又点了「已完成」,发出请求 B。正常情况下 B 应该后到,先渲染的 A 被 B 的结果覆盖掉,没问题。
但如果 A 这个请求走的网络路径恰好比较慢,300 毫秒才回来,而 B 走了一条快路径 150 毫秒就回来了呢?那就变成 B 先渲染,然后把 A 的响应数据又盖了上去。用户看到的筛选按钮是「已完成」,列表却是「待处理」的内容。
这类问题的麻烦在于复现不稳定。网络好的时候永远测不出来,一到真实环境、弱网环境就频繁出现,测试同学很容易以为是偶发 bug 而忽略掉。
常见的土办法是加个 loading 遮罩,请求期间禁止点击。能用,但体验很糟——用户点不动就会以为卡死了,然后疯狂点击。而且遮罩挡得住手速,挡不住程序内部触发的并发请求,比如埋点上报和列表刷新同时发起。
更好的思路是:既然新的请求代表用户最新的意图,那就让旧的请求直接消失。不是等它回来再决定要不要用,而是从根上就别让它回来。
二、uni.request 其实一直有这个能力
很多人用 uni.request 只用了 success 和 fail 两个回调,忽略掉了一个细节:uni.request 会返回一个 RequestTask 对象。
const task = uni.request({
url: 'https://api.example.com/orders',
success(res) {}
})
task.abort() // 请求直接从底层断掉
abort() 调用之后,底层的小程序网络层或者 H5 的 XHR 会直接把这次连接掐掉,不再等待响应。被掐掉的请求会走 fail 回调,errMsg 里带着类似 request:fail abort 的内容。
这一点是整套方案的地基。有了它,我们才能实现「新请求进来,旧请求立刻作废」这个语义。
不过这里有个坑要提前说清楚:不同端 abort 之后的表现不完全一样。
- 微信小程序:abort 之后 fail 回调会立即触发,errMsg 为
request:fail abort,complete 也会正常触发。 - H5:本质是 XHR 的 abort,fail 回调同样会被调用,errMsg 可能是
request:fail abort。但如果请求已经完成了(响应体都接收完了),再调 abort 就没效果了,h和正常完成没有区别。 - App 端:走的是原生网络库,abort 行为和小程序接近,但时机上会略有延迟。
因为 errMsg 的文案各端不一致、各版本也可能变,绝对不能靠匹配字符串来判断「是不是被主动取消」。正确做法是自己在外层维护一个标志位,调用 abort 之前先把它置上,fail 回调里检查这个标志位来区分是网络错误还是主动取消。这个细节后面代码里会体现。
三、把基础封装搭起来
先做一个最小可用的版本,把请求、取消、错误处理串起来。
// utils/request.js
const BASE_URL = 'https://api.example.com'
const DEFAULT_TIMEOUT = 15000
// 在途请求表:key -> record,用于参数级去重
const inflight = new Map()
function hashKey(method, url, data) {
let raw = ''
if (data && typeof data === 'object') {
raw = Object.keys(data)
.sort()
.map(k => `${k}=${data[k]}`)
.join('&')
} else if (data !== undefined && data !== null) {
raw = String(data)
}
return `${method.toUpperCase()}::${url}::${raw}`
}
function toast(msg) {
uni.showToast({ title: msg, icon: 'none', duration: 1800 })
}
export function request(options = {}) {
const {
url,
method = 'GET',
data,
header = {},
timeout = DEFAULT_TIMEOUT,
dedupe = 'share',
silent = false,
withToken = true
} = options
const key = hashKey(method, url, data)
// 参数完全一致且已有在途请求,直接复用
if (dedupe === 'share' && inflight.has(key)) {
return inflight.get(key).promise
}
// 参数一致但策略是替换,把旧的那个掐掉
if (dedupe === 'replace' && inflight.has(key)) {
inflight.get(key).abort('replaced')
}
let task = null
let record = null
let abortReason = null
const promise = new Promise((resolve, reject) => {
const finalHeader = {
'Content-Type': 'application/json',
...header
}
if (withToken) {
const token = uni.getStorageSync('token')
if (token) {
finalHeader.Authorization = `Bearer ${token}`
}
}
task = uni.request({
url: /^https?:///.test(url) ? url : BASE_URL + url,
method: method.toUpperCase(),
data,
header: finalHeader,
timeout,
success(res) {
const { statusCode, data: body } = res
if (statusCode < 200 || statusCode >= 300) {
const err = new Error(`HTTP ${statusCode}`)
err.statusCode = statusCode
err.response = body
if (!silent) toast(`服务异常(${statusCode})`)
reject(err)
return
}
// 业务约定 code === 0 表示成功
if (body && typeof body === 'object' && 'code' in body) {
if (body.code === 0) {
resolve(body.data)
} else {
const err = new Error(body.message || '业务处理失败')
err.code = body.code
err.biz = true
if (!silent) toast(body.message || '操作失败')
reject(err)
}
return
}
resolve(body)
},
fail(err) {
// 关键:靠自己的标志位判断,不看 errMsg 文案
if (abortReason) {
const e = new Error('请求已取消')
e.name = 'AbortError'
e.reason = abortReason
reject(e)
return
}
if (!silent) toast('网络连接失败')
reject(new Error(err.errMsg || '网络异常'))
},
complete() {
inflight.delete(key)
}
})
})
// 防止主动取消引发的 unhandled rejection 噪音
promise.catch(e => {
if (e && e.name === 'AbortError') return
})
record = {
key,
promise,
abort(reason = 'manual') {
if (abortReason) return // 已经取消过了,不重复触发
abortReason = reason
if (task && typeof task.abort === 'function') {
task.abort()
}
}
}
if (dedupe === 'share' || dedupe === 'replace') {
inflight.set(key, record)
}
promise.abort = record.abort
return promise
}
export const http = {
get: (url, data, opts = {}) => request({ url, method: 'GET', data, ...opts }),
post: (url, data, opts = {}) => request({ url, method: 'POST', data, ...opts }),
put: (url, data, opts = {}) => request({ url, method: 'PUT', data, ...opts }),
del: (url, data, opts = {}) => request({ url, method: 'DELETE', data, ...opts })
}
用起来是这样:
import { http } from '@/utils/request'
const p = http.get('/orders', { status: 0 })
// 需要的时候可以主动取消
p.abort()
// 正常消费
p.then(list => {
this.list = list
}).catch(e => {
if (e.name === 'AbortError') return
// 其他错误处理
})
有几个地方解释一下。
为什么把 abort 挂在 promise 上而不是单独返回?
因为调用方大多数时候只关心数据,不想每次都写两行。直接把 abort 挂到 promise 上,需要的时候 p.abort(),不需要的时候完全不用管,代码看起来干净很多。这个设计思路在一些老的请求库(比如 axios 的 CancelToken 时代)也出现过。
为什么要在 promise 上加一个空的 catch?
因为 abort 之后的 promise 一定会 reject。如果调用方此刻正在等另一个更慢的请求,或者调用方是 fire-and-forget 类型的(比如埋点上报),这个 reject 就没人接,控制台里会刷一片 unhandled rejection 警告。加一个内部的 catch 把 AbortError 静默掉,不影响调用方另外挂自己的 catch。
为什么不用一个新的空 promise 或者 resolve 来处理 abort?
这是设计上的取舍。abort 应该被当作一个「异常终止」来对待,让调用方显式地知道这次请求没有结果。如果 resolve 一个空值,调用方拿到 undefined 会莫名其妙,反而更容易出问题。用 reject 加一个约定好的错误名,语义更清楚。
四、两种去重策略,对应两类业务场景
上面的代码里有个 dedupe 参数,我给了它两个取值,分别对应两种不同的业务需求。
share:同一份数据,同时只要一次
典型场景是详情页。一个页面上有多个模块需要用到用户信息,各自都去调 /user/profile。这时候不应该发三次请求,而是复用第一次的结果。
// 三个模块同时调用,实际只会发出一次网络请求
http.get('/user/profile')
http.get('/user/profile')
http.get('/user/profile')
这是 share 模式的默认行为。参数完全一致的时候,后两次调用拿到的都是第一次那个 promise 对象,resolve 的值也是同一个。
防重复提交本质上也是这个模式。用户手抖点两下提交按钮,参数完全一样,第二次调用会命中在途请求,直接复用第一次的结果,不会真的发两次。
不过要注意,防重复提交这块我还是建议在按钮层面也加一下禁用,因为在途请求的窗口期很短,一旦第一个请求回来了,第二次点击就又变成新请求了。请求层和 UI 层的两道防线是互补的,不是替代关系。
replace:同一份数据,只要最新的
典型场景就是文章开头说的筛选栏。参数不一样,但其实它们代表的是同一个数据源,只有最新的那一个有意义。
http.get('/orders', { status: 0 }, { dedupe: 'replace' })
http.get('/orders', { status: 1 }, { dedupe: 'replace' })
注意这里的关键点:replace 模式下并不是按 hashKey 去匹配的。上面这两个请求的参数不一样,hashKey 也不一样,按 key 是匹配不到的。
所以 replace 单独用还不够,得配合下面要说的 slot 机制才行。
五、slot 机制:让筛选栏不再打架
思路很简单:把一个请求和它代表的数据源绑定起来。同一个页面里,同一个数据源同时只允许有一个在途请求,新的进来就把旧的踢掉。
这个数据源的标识,我管它叫 slot(槽位)。
http.get('/orders', { status: this.status }, {
slot: 'order-list'
})
不管参数怎么变,只要 slot 都是 order-list,那么后发起的请求就会取消先发起的那个。筛选栏连点几下也不会串了。
改一下刚才的封装,加上 slot 的支持。
// utils/request.js 的补充部分
// 页面作用域对象
export function createRequestScope() {
const records = new Set() // 这个页面上所有在途请求
const slots = new Map() // slot 名 -> 该槽位当前的请求
return {
add(record) {
records.add(record)
},
remove(record) {
records.delete(record)
// 如果这个 record 还占着某个 slot,一并释放
for (const [name, r] of slots) {
if (r === record) slots.delete(name)
}
},
occupy(slot, record) {
const prev = slots.get(slot)
if (prev && prev !== record) {
prev.abort('slot-replaced')
}
slots.set(slot, record)
},
destroy(reason = 'scope-destroyed') {
records.forEach(r => r.abort(reason))
records.clear()
slots.clear()
},
get size() {
return records.size
}
}
}
然后 request 函数里加上 slot 的处理逻辑:
export function request(options = {}) {
const {
url,
method = 'GET',
data,
header = {},
timeout = DEFAULT_TIMEOUT,
dedupe = 'share',
slot = null, // 新增:该请求占用的槽位名
silent = false,
withToken = true
} = options
const key = hashKey(method, url, data)
const scope = currentScope()
// 1. 相同参数的请求去重
if (dedupe === 'share' && inflight.has(key)) {
return inflight.get(key).promise
}
if (dedupe === 'replace' && inflight.has(key)) {
inflight.get(key).abort('replaced')
}
// 2. 同一槽位只保留最新
if (slot && scope) {
const prev = scope.getSlot(slot)
if (prev) prev.abort('slot-replaced')
}
// ... 中间 uni.request 的部分和上一版一样 ...
record = {
key,
promise,
slot,
abort(reason = 'manual') {
if (abortReason) return
abortReason = reason
if (task && typeof task.abort === 'function') {
task.abort()
}
}
}
if (scope) {
scope.add(record)
if (slot) scope.occupy(slot, record)
}
if (dedupe === 'share' || dedupe === 'replace') {
inflight.set(key, record)
}
promise.abort = record.abort
return promise
}
complete 回调里也要做清理:
complete() {
inflight.delete(key)
if (scope) scope.remove(record)
}
这样一套下来,页面上就可以这么写了:
async loadOrders() {
try {
const list = await http.get('/orders', {
status: this.status,
page: this.page
}, {
slot: 'order-list'
})
this.list = list
} catch (e) {
if (e.name === 'AbortError') {
// 被更新的请求顶掉了,正常现象,直接忽略
return
}
// 其他错误
}
}
被取消的请求会在 catch 里拿到 AbortError,直接 return 就行。这样旧请求的结果永远不会污染新数据。
这里有个细节值得提一下:分页加载和筛选不能共用一个 slot。因为「加载第 2 页」和「筛选切换」是两种不同的语义,前者想要追加,后者想要替换。共用一个 slot 的话,用户滑到底部触发加载更多,就会把当前页的请求取消掉,列表直接空白。正确做法是给分页单独用另一个 slot,或者分页请求干脆不设 slot,只设 dedupe: 'share' 防止重复触发。
六、把请求绑到页面上,页面走了全部掐掉
光有 slot 还不够。用户从列表页点进详情页,列表页的请求如果不取消,会一直消耗流量和电量。虽然这些请求回来之后更新的是已经离屏的页面,用户看不到,但内存和电量的浪费是实打实的。
而且,如果用户从列表页点进详情页,又从详情页返回列表页,此时列表页可能残留着几个旧请求,它们回来后把新加载的数据盖掉,会出现类似「返回后数据变旧」的现象。
所以需要把每个请求和发起它的那个页面绑定起来,页面一销毁,它发出去的所有请求一起取消。
// utils/request.js
function topPage() {
const pages = getCurrentPages()
return pages.length ? pages[pages.length - 1] : null
}
function currentScope() {
const page = topPage()
if (!page) return null
return page._reqScope || null
}
export function abortPageRequests(page, reason = 'page-destroy') {
const target = page || topPage()
if (target && target._reqScope) {
target._reqScope.destroy(reason)
}
}
然后在 main.js 里挂一个全局 mixin,给每个页面实例在 onLoad 时创建 scope,onUnload 时销毁:
// main.js(Vue 3 版本)
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createRequestScope } from '@/utils/request'
export function createApp() {
const app = createSSRApp(App)
app.mixin({
onLoad() {
if (!this._reqScope) {
this._reqScope = createRequestScope()
}
},
onUnload() {
if (this._reqScope) {
this._reqScope.destroy('page-unload')
this._reqScope = null
}
}
})
return { app }
}
Vue 2 的项目里换成 Vue.mixin({...}),结构完全一致。
有个容易被忽略的点:onHide 要不要也取消?
我倾向于不取消,但可以对长时间在后台的页面做特殊处理。原因是用户可能只是切了个微信去回消息,几秒钟就回来了,这时候把列表请求取消掉,回来之后页面还是空的,体验反而更差。
如果确实需要处理长时间后台的情况,可以在 onHide 时记个时间戳,onShow 时判断间隔超过一定阈值(比如 5 分钟)再刷新数据。
七、各端差异和几个踩过的坑
1. abort 之后的 Promise 一定要处理
前面提到过,abort 会让 promise reject。如果调用方没有 catch,会污染控制台。有两种处理方式:封装里加内部 catch(上面已经做了),或者约定调用方必须处理 AbortError。
两种都行,但千万不能两边都不管。我见过有项目因为这个问题,控制台里刷了几千条 unhandled rejection,把真正的问题全都埋住了。
2. 重复调用 abort 没有意义
一个请求已经被取消了,再调一次 abort,小程序端不会有任何反馈,H5 端可能抛个异常。上面代码里用 if (abortReason) return 做了保护,这是个必要的防御。
3. 在 H5 端,同步代码里调 abort 可能来不及
有次写了这么一段:
const p = http.get('/slow-endpoint')
p.abort()
本意是发出去立刻就取消掉,实际上在 H5 端请求已经发出去了,abort 也确实调用了,但 XHR 的 abort 需要在网络栈里排队处理。看起来是取消了,只是后端可能已经收到了请求。所以不要指望用 abort 做「撤销业务操作」,它只是客户端层面的取消。
4. 网络错误和被取消要严格区分
因为两者都会走 fail 回调。如果代码里靠 errMsg.indexOf('abort') 来判断,在不同端会出各种幺蛾子。上面代码里用 abortReason 这个闭包变量来标记,是最稳妥的做法。
5. 上传和下载是另一套 API
uni.uploadFile 和 uni.downloadFile 同样返回 task 对象,也支持 abort,需要单独封装一遍。它们的错误处理逻辑和请求不太一样,但取消机制可以复用同一套 scope 和 slot 结构。这个工作本文不展开了。
6. 别把 scope 存在 data 里
这是个 Vue 使用者容易犯的错误。scope 里包含 Set 和 Map 这种不可序列化的结构,放到 data 里会被 Vue 的响应式系统包一层,性能会掉得很难看。正确做法是挂在组件实例上作为一个普通属性,用 this._reqScope 访问。
八、完整版的一个小优化
上面拼出来的代码已经能跑了,但还有两个可以优化的小点。
第一,给请求加一个统一的前置钩子,方便做一些全局事情,比如加上时间戳防缓存、加上设备信息、或者记录埋点:
// 在 uni.request 之前,finalHeader 组装完之后
const beforeSend = request.beforeSend
if (typeof beforeSend === 'function') {
const extra = beforeSend({ url, method, data, header: finalHeader })
if (extra) Object.assign(finalHeader, extra)
}
第二,把常用模块的 API 抽到一个独立的 api 目录下,页面只关心「调用哪个方法」,不关心 URL 和参数结构:
// api/order.js
import { http } from '@/utils/request'
export function fetchOrderList(params, options = {}) {
return http.get('/orders', params, {
slot: 'order-list',
...options
})
}
export function submitOrder(payload) {
return http.post('/orders', payload, {
dedupe: 'share', // 防重复提交
silent: false
})
}
页面里就变成:
const list = await fetchOrderList({ status: this.status })
清爽很多,而且 slot 的命名策略集中在一处,改起来方便。
九、收尾
这套方案不是银弹。它解决的是「同一数据源、同一用户意图下多次请求互相干扰」这一类问题。如果业务本身就是需要多个请求同时并发、结果合并展示的,那就要另外设计,比如用 Promise.all 配上取消信号。
但从实际项目经验来看,页面上大部分数据加载都属于前面那种模式——用户想看的是「最新状态」,中间过程无所谓。想清楚这一点,剩下的就是工程实现的细节了。
回头看,整个方案的核心其实就一句话:把「取消」这件事,从调用方的自觉行为,变成框架层面的默认行为。页面上写代码的时候不需要每次都想着「万一用户手快怎么办」,框架自己会处理。这才是让这类 bug 从根上消失的办法,比到处加防抖、加遮罩要可靠的 多。

