Vue3 竞态请求防护组合式hooks:useLatestRequest 设计思路
一、问题:什么是竞态请求
在列表搜索、按日期切换查询、级联下拉等场景里,用户连续触发多次请求时,由于网络耗时不确定,先发出的请求可能后返回,导致旧数据覆盖新数据:
vbnet
t0: 点"9月7日" → 发起请求 A
t1: 点"9月8日" → 发起请求 B
t2: B 先返回 → 界面显示 9月8日数据 ✓
t3: A 后返回 → 界面被覆盖成 9月7日数据 ✗(错误!)
要解决这类竞态,需要两件事:
- 取消上一次未完成的请求(避免它继续占网络、避免它的回调污染状态)
- 丢弃过期结果(即使旧请求完成了,也不让它的结果生效)
二、手写实现的样板
在封装之前,每个需要竞态防护的地方都得手写一遍类似逻辑:
ts
let searchController: AbortController | null = null;
let requestId = 0;
const search = async (keyword: string) => {
searchController?.abort(); // 取消上一次
const controller = new AbortController();
const currentId = ++requestId; // 序号
searchController = controller;
isLoading.value = true;
list.value = [];
try {
const res = await searchApi(keyword, controller.signal);
if (currentId !== requestId) return; // 已过期,丢弃
list.value = res.data;
} finally {
if (currentId === requestId) { // 仅最新请求复位 isLoading
searchController = null;
isLoading.value = false;
}
}
};
onBeforeUnmount(() => {
searchController?.abort();
});
问题在于:这套逻辑在每个用到竞态防护的地方都要复制一遍 ,且 controller / requestId / onBeforeUnmount 散落在业务代码里,容易遗漏。于是抽成 composable。
三、封装后的使用方式
ts
const { isLoading, run } = useLatestRequest();
const res = await run((signal) => searchApi(keyword, signal));
if (!res) return;
list.value = res.data;
业务代码只剩「调哪个接口、传什么参数、拿到结果怎么处理」,取消、序号、loading、卸载清理全部隐式完成。
四、核心设计:工厂函数 + signal 注入
这是整个封装最精妙的一点。run 接收的是一个函数 ,而不是一个 Promise:
ts
run: <T>(factory: (signal: AbortSignal) => Promise<T>) => Promise<T | null>;
4.1 为什么不能接收 Promise<T>
如果 run 直接接收 Promise<T>:
ts
// ❌ 错误设计
run(someApi(payload)) // someApi(payload) 在这一刻请求就已经发出了
调用方在构造 someApi(payload) 这个 Promise 时,请求已经发出去 了。run 此时拿到的是「已经在路上的请求」,它再创建 AbortController 也来不及------因为 AbortSignal 必须在请求发出之前 就和底层 axios/fetch 绑定,才能在之后被 abort() 取消。
一个已经发出的 Promise,无法再注入 signal。
4.2 正确做法:让 run 拥有 controller 生命周期
反过来:run 先自己创建 controller、拿到 signal,再把 signal 当参数喂给工厂函数,由工厂去真正发起请求并把这个 signal 传进 axios:
ts
const run = async <T>(factory: (signal: AbortSignal) => Promise<T>): Promise<T | null> => {
controller?.abort(); // 先取消上一次
const currentController = new AbortController(); // 自己造 controller
const currentId = ++latestId; // 序号
controller = currentController; // 持有引用,下次能 abort 它
isLoading.value = true;
try {
// 把 signal 注入业务请求 ------ 请求在这一刻才真正发出
const result = await factory(currentController.signal);
if (currentId !== latestId) return null; // 已过期,丢弃
return result;
} catch (error) {
if (currentId === latestId) throw error; // 最新请求的真实异常才抛
return null; // 被取消/过期的静默丢弃
} finally {
if (currentId === latestId) { // 仅最新请求复位 isLoading
controller = null;
isLoading.value = false;
}
}
};
调用方的写法因此变成:
ts
await run((signal) =>
searchApi(keyword, signal), // 请求在这里才真正发出
);
signal 是 run 生成的、run 拥有其生命周期,所以:
- 下一次调用 时
run能controller.abort(),触发的取消异常会回到run自己的 catch 被静默吞掉,不会污染业务。 - 组件卸载 时(
onScopeDispose)也能cancel(),避免请求悬挂。
4.3 控制反转(IoC)
这是一种典型的控制反转:不是业务代码去关心取消逻辑,而是 run 把取消能力「下放」给业务去用。
| 关注点 | 谁负责 |
|---|---|
| 调哪个接口、传什么参数 | 调用方(业务) |
| 何时取消、是否过期 | run(横切) |
| loading 状态、卸载清理 | run(横切) |
业务和横切关注点彻底分离,业务代码回归纯粹。
五、序号 latestId 是第二道保险
signal 取消是第一道保险 :请求在途时被 abort() → axios 抛 CanceledError → 进 catch。
但如果 factory 没用 signal (比如某些接口不支持 AbortSignal),请求仍会正常 resolve,这时靠序号判断丢弃:
ts
if (currentId !== latestId) return null; // 期间又来了新请求,旧结果作废
两道保险互补:
- 在途的请求 → 靠
abort()取消 - 已完成的请求 → 靠序号丢弃
即使业务方忘记传 signal,也不会出错,只是少了「省网络」的好处。
六、返回 T | null 的语义
run 返回 Promise<T | null>,三种结局对应不同处理:
| 场景 | 返回值 | 调用方处理 |
|---|---|---|
| 最新请求成功 | T |
正常使用 |
| 被取消或已过期 | null |
if (!res) return; 直接跳过 |
| 最新请求的真实异常 | 向上抛 | 让业务 try/catch |
这样调用方既能拿到「最新数据」,又不用自己写 try/catch 处理竞态取消异常------因为取消异常已经被 run 内部吞掉了。
七、卸载清理:onScopeDispose(cancel, true)
ts
onScopeDispose(() => {
cancel();
}, true);
onScopeDispose在当前 effect scope(组件 setup 作用域)销毁时触发,等价于组件卸载时自动取消未完成请求。- 第二个参数
true是failSilently:如果 composable 被在脱离组件的地方调用(无活跃作用域),静默不注册,避免 dev 警告。
比手写 onBeforeUnmount 更通用:不依赖组件实例,任何 effectScope 销毁都会触发。
八、多个请求怎么办
一个页面有多个需要竞态防护的请求时,各调一次 useLatestRequest() 即可,互不干扰:
ts
const { isLoading: isLoadingList, run: runList } = useLatestRequest();
const { isLoading: isLoadingDetail, run: runDetail } = useLatestRequest();
每个实例很轻(几个变量 + 一个 shallowRef + 一次 onScopeDispose),写法也最直白。
九、完整源码
ts
import { onScopeDispose, shallowRef } from "vue";
import type { ShallowRef } from "vue";
export interface UseLatestRequestReturn {
/** 是否有进行中的请求(仅最新一次请求结束后才会重置为 false) */
isLoading: ShallowRef<boolean>;
/** 发起一次请求:自动取消上一次,仅最新一次的结果会返回,被取消的请求静默丢弃 */
run: <T>(factory: (signal: AbortSignal) => Promise<T>) => Promise<T | null>;
/** 主动取消当前进行中的请求 */
cancel: () => void;
}
/**
* 封装"最新请求生效"模式:每次发起请求时取消上一次,
* 仅保留最新一次的结果,避免竞态;作用域销毁时自动取消未完成请求。
*/
export const useLatestRequest = (): UseLatestRequestReturn => {
let controller: AbortController | null = null;
let latestId = 0;
const isLoading = shallowRef(false);
const run = async <T>(factory: (signal: AbortSignal) => Promise<T>): Promise<T | null> => {
// 先取消上一次未完成的请求,再开新请求并递增序号。
controller?.abort();
const currentController = new AbortController();
const currentId = ++latestId;
controller = currentController;
isLoading.value = true;
try {
const result = await factory(currentController.signal);
// 期间又发起了更新的请求,本次结果作废。
if (currentId !== latestId) {
return null;
}
return result;
} catch (error) {
// 仅最新请求的真实异常向上抛出;被取消或已过期的请求静默丢弃。
if (currentId === latestId) {
throw error;
}
return null;
} finally {
if (currentId === latestId) {
controller = null;
isLoading.value = false;
}
}
};
const cancel = (): void => {
controller?.abort();
controller = null;
isLoading.value = false;
};
// 无活跃作用域(脱离组件 setup)时静默不注册,避免 dev 警告。
onScopeDispose(() => {
cancel();
}, true);
return { isLoading, run, cancel };
};
十、要点速记
- 工厂函数而非 Promise :
run接收(signal) => Promise<T>,让它自己造 controller 并把 signal 注入请求,才能取消「还没发出的请求」。 - 控制反转 :取消、序号、loading、卸载清理归
run,业务只管调接口和处理结果。 - 双保险 :在途请求靠
abort(),已完成请求靠序号latestId丢弃。 T | null语义 :最新成功返T,过期/取消返null,真实异常才抛。onScopeDispose(cancel, true):卸载自动取消,且脱离作用域时不警告。- 单实例
useLatestRequest:多个请求各调一次即可,互不干扰。