跳到主内容

一文搞懂 Vue3 请求防抖轮询 Hook 封装 ​

为什么需要统一的请求 Hook ​

在 Vue3 业务开发中,搜索联想、状态刷新、订单进度、日志 tail 等场景几乎都会同时出现三个诉求:输入要防抖、结果要轮询、组件卸载或重新请求时要清理。很多团队的写法是在每个页面各写一份 setTimeout、setInterval 和 loading 状态,短期看能用,长期却埋了一堆隐患:旧请求晚于新请求返回导致数据“闪回”、页面切走后定时器仍在跑、防抖与轮询计时器互相叠加、报错后轮询永不停止。

组合式函数(Composables)正是解决这类横切逻辑的最佳载体。把请求生命周期收敛到一个 Hook 里,业务侧只保留“传函数、拿状态、调方法”,既能复用,也方便单测。本文实现一个生产可用的 usePollingRequest,覆盖手动触发、防抖触发、轮询、竞态保护、错误重试上限与完整清理。

方案对比:watch、watchEffect 还是独立 Hook ​

常见有三种组织方式:

  • 直接在组件里 watch + setInterval:上手快,但计时器散落在各组件,清理逻辑容易漏,复制粘贴成本高。
  • watchEffect 自动收集:适合依赖即触发的简单请求,但防抖、暂停、手动刷新、竞态取消等命令式能力很难优雅表达。
  • 封装独立 Composable:以 ref 暴露状态、以普通函数暴露控制能力,内部统一管理定时器与 AbortController,可在任意组件复用并被测试。

对于“防抖 + 轮询 + 清理”这种命令式与响应式混合的需求,独立 Hook 是最稳妥的选择。

整体执行流程 ​

调用 run / debouncedRun清理旧计时器与请求发起请求(AbortController)成功写入 data开启下一轮轮询setTimeout 递归stop / 卸载统一清理

关键点有两个:轮询使用递归 setTimeout 而不是固定节奏的 setInterval,保证“上一次请求回来后再等待间隔”,避免请求堆积;每次触发都先让上一轮的 AbortController 失效,从根上消除竞态。

TypeScript 实现:usePollingRequest ​

下面代码可直接放入 src/composables/usePollingRequest.ts:

typescript
import { ref, shallowRef, onBeforeUnmount, readonly } from 'vue';

export interface UsePollingRequestOptions {
  /** 防抖等待毫秒,0 表示不防抖 */
  debounceMs?: number;
  /** 轮询间隔毫秒,0 表示只请求一次 */
  pollingMs?: number;
  /** 连续错误达到该值后自动停止,默认 5 */
  maxErrorRetry?: number;
  /** 是否在组件卸载时自动清理,默认 true */
  autoCleanup?: boolean;
}

// 请求函数接收 AbortSignal,便于对接 fetch 或自定义取消
export type RequestFn<T> = (signal: AbortSignal) => Promise<T>;

export function usePollingRequest<T>(
  requestFn: RequestFn<T>,
  options: UsePollingRequestOptions = {},
) {
  const {
    debounceMs = 0,
    pollingMs = 0,
    maxErrorRetry = 5,
    autoCleanup = true,
  } = options;

  // 返回数据可能是对象/数组,shallowRef 避免无谓深响应
  const data = shallowRef<T>();
  const loading = ref(false);
  const error = shallowRef<unknown>(null);
  const running = ref(false);
  const errorCount = ref(0);

  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
  let pollingTimer: ReturnType<typeof setTimeout> | null = null;
  let controller: AbortController | null = null;

  function clearTimers() {
    if (debounceTimer) clearTimeout(debounceTimer);
    if (pollingTimer) clearTimeout(pollingTimer);
    debounceTimer = null;
    pollingTimer = null;
  }

  function abortCurrent() {
    // abort 会让上一个请求的 signal 失效,旧结果不再写入
    controller?.abort();
    controller = null;
  }

  function schedulePolling() {
    if (!running.value || pollingMs <= 0) return;
    pollingTimer = setTimeout(() => {
      exec();
    }, pollingMs);
  }

  async function exec() {
    // 核心:新一轮开始前,先取消旧请求、清旧轮询
    abortCurrent();
    if (pollingTimer) clearTimeout(pollingTimer);

    controller = new AbortController();
    loading.value = true;
    try {
      const result = await requestFn(controller.signal);
      // 仅当当前 controller 仍是这一轮时才落数据
      if (!controller || controller.signal.aborted) return;
      data.value = result;
      error.value = null;
      errorCount.value = 0;
      schedulePolling();
    } catch (err) {
      if ((err as Error)?.name === 'AbortError') return;
      error.value = err;
      errorCount.value += 1;
      if (errorCount.value >= maxErrorRetry) {
        stop();
      } else {
        schedulePolling();
      }
    } finally {
      loading.value = running.value;
    }
  }

  /** 立即发起一次请求并(按配置)进入轮询 */
  function run() {
    clearTimers();
    running.value = true;
    exec();
  }

  /** 防抖入口:搜索框输入等场景使用 */
  function debouncedRun() {
    clearTimers();
    running.value = true;
    if (debounceMs <= 0) {
      exec();
      return;
    }
    debounceTimer = setTimeout(() => exec(), debounceMs);
  }

  function stop() {
    running.value = false;
    clearTimers();
    abortCurrent();
    loading.value = false;
  }

  if (autoCleanup) {
    onBeforeUnmount(stop);
  }

  return {
    data: readonly(data) as typeof data,
    loading: readonly(loading),
    error: readonly(error),
    running: readonly(running),
    errorCount: readonly(errorCount),
    run,
    debouncedRun,
    stop,
  };
}

在组件中消费 Hook ​

以一个带搜索与进度轮询的页面为例:

vue
<script setup lang="ts">
import { ref } from 'vue';
import { usePollingRequest } from '@/composables/usePollingRequest';

const keyword = ref('');

interface OrderStatus {
  id: string;
  status: string;
}

const { data, loading, error, debouncedRun, stop } = usePollingRequest<OrderStatus>(
  async (signal) => {
    const res = await fetch(`/api/order/search?q=${keyword.value}`, { signal });
    if (!res.ok) throw new Error(`请求失败:${res.status}`);
    return res.json() as Promise<OrderStatus>;
  },
  { debounceMs: 300, pollingMs: 5000, maxErrorRetry: 5 },
);

function onInput() {
  // 输入变化走防抖;内部会自动取消上一个未完成请求
  debouncedRun();
}
</script>

<template>
  <div>
    <input v-model="keyword" @input="onInput" placeholder="搜索订单号" />
    <button @click="stop">停止刷新</button>
    <p v-if="loading">加载中…</p>
    <p v-else-if="error">出错了,已按策略处理</p>
    <p v-else>当前状态:{{ data?.status ?? '暂无' }}</p>
  </div>
</template>

业务组件完全不接触计时器,只描述“何时触发”和“如何渲染”。如果使用 axios,可把 signal 透传到配置:axios.get(url, { signal }),取消语义同样成立。

避坑总结:来自实战的五个细节 ​

  1. 竞态未取消导致旧数据覆盖新数据。 现象是输入“iphone”后结果先显示 iphone 的数据,片刻又被更早发出的“ip”请求结果覆盖。修正方法是为每次请求创建独立 AbortController,新请求前 abort() 旧的,并在响应回来后二次校验 signal.aborted,仅最新一轮允许写入 data。

  2. 用 setInterval 做轮询造成请求堆叠。 网络慢时前一次请求尚未返回,下一次又发出,标签页挂起后还会集中补爆发。应改为“请求结束后再 setTimeout 排下一次”的递归调度;并监听 document.visibilitychange,页面隐藏时可调用 stop() 或拉长间隔,回到前台再 run()。

  3. 卸载后仍 setState 报警告或泄漏请求。 常见报错是 “Can't perform a React state update”(在混用渲染层时)或 Vue 中无意义的响应式更新。务必在 onBeforeUnmount 里同时 clearTimeout 两类计时器并 abort 请求;本 Hook 用 autoCleanup 默认开启,若在非组件环境(如工具函数)使用则显式传 false 并手动 stop。

  4. 防抖与轮询计时器混用、只清了一个。 典型现象是停止后请求又“复活”一次。根因是防抖等待中触发了 stop,但只清了轮询句柄。正确做法是统一收敛到 clearTimers(),run、debouncedRun、stop 入口都先清理,杜绝句柄丢失。

  5. 错误后无限轮询或 loading 卡死。 接口持续 500 时若不计数,会无限重试拖垮服务;而在 finally 中无条件置 loading=false,又会让轮询间隙的状态不符合预期。建议设置 maxErrorRetry 自动熔断,并让 loading 跟随 running:轮询等待期间可结合独立的 pending 标记区分“首次加载”和“后台刷新”,避免按钮闪烁。

把这层逻辑沉淀为团队级 Composable 后,新增一个实时页面往往只需十几行代码,请求相关的内存泄漏与竞态缺陷也能在 Code Review 中被结构性地消灭。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。