这几天我在做一个跨平台的社区小程序,遇到一个非常经典又恼火的bug:用户搜索关键词时,我监听输入框实时请求接口,结果只要打字稍微快一点,页面显示的结果老是和搜索框里的词对不上。
比如输入“苹果”之后没等结果回来,又删掉改成“香蕉”,接口先返回了“香蕉”的数据。可是不知道哪次网络抖动,“苹果”的响应比“香蕉”还晚到,直接覆盖了正确的页面数据。更糟糕的是,在微信开发者工具里一切正常,到了手机App上就概率性复现。
这不是后端接口的问题,而是典型的“请求竞态”。简单说,就是多个请求顺序发出去,后端的处理速度和网络传输并不保证按发送顺序返回。谁先到谁就展示,那用户的最后一步操作可能被更早的老请求覆盖。
这种问题在web开发里很常见,程序员们会利用AbortController、axios取消令牌之类的方式去处理。但真放到uni-app跨端环境下,事情就没那么顺滑了。因为小程序里没有真正的AbortController,uni.request虽然能返回一个task对象,但有的平台支持abort,有的平台abort会失效。如果只依赖官方能力,很容易写出只有一端正常的代码。
所以我决定从业务层解决掉竞态,封装一个自定义的useRequest组合式函数。这样不管底层用的是uni.request还是axios,都能保证“只有最后一次操作触发的结果才能更新页面”。
先想清楚竞态的本质
竞态发生的充分条件有以下三条:
- 请求A先发出
- 请求B后发出
- 响应B先到,响应A后到
前两条已经决定,我们控制不了请求A的返回顺序,那就只能让页面无视响应A的产生。最简单粗暴的办法是给每一次请求发一个序号,比如第一次发id=1,第二次发id=2,响应回来时检查id是不是全局最新的。如果不是,直接丢弃。
这类似于前端防抖的debounce思想,其实是“只认最后动作”。所有请求照样发到后端,但页面状态只认最后一次。
我们可以不依赖任何第三方库,自己实现一个轻量的请求拦截器。
先把基础架子搭出来
先定义一个普通版useRequest,它接收一个异步函数fn,函数内部需要调用uni.request或者任何网络库。我们包装出一个run方法来触发请求。
做法是在闭包里维护一个latestToken,每次run时自增。异步回调返回后,判断当时的token是否仍然等于latestToken,如果不是就直接return。
function useRequest(apiFn) {
let latestToken = 0;
let loading = false;
let data = ref(null);
let error = ref(null);
const run = (...args) => {
const currentToken = ++latestToken;
loading.value = true;
error.value = null;
return apiFn(...args).then((res) => {
if (currentToken !== latestToken) {
// 过期响应,直接丢弃
return null;
}
data.value = res;
loading.value = false;
return res;
}).catch((err) => {
if (currentToken !== latestToken) {
// 过期错误,也不处理
return null;
}
error.value = err;
loading.value = false;
throw err;
});
};
return {
data,
error,
loading,
run
};
}
上面代码里用到了响应式ref,在uni-app里如果使用vue3组合式API,这很自然。data、error、loading都是ref对象,模板直接用。
可以发现,这个版本已经能处理大部分竞态问题。后端再慢,也不怕旧响应覆盖新状态。不过它仍然存在两个小遗憾:
第一,请求并不会因为已经过期就被真正取消,这会浪费一点点网络资源。第二,如果用户连续触发run两次,loading.value会先true,然后第一次响应时马上设置为false;可能使用者希望最后一次请求结束后loading才变false,而不是过期请求回来就改掉加载状态。上面的写法虽然不会更新data,但loading会被过期请求置为false。
针对第二点,更好的处理方式是,每个请求结束都判token,但loading应该只由最新请求控制。所以我们可以把loading改成按token设置。
让loading也保持理智
我们可以使用一个计数器或者一个状态机维护正在pending的请求数。如果同一个useRequest实例连续触发了多个请求,loading应该一直为true,直到所有请求都完成?但那样不能反映最新请求状态,不过如果保证只有最新token才能改loading呢?
修改如下:loading的值也由token来判断,只有最新token结束才能改false。
const [loading, setLoading] = useState(false);
// 但这里要注意,如果最新token结束且成功,loading变false;如果最新token还在pending,loading则为true。
const run = (...args) => {
const currentToken = ++latestToken;
setLoading(true); // 先变true
return apiFn(...args).then(res => {
if (currentToken !== latestToken) return null; // 过期,不改变loading
setLoading(false);
// ...
return res;
}).catch(err => {
if (currentToken !== latestToken) return null;
setLoading(false);
// ...
throw err;
});
};
这种情况下,如果最后一次请求还没结束,旧的请求先结束也不会把loading置false,页面体验更符合预期。
但这里还差一步:如果第二次请求因为错误返回了,第二次就是最后一次,loading被置false。OK。
顺手把数据缓存也做了
既然都封装了,不如再加一个不痛不痒的缓存功能。我记得在uni-app开发中有一些页面比如活动详情、商品详情,用户每次进入都会白屏加载,很烦。我们可以给useRequest加一个简单的内存缓存,相同参数在过期时间(比如5秒)内直接返回之前的数据,并且不触发loading。
当然并非所有接口都需要缓存,所以给run加一个配置参数。为了保持简单,我们不使用装饰器,而是在函数参数里增加一个选项对象,或者单独导出一个requestWithCache。
今天重点在竞态,缓存功能点到为止。如果想深入了解,可以看看uni-app官方社区里的“useRequest”讨论帖。
在小程序里要不要真正取消请求?
先说结论:能取消就取消,不能取消就靠token。然后token方案是保底逻辑。
uni.request的返回值其实是一个requestTask,如果业务层有需求可以把它保存起来,下次发新请求时手动abort上一个请求。我们可以在useRequest内维护一个task,在每次run时如果存在上一个task就尝试取消。
let currentTask = null;
const run = (...args) => {
// 真正取消上一个请求:uni.request调用时会返回task,然后把task传给apiFn。
// 设计apiFn时,需要把task向外暴露。
}
但是实现起来有点别扭,因为我们的apiFn可能是被封装过的,不一定能拿到task。换个思路,我们让useRequest接收一个“请求实例”对象,对象里有request和cancel方法。这样框架无关性会更强。
不过在对的时间做对的事:如果我们发起一个新请求时,取消上一个过期请求,确实能省流量。对于搜索框的场景也有用。因为前面的请求已经不重要了,关了它还能避免不必要的状态回调。比较靠谱的做法是:用uni.request的task调用abort。而这个task必须从apiFn里透传出来。
所以我把apiFn的设计约定改成:它接收一个signal处理器,返回一个原生task对象。然后在useRequest内部保存task。
function useRequest(requestImplementation) {
let latestToken = 0;
let currentTaskRef = null;
// ...
const run = (...args) => {
// 如果上一个task还没结束,直接abort
if (currentTaskRef && typeof currentTaskRef.abort === 'function') {
try { currentTaskRef.abort(); } catch (e) {}
}
const currentToken = ++latestToken;
const task = requestImplementation({
args,
onResponse: (res) => { ... },
onError: (err) => { ... }
});
currentTaskRef = task;
};
return { run };
}
但是在uni-app中,uni.request的API结构是回调方式:success/fail,而不是promise。如果用promise包一层,task需要在promise里关联。所以我们可以设计这样一个辅助函数:
function createUniRequest(options) {
return new Promise((resolve, reject) => {
const task = uni.request({
...options,
success: resolve,
fail: reject
});
// 在promise上附加task
promise.abort = () => task.abort();
});
}
不过这样使用起来有点hack。其实对目前90%的uni-app项目,令牌token已经足够,因为请求消耗不大,只是等待回调。真追求极致响应体验,也可以管理task。
接下来我用一个实际案例,展示怎么利用刚才的useRequest实现一个关键词搜索列表。
实例:搜索页的防竞态处理
假设我们要做一个“百科搜索”页面,顶部有search框,下面是搜索结果列表。用户输入关键词后500ms请求一次接口,并且可能快速切换不同关键词。我们的useRequest需要保证:无论接口返回顺序如何,列表展示的一定是最新关键词的结果。
这是一个典型的搜索交互。很多前端开发会选择防抖函数,掉包为延时请求。但防抖并不能阻止先发出的请求在后发结果之后到达,所以还必须搭配竞态忽略逻辑。
先看页面模板:
<template>
<view class="container">
<input v-model="keyword" placeholder="请输入关键词" @input="onInput" />
<view v-if="loading">正在搜索...</view>
<view class="result" v-else>
<text v-for="item in resultList" :key="item.id">{{ item.name }}</text>
</view>
</view>
</template>
这里用v-model可能在小程序上会有点水土不服,但逻辑一样。我们更关心请求层代码。
编写useRequest核心部分,并让它支持在setup里被调用:
import { ref, watch } from 'vue';
const keyword = ref('');
// 这个searchApi其实就是调用uni.request,返回一个Promise
const searchApi = (keyword) => {
return new Promise((resolve, reject) => {
uni.request({
url: '/api/search?kw=' + encodeURIComponent(keyword),
method: 'GET',
success: (res) => {
// 假设res.data.data是数组
resolve(res.data);
},
fail: reject
});
});
};
const { data: resultData, loading, run } = useRequest(searchApi);
const debounceTimer = ref(null);
const onInput = () => {
if (debounceTimer.value != null) {
clearTimeout(debounceTimer.value);
}
debounceTimer.value = setTimeout(() => {
run(keyword.value);
}, 500);
};
// 注意,useRequest内部的run已经做了token处理
这段代码表面上已经很完善,但还有两个隐蔽问题:
一是如果上一次run的超时任务还没执行,新的输入事件又来了,旧的run会照常发出请求,而之后新的run又会把它置为过期,这没问题。
二是当keyword从“苹果”变为“苹果手机”时,用户可能已经输入到一半,两个关键词都能搜到很多结果。旧请求被token忽略,页面显示的是“苹果手机”的数据,这没问题。
但如果你把请求封装成“每次搜索强制刷新loading”的话,可能会出现比较闪烁的加载效果。所以我刚才说过,loading被旧请求置为false并不合理,我们调整后的useRequest可以避免。
完善版本的实际代码
因为上述分块版本的代码是片段,我直接给一个相对完整的useRequest实现,方便各位copy后跑起来。
import { ref } from 'vue';
function useRequest(apiFn) {
let latestToken = 0;
const loading = ref(false);
const data = ref(null);
const error = ref(null);
const isPending = ref(false);
async function run(...args) {
const currentToken = ++latestToken;
// 如果上一个请求还没完成,这里可以决定是否触发什么回调
if (isPending.value) {
// 可以使用uni.showLoading之类,不过略
}
loading.value = true;
isPending.value = true;
error.value = null;
try {
const res = await apiFn(...args);
// 响应回来,判断当前是否还是这个token
if (currentToken !== latestToken) {
// 过期,直接丢弃,不修改状态
return null;
}
data.value = res;
return res;
} catch (e) {
if (currentToken !== latestToken) {
return null;
}
error.value = e;
throw e;
} finally {
if (currentToken === latestToken) {
isLoading.value = false;
isPending.value = false;
}
}
}
// 供外部调用的reset,清空状态
const reset = () => {
latestToken++;
data.value = null;
error.value = null;
loading.value = false;
isPending.value = false;
};
return {
data,
loading,
error,
isPending,
run,
reset
};
}
这个版本有两个细节:一是resp过期后不会进入try? 注意catch里如果过期会直接返回null,抛不出错误。第二,在finally里只有当前token等于最新才关闭loading。这防止旧请求关闭loading。
如果用户连续run两次,第一次在finally时token不等于latestToken,所以loading保持true,直到第二次请求结束才变false。完美。
可能有朋友要问,如果apiFn本身是一个需要传参的uni.request包装函数,但是run方法还需要支持取消逻辑怎么办?关于取消,这个useRequest没有内置。我们可以改造run方法,传入一个信号对象或者让apiFn返回一个cancelable。
但在uni-app里,取消请求最稳妥的其实是uni.request的task。如果用promise包装,可以给promise附加abort属性。可以这样使用:
function abortableRequest(options) {
let task = null;
const promise = new Promise((resolve, reject) => {
task = uni.request({
...options,
success: (res) => {
if (promise.aborted) {
// 已经被外部abort,就不resolve了
return;
}
resolve(res);
},
fail: (err) => {
if (promise.aborted) {
return;
}
reject(err);
}
});
});
promise.abort = () => {
promise.aborted = true;
if (task) {
task.abort();
}
};
return promise;
}
然后在useRequest的run中,每次执行前先检查上一次返回的promise上有没有abort方法,有就调用。这种方案的兼容性也很好。
在页面unload时处理残留
如果我们跳转页面,而请求还没回来,理论上我们不应该再去更新页面状态。但由于uniapp页面是单页模式,可能在请求未返回时页面已经销毁。此时如果setState,轻则警告,重则内存泄漏。使用token的useRequest还可以在onUnload里调用reset,让所有返回的过期token都不再触发更新。
onUnload(() => {
// 假设reset会让latestToken自增,所有pending请求全部变成过期
reset();
});
记住在setup中可以这样写:
import { onUnload } from '@dcloudio/uni-app';
// 注意:小程序端生命周期在@dcloudio/uni-app里
onUnload触发时执行reset,之后任何响应回来都变成过期状态。这能防止uniapp在页面关闭后还能修改内存中变量的情况。
关于状态的持久性
这里插一个和竞态无关但经常一起出现的问题:页面跳转返回后,希望保留之前的列表数据。我们封装的data是一个ref,它在页面实例存活期间会一直存在。如果你在onLoad里初始加载,返回再回来页面会被重新创建,data自然丢失。这是预期行为。
如果希望搜索页数据在全局共享,可以使用pinia。但要注意pinia中的getter是同步的,异步请求需要放入action里。这与useRequest并不冲突,可以把data放到pinia中,并把run暴露为action。
更多异步边界
有时候用户触发切换tab,但上一个tab的useRequest还没结束。这时候除了token,还要防止在请求关闭后触发computed或watch造成新页面的抖动。
在uni-app的小程序端,当页面onHide时也可以主动reset,但要注意reset后loading变false会可能影响正在展示的动画。所以通常只在onUnload时reset,onHide保留状态以便回来时继续展示。
再深挖一点,code分割的时候,我们需要让useRequest支持“参数缓存”。快速点击两次搜索时,如果关键词完全相同,可以直接用上一次的data,不触发网络请求。这种场景很常见,比如用户清空又填入同一个词。
做法很简单,在run内部维护一个lastParams和lastResult,比较当前参数和上次参数是否一致,一致就直接返回缓存。
function useRequest(apiFn, options = {}) {
const { cacheTime = 0 } = options;
let latestToken = 0;
let cache = { params: null, data: null, time: 0 };
// ...
async function run(...args) {
const key = args[0];
if (cacheTime > 0 && cache.params === key && Date.now() - cache.time < cacheTime) {
return cache.data;
}
// ...
const res = await apiFn(...args);
if (currentToken === latestToken) {
cache = { params: key, data: res, time: Date.now() };
}
return res;
}
}
数组参数做缓存key有点麻烦,可以使用JSON.stringify序列化成字符串。但在uniapp中参数往往是对象,建议用专门参数key函数处理。这里就不再展开细节了。
真实的踩坑:在微信小程序里task.abort不一定中断fail
有朋友在社区反馈,当你调用task.abort()后,微信小程序的fail回调也会收到一个错误。但有些基础库版本里,请求还是会正常返回success。这就导致即使abort了,原先的成功回调也可能触发。这也是token为什么不能丢的原因。
所以我的建议是:把token当作唯一可信的来源。task.abort只是省流量的辅助手段,不管它的fail是否真的回调,我们一律以token判断。如果abort成功,fail回调可能会执行,但token已经不对,就不会更新数据。
// 一个典型的cancelable的request函数写法
function uniRequestCancelable(url, data = {}) {
let task;
const promise = new Promise((resolve, reject) => {
task = uni.request({
url,
data,
success: resolve,
fail: (err) => {
// 某些情况下abort会触发fail
if (promise.__aborted) {
// 这个错误不能向外抛
return;
}
reject(err);
}
});
});
promise.abort = () => {
promise.__aborted = true;
if (task) {
task.abort();
}
};
return promise;
}
在useRequest中这样使用:
const p = req(...args); if (p.abort) p.abort(); // 取消上一个请求
由于promise.__aborted置为了true,fail回调直接忽略错误,不会让useRequest的错误处理器介入。这很干净。
解决完竞态之后,页面流畅多了
写了这么一大圈,其实核心代码加起来不超过80行。但实际效果立竿见影:用户无论多快地切换关键词,页面数据始终能正确对应输入框的文字。
没有引入复杂度很高的rxjs或者axios取消机制,就是一个token加几个if判断,就解决了跨端难题。如果哪一天我接到一个新的uniapp项目,我第一件事就是把它封装成公共hook,让团队内的小朋友们不再写重复的竞态逻辑。
所谓“性能优化”,往往不一定是张牙舞爪的链表改虚拟滚动,反而很多都是像这样把脏活、杂活收拢在底层。以后页面层只需要关心业务展示,不需要关心老请求会不会串台。
补充一点:千万不要等到出bug再做竞态处理
页面有一个搜索框,或者筛选条件,通常是竞态高发区。不管项目里使用的请求库是axios还是uni.request,从第一天就用类似useRequest的方式去做,收益真的是最高的。
如果业务代码里到处都是裸的uni.request调用,然后手动在.success回调里给赋值,后期再加竞态逻辑会导致你需要改动所有页面。还不如一开始就定好规范:页面上所有数据请求都必须走useRequest。
另外,有的团队还会用函数防抖和节流来掩盖竞态,但那只是推迟了问题发生。用户的网络环境千变万化,一个响应延迟夸张的旧请求还是会像幽灵一样冒出来。因此要记住,防抖是优化输入频率,令牌是保证顺序正确,两者结合起来才叫完美。
代码之外的一点感想
我非常喜欢uni-app的一点,就是它至少让跨端逻辑在大多数时候是一致的。但跨端带来的API差异仍然很多,比如这个task.abort在各端的表现不一,所以自己封装一层稳定逻辑显得尤其重要。
想一下,如果我们只是写一套web端代码,根本不用这么麻烦。但“一次编写,多端运行”的浪漫背后,就是要处理这些边边角角的差异。好在封装后,这些差异都屏蔽掉了。
希望这篇实战记录对你有点帮助。也欢迎你在评论区说说你们项目里是怎么处理请求竞态的,我最喜欢看奇奇怪怪的做法了。

