外观
一文搞懂 Vue3 请求防抖轮询 Hook 封装
为什么需要统一的请求 Hook
在 Vue3 业务开发中,搜索联想、状态刷新、订单进度、日志 tail 等场景几乎都会同时出现三个诉求:输入要防抖、结果要轮询、组件卸载或重新请求时要清理。很多团队的写法是在每个页面各写一份 setTimeout、setInterval 和 loading 状态,短期看能用,长期却埋了一堆隐患:旧请求晚于新请求返回导致数据“闪回”、页面切走后定时器仍在跑、防抖与轮询计时器互相叠加、报错后轮询永不停止。
组合式函数(Composables)正是解决这类横切逻辑的最佳载体。把请求生命周期收敛到一个 Hook 里,业务侧只保留“传函数、拿状态、调方法”,既能复用,也方便单测。本文实现一个生产可用的 usePollingRequest,覆盖手动触发、防抖触发、轮询、竞态保护、错误重试上限与完整清理。
方案对比:watch、watchEffect 还是独立 Hook
常见有三种组织方式:
- 直接在组件里 watch + setInterval:上手快,但计时器散落在各组件,清理逻辑容易漏,复制粘贴成本高。
- watchEffect 自动收集:适合依赖即触发的简单请求,但防抖、暂停、手动刷新、竞态取消等命令式能力很难优雅表达。
- 封装独立 Composable:以
ref暴露状态、以普通函数暴露控制能力,内部统一管理定时器与AbortController,可在任意组件复用并被测试。
对于“防抖 + 轮询 + 清理”这种命令式与响应式混合的需求,独立 Hook 是最稳妥的选择。
整体执行流程
关键点有两个:轮询使用递归 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 }),取消语义同样成立。
避坑总结:来自实战的五个细节
竞态未取消导致旧数据覆盖新数据。 现象是输入“iphone”后结果先显示 iphone 的数据,片刻又被更早发出的“ip”请求结果覆盖。修正方法是为每次请求创建独立
AbortController,新请求前abort()旧的,并在响应回来后二次校验signal.aborted,仅最新一轮允许写入data。用
setInterval做轮询造成请求堆叠。 网络慢时前一次请求尚未返回,下一次又发出,标签页挂起后还会集中补爆发。应改为“请求结束后再setTimeout排下一次”的递归调度;并监听document.visibilitychange,页面隐藏时可调用stop()或拉长间隔,回到前台再run()。卸载后仍 setState 报警告或泄漏请求。 常见报错是 “Can't perform a React state update”(在混用渲染层时)或 Vue 中无意义的响应式更新。务必在
onBeforeUnmount里同时clearTimeout两类计时器并abort请求;本 Hook 用autoCleanup默认开启,若在非组件环境(如工具函数)使用则显式传false并手动stop。防抖与轮询计时器混用、只清了一个。 典型现象是停止后请求又“复活”一次。根因是防抖等待中触发了
stop,但只清了轮询句柄。正确做法是统一收敛到clearTimers(),run、debouncedRun、stop入口都先清理,杜绝句柄丢失。错误后无限轮询或 loading 卡死。 接口持续 500 时若不计数,会无限重试拖垮服务;而在
finally中无条件置loading=false,又会让轮询间隙的状态不符合预期。建议设置maxErrorRetry自动熔断,并让loading跟随running:轮询等待期间可结合独立的pending标记区分“首次加载”和“后台刷新”,避免按钮闪烁。
把这层逻辑沉淀为团队级 Composable 后,新增一个实时页面往往只需十几行代码,请求相关的内存泄漏与竞态缺陷也能在 Code Review 中被结构性地消灭。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。