取消请求与竞态防护
异步数据与错误处理 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
竞态(race condition)是异步界面最阴险的 bug:慢请求覆盖快请求的结果。这一节掌握 AbortController 取消请求、用请求序号丢弃过期响应,以及 V05 的 onCleanup 在这里的用法。
问题场景
搜索框输入「vue」,300ms 后请求 A 发出;用户继续输入「vue3」,请求 B 发出。B 先返回并渲染,1 秒后 A 才返回,把结果覆盖成旧的——用户看到的关键词是「vue3」,列表却是「vue」的结果。网络越慢越容易复现。这不是防抖能单独解决的:防抖只是减少请求数量,只要允许并发,竞态就存在。
核心解释
两层防护:取消过期请求 + 校验响应新鲜度。
AbortController 是浏览器原生 API,fetch 支持 signal 选项:
<script setup>
import { ref, watch } from "vue";
const keyword = ref("");
const results = ref([]);
const loading = ref(false);
const error = ref("");
let controller = null;
watch(keyword, async (value) => {
// 取消上一次仍在途的请求
controller?.abort();
controller = new AbortController();
if (!value.trim()) {
results.value = [];
error.value = "";
return;
}
loading.value = true;
error.value = "";
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
results.value = await res.json();
} catch (err) {
if (err.name === "AbortError") return; // 主动取消,不当作错误
error.value = err.message;
} finally {
loading.value = false;
}
});
</script>
关键点:AbortError 要单独识别并吞掉——它不是失败,是用户行为导致的中断;finally 里重置 loading 会让被取消的请求也走到这里,所以如果多个请求并发,loading 状态会有抖动。更严谨的写法是配合请求序号:
let seq = 0;
watch(keyword, async (value) => {
const current = ++seq;
loading.value = true;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
const data = await res.json();
if (current !== seq) return; // 已有更新的请求,丢弃本次结果
results.value = data;
} catch (err) {
if (current !== seq) return;
error.value = err.message;
} finally {
if (current === seq) loading.value = false;
}
});
序号法的价值:即使请求已经发出、无法取消(比如服务端已经在处理),也能保证「只有最新请求的结果被采纳」。取消 + 序号双保险是生产级做法。
组件卸载时的清理:卸载后赋值给已销毁组件的状态不会报错(Vue 3 允许),但请求仍然占着网络。规范做法是把 controller 在 onUnmounted 里 abort,或者用 watch 的 onCleanup(V05):
watch(keyword, async (value, _old, onCleanup) => {
const ac = new AbortController();
onCleanup(() => ac.abort());
const res = await fetch(url, { signal: ac.signal });
// ...
});
和防抖的关系:防抖解决「请求太多」,取消解决「结果错乱」,两者配合使用。防抖 300ms + 每次触发取消上一次,是搜索框的标准配置。
示例拆解
一个可复现竞态的对照实验:服务端人为延迟(用 setTimeout 模拟),输入越快越容易看到旧结果覆盖:
// 模拟接口:延迟随关键词长度变化,制造「先发后到」
async function fakeSearch(q, signal) {
const delay = q.length <= 1 ? 1200 : 200;
await new Promise((resolve, reject) => {
const t = setTimeout(resolve, delay);
signal?.addEventListener("abort", () => {
clearTimeout(t);
reject(new DOMException("aborted", "AbortError"));
});
});
return [{ id: q, title: `结果:${q}` }];
}
操作:先输入「a」,立刻输入「ab」。不加取消时,1.2 秒后列表变成「结果:a」;加取消后,只剩「结果:ab」。这个实验能让你在真实项目里一眼认出竞态。
动手任务
- 完成上面的对照实验,截图记录加取消前后的差异。
- 把「取消」和「序号」两种机制同时用上,人为让接口不响应 abort(忽略 signal),验证序号仍能防止覆盖。
- 给一个「切换课程」的详情页加上
onCleanup,用 Network 面板确认快速切换时旧请求显示 canceled。
常见错误
- 把 AbortError 当请求失败:页面显示「请求失败」红字,其实是用户自己触发的取消。单独特判。
- 只防抖不取消:并发请求依然存在,慢请求仍会覆盖。两者都要。
- 在 finally 里无条件重置 loading:被取消的旧请求把新请求的 loading 关掉,界面闪烁。
- 卸载后不取消:请求继续发,组件已销毁,浪费带宽还可能报状态更新警告。
检查与复盘
- 能写出带 AbortController 的搜索请求并正确忽略 AbortError
- 能解释序号法解决什么、取消解决什么
- 能说出防抖与取消的分工
参考资料
- MDN:AbortController 与 fetch 的 signal
- Vue 官方文档「侦听器」:onCleanup 的触发时机
读完这一课了?
标记完成后,工作台会安排接下来的学习。