Vue3 竞态请求防护组合式hooks:useLatestRequest 设计思路

Vue3 竞态请求防护组合式hooks:useLatestRequest 设计思路

一、问题:什么是竞态请求

在列表搜索、按日期切换查询、级联下拉等场景里,用户连续触发多次请求时,由于网络耗时不确定,先发出的请求可能后返回,导致旧数据覆盖新数据:

vbnet 复制代码
t0: 点"9月7日" → 发起请求 A
t1: 点"9月8日" → 发起请求 B
t2: B 先返回 → 界面显示 9月8日数据 ✓
t3: A 后返回 → 界面被覆盖成 9月7日数据 ✗(错误!)

要解决这类竞态,需要两件事:

  1. 取消上一次未完成的请求(避免它继续占网络、避免它的回调污染状态)
  2. 丢弃过期结果(即使旧请求完成了,也不让它的结果生效)

二、手写实现的样板

在封装之前,每个需要竞态防护的地方都得手写一遍类似逻辑:

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),  // 请求在这里才真正发出
);

signalrun 生成的、run 拥有其生命周期,所以:

  • 下一次调用runcontroller.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 作用域)销毁时触发,等价于组件卸载时自动取消未完成请求。
  • 第二个参数 truefailSilently:如果 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 };
};

十、要点速记

  1. 工厂函数而非 Promiserun 接收 (signal) => Promise<T>,让它自己造 controller 并把 signal 注入请求,才能取消「还没发出的请求」。
  2. 控制反转 :取消、序号、loading、卸载清理归 run,业务只管调接口和处理结果。
  3. 双保险 :在途请求靠 abort(),已完成请求靠序号 latestId 丢弃。
  4. T | null 语义 :最新成功返 T,过期/取消返 null,真实异常才抛。
  5. onScopeDispose(cancel, true):卸载自动取消,且脱离作用域时不警告。
  6. 单实例 useLatestRequest:多个请求各调一次即可,互不干扰。
相关推荐
_zhourui_h_1 小时前
一个 [ECS] 到底生成了多少代码?EasyECS 最核心的秘密都在这里
前端
梨想橙汁1 小时前
Git 分支详解:创建切换、合并分支、冲突解决实战
前端·git
雪芽蓝域zzs1 小时前
Vue3 defineProps` / `defineEmits` 是编译器宏,不需要手动 import 导入
前端·javascript·vue.js
whyweplay1 小时前
Elpis:从Json Schema 到 页面
前端·javascript
做萤石二次开发的哈哈2 小时前
海康班班通交互一体机技能接入实战:ISAPI透传+OTAP双协议封装,Web/App/小程序教学管理应用一站生成
前端·物联网·小程序·交互·萤石开放平台·蓝海aiot一站式工作台·aiot开发
计算机魔术师2 小时前
Anthropic一次性锁死十年算力,5170亿美元买什么
前端
IT_陈寒2 小时前
Redis的订阅丢失消息?你可能忘了这个配置
前端·人工智能·后端
头茬韭菜2 小时前
第 3 篇:「Pydantic 即 Schema」—— 工具生态三层解剖
前端·chrome·ai·openmanus