【Vue3】从混乱到有序:我用 1 个 Vue Hooks 搞定大屏项目所有定时任务

631 阅读7分钟

前言

你是否也曾在大屏项目中陷入这样的困境:

  • 为了实时展示数据,页面里塞了十几个setInterval
  • 定时器清理不当导致内存泄漏,页面越用越卡
  • 切换页面后轮询还在疯狂请求,控制台报错刷屏
  • 想统一控制所有定时任务的启停,却要逐个修改代码

作为一个常年和数据大屏打交道的前端开发者,我太懂这种痛了。大屏项目往往需要同时维护 N 个定时任务(数据刷新、状态同步、心跳检测...),原生定时器 API 用起来不仅繁琐,还容易出各种幺蛾子。

今天就给大家分享一个我专门为大屏场景设计的usePolling Hooks,用它管理定时任务,效率直接翻倍!

为什么需要专门的轮询 Hooks?

先说说大屏项目的特殊性:

  1. 高频定时任务多:一个大屏可能需要同时维护 5-10 个不同的轮询任务(实时数据、告警、状态更新等)
  2. 资源敏感:大屏通常需要长时间运行,内存泄漏是致命问题
  3. 可见性需求:页面隐藏时没必要继续轮询(比如切换标签页)
  4. 统一管理:需要能批量控制所有轮询的启停状态

原生setInterval的问题就很明显了:每次使用都要手动管理定时器 ID、手动清理、手动处理异常,代码冗余且容易出错。

于是我封装了这个usePolling Hooks,把所有重复逻辑收归一处,让业务代码只关注核心逻辑。

核心实现:usePolling.ts

先上代码,核心逻辑不到 50 行,却解决了大屏轮询的所有痛点:

import { ref, onMounted, onUnmounted } from "vue";

// interval: 轮询间隔时间,默认5分钟
export function usePolling(fn: () => void, interval: number = 5 * 60 * 1000) {
  const timer = ref<number | null>(null);
  const isActive = ref(true); // 页面可见性控制
  const isPolling = ref(false);
  const startPolling = () => {
    if (isPolling.value) return; // 防止重复启动
    isPolling.value = true;
    if (timer.value) clearInterval(timer.value); // 清理旧定时器
    // 设置新定时器
    timer.value = window.setInterval(async () => {
      if (!isActive.value) return; // 页面不可见时不执行
      try {
        await fn(); // 执行轮询任务(支持异步)
      } catch (error) {
        console.error("轮询异常:", error); // 统一错误处理
      }
    }, interval);
    fn(); // 立即执行一次(大屏通常需要即时数据)
  };
  // 页面可见性变化时自动暂停/恢复
  const handleVisibilityChange = () => {
    isActive.value = !document.hidden;
  };
  // 组件挂载时启动轮询并监听可见性变化
  onMounted(() => {
    document.addEventListener("visibilitychange", handleVisibilityChange);
    startPolling();
  });
  // 组件卸载时清理资源
  onUnmounted(() => {
    if (timer.value !== null) {
      clearInterval(timer.value);
    }
    document.removeEventListener("visibilitychange", handleVisibilityChange);
  });
  return { startPolling };
}

大屏项目中的实战用法

使用起来更是简单到离谱,只需要两步:

  1. 定义你的轮询任务(比如请求数据)
  2. usePolling包装它,指定间隔时间
<template>
  <div class="dashboard">
    <!-- 大屏内容 -->
  </div>
</template>

<script setup lang="ts">
import { usePolling } from "./hooks/usePolling";
import { fetchRealTimeData } from "./api/dashboard";

// 1. 定义轮询任务:获取实时数据并更新视图
const fetchData = async () => {
  const data = await fetchRealTimeData();
  // 更新组件状态...
};

// 2. 使用轮询Hooks:每30秒刷新一次
const { startPolling } = usePolling(fetchData, 30 * 1000);

// 如需手动控制(比如按钮触发重启)
// const handleRestart = () => {
//   startPolling();
// };
</script>

如果页面有多个轮询任务,直接多次调用即可:

// 任务1:每30秒刷新实时数据 
usePolling(fetchRealTimeData, 30 * 1000);
// 任务2:每5分钟同步一次配置 
usePolling(syncConfig, 5 * 60 * 1000); 
// 任务3:每10秒检测一次告警状态
usePolling(checkAlarm, 10 * 1000);

所有轮询都会自动遵守页面可见性规则,组件卸载时自动清理,再也不用手动管理一堆定时器了!

扩展

在后续的开发过程中,我和团队的小伙伴意识到了usePolling还有不足之处,所以我又对其进行了进一步的优化和扩展

1. 控制任务并发:避免轮询任务「叠加执行」

在执行轮询任务fn的时候,执行的耗时超过了轮询间隔(比如接口响应慢),setInterval 会无视前一次任务是否完成,继续触发下一次执行,导致多个任务同时运行(并发),堆积大量请求 / 计算,占用过多资源。

优化方案: 用 setTimeout 递归调用替代 setInterval,确保上一次任务执行完成后,再开始计算下一次的间隔时间,避免并发。

export function usePolling(fn: () => void, interval: number = 5 * 60 * 1000) {
  const timer = ref<number | null>(null);
  const isActive = ref(true);
  const isPolling = ref(false);
  const isTaskRunning = ref(false); // 标记任务是否正在执行

  // 递归执行任务的核心函数
  const runTask = async () => {
    if (!isActive.value || !isPolling.value) return; // 非激活状态或未启动轮询,终止递归

    if (isTaskRunning.value) return; // 上一次任务未完成,跳过
    isTaskRunning.value = true;

    try {
      await fn(); // 等待任务完成
    } catch (error) {
      console.error("轮询异常:", error);
    } finally {
      isTaskRunning.value = false;
      // 任务完成后,间隔指定时间再执行下一次
      timer.value = window.setTimeout(runTask, interval);
    }
  };

  const startPolling = () => {
    if (isPolling.value) return;
    isPolling.value = true;
    runTask(); // 启动递归
  };

  // 停止轮询的方法(新增)
  const stopPolling = () => {
    isPolling.value = false;
    if (timer.value) {
      clearTimeout(timer.value);
      timer.value = null;
    }
  };

  onMounted(() => {
    document.addEventListener("visibilitychange", () => {
      isActive.value = !document.hidden;
    });
    startPolling();
  });

  onUnmounted(() => {
    stopPolling(); // 卸载时停止轮询
    document.removeEventListener("visibilitychange", () => {
      isActive.value = !document.hidden;
    });
  });

  return { startPolling, stopPolling };
}

效果:确保同一时间只有一个轮询任务在运行,避免接口 / 计算资源被过度占用。

2. 优化初始执行时机:避免「同步阻塞」

原实现中 startPolling 会同步执行 fn(),如果 fn 是耗时的同步操作(或异步操作未等待),可能阻塞组件初始化(比如大屏首次渲染时)。

优化方案:初始执行也纳入异步流程,避免阻塞,并确保初始执行完成后再开始计时。

const startPolling = () => {
  if (isPolling.value) return;
  isPolling.value = true;
  // 初始执行也通过 runTask 触发(异步处理)
  runTask(); 
};

效果: 组件初始化时不会被轮询任务阻塞,提升首屏渲染速度。

3. 支持「动态调整间隔」:适配场景化需求

固定间隔可能不灵活 —— 比如大屏某些时段需要高频刷新(如高峰期),某些时段需要低频刷新(如凌晨)。

优化方案:允许动态修改轮询间隔,通过 ref 传入间隔时间,监听变化并重启轮询


// 改为接收 ref 类型的间隔,支持动态修改
export function usePolling(
  fn: () => void,
  interval: Ref<number> = ref(5 * 60 * 1000)
) {
  // ... 其他状态

  // 监听间隔变化,自动重启轮询
  watch(interval, () => {
    if (isPolling.value) {
      stopPolling(); // 先停止
      startPolling(); // 再用新间隔启动
    }
  });

  // ... 剩余逻辑不变
}

//使用示例
const pollInterval = ref(30000); // 初始30秒
usePolling(fetchData, pollInterval);

// 某时刻动态修改为1分钟
pollInterval.value = 60000;

效果:无需手动停止 / 启动,间隔自动生效,适配动态场景。

4. 强化「暂停 / 恢复」能力:减少无效执行

原实现仅通过页面可见性控制暂停,但大屏场景可能需要手动暂停(如用户操作时、数据加载中)。

优化方案:新增 pause 和 resume 方法,允许手动控制轮询状态,减少不必要的执行。

export function usePolling(fn: () => void, interval: number = 5 * 60 * 1000) {
  // ... 其他状态
  const isPaused = ref(false); // 手动暂停标记

  const runTask = async () => {
    if (!isActive.value || !isPolling.value || isPaused.value) return;
    // ... 剩余逻辑不变
  };

  // 手动暂停
  const pause = () => {
    isPaused.value = true;
  };

  // 手动恢复
  const resume = () => {
    isPaused.value = false;
    if (isPolling.value) {
      runTask(); // 立即执行一次,或等待下一次间隔(根据需求选择)
    }
  };

  return { startPolling, stopPolling, pause, resume };
}

使用场景

  • 大屏有「手动刷新」按钮时,点击后暂停轮询,避免接口冲突
  • 数据加载中(如显示 loading)时,暂停轮询,防止重复请求

5. 错误处理增强:避免「静默失败」与「过度重试」

原实现仅打印错误,但未处理「连续失败」场景(比如接口连续报错时,频繁重试会浪费资源)。

优化方案

  • 允许用户传入错误回调,自定义错误处理(如上报、提示)
  • 增加失败重试限制(如连续失败 N 次后暂停,避免无效请求)
 // 新增错误回调和重试限制参数
export function usePolling(
  fn: () => void,
  interval: number = 5 * 60 * 1000,
  options: {
    onError?: (error: Error) => void; // 自定义错误处理
    maxRetry?: number; // 最大连续失败次数
  } = {}
) {
  const { onError, maxRetry = 3 } = options;
  const failCount = ref(0); // 连续失败计数

  const runTask = async () => {
    // ... 其他判断
    try {
      await fn();
      failCount.value = 0; // 成功后重置失败计数
    } catch (error) {
      failCount.value++;
      onError?.(error as Error); // 触发自定义错误处理
      console.error("轮询异常:", error);

      // 超过最大失败次数,暂停轮询
      if (failCount.value >= maxRetry) {
        stopPolling();
        console.warn(`连续失败${maxRetry}次,已暂停轮询`);
        return;
      }
    } finally {
      // ... 继续递归
    }
  };

  // ... 剩余逻辑不变
}

效果:减少错误场景下的无效请求,同时让错误处理更灵活。

6. 清理机制强化:避免「内存泄漏」

虽然原实现有 onUnmounted 清理,但如果组件在轮询过程中被频繁挂载 / 卸载,可能因清理不及时导致定时器残留。

优化方案

  • 在 startPolling 前强制清理旧定时器,确保状态一致
  • 用 onBeforeUnmount 提前清理(比 onUnmounted 更早执行)
onBeforeUnmount(() => {
  stopPolling();
  document.removeEventListener("visibilitychange", handleVisibilityChange);
});

const startPolling = () => {
  if (isPolling.value) return;
  // 启动前强制清理旧定时器,防止残留
  if (timer.value) {
    clearTimeout(timer.value);
    timer.value = null;
  }
  isPolling.value = true;
  runTask();
};

效果:进一步降低内存泄漏风险,尤其适合大屏中组件频繁切换的场景。

优化后的全部代码

import { ref, onMounted, onUnmounted } from "vue";

// 轮询配置项类型(全部可选)
interface PollingOptions {
  /** 错误处理回调 */
  onError?: (error: Error) => void;
  /** 最大连续失败次数(超过后暂停),默认无限制 */
  maxRetry?: number;
  /** 是否在启动时立即执行一次,默认true */
  immediate?: boolean;
}

/**
 * 轮询钩子函数
 * @param fn 轮询执行的函数(支持异步)
 * @param interval 轮询间隔时间(毫秒),默认5分钟
 * @param options 轮询配置项(可选,可完全不传递)
 * @returns 轮询控制方法
 */
export function usePolling(
  fn: () => Promise<void> | void,
  interval: number = 5 * 60 * 1000,
  options: PollingOptions = {} // 可选参数,默认空对象
) {
  const timer = ref<number | null>(null);
  const isActive = ref(true); // 页面可见性控制
  const isPolling = ref(false);
  const failCount = ref(0); // 连续失败计数器

  // 解析配置项(带默认值,避免用户未传递的情况)
  const {
    onError = (err) => console.error("轮询异常:", err), // 默认错误处理
    maxRetry = Infinity, // 默认无限制重试
    immediate = true, // 默认立即执行
  } = options;

  // 执行轮询任务
  const executeTask = async () => {
    if (!isActive.value) return;
    try {
      await fn();
      failCount.value = 0; // 成功则重置失败计数
    } catch (error) {
      failCount.value++;
      onError(error as Error);

      // 超过最大重试次数则暂停
      if (failCount.value >= maxRetry) {
        console.warn(`已连续失败${maxRetry}次,暂停轮询`);
        stopPolling();
      }
    }
  };

  // 启动轮询
  const startPolling = () => {
    if (isPolling.value) return;
    isPolling.value = true;
    clearTimer();

    // 立即执行一次
    if (immediate) {
      executeTask();
    }

    // 设置定时器
    timer.value = window.setInterval(executeTask, interval);
  };

  // 暂停轮询
  const pausePolling = () => {
    if (!isPolling.value) return;
    clearTimer();
  };

  // 恢复轮询
  const resumePolling = () => {
    if (!isPolling.value) return;
    clearTimer();
    timer.value = window.setInterval(executeTask, interval);
  };

  // 停止轮询
  const stopPolling = () => {
    isPolling.value = false;
    clearTimer();
  };

  // 清理定时器
  const clearTimer = () => {
    if (timer.value) {
      clearInterval(timer.value);
      timer.value = null;
    }
  };

  // 页面可见性变化处理
  const handleVisibilityChange = () => {
    const prevActive = isActive.value;
    isActive.value = !document.hidden;

    // 页面从隐藏变为可见时,若轮询中则立即执行一次
    if (!prevActive && isActive.value && isPolling.value) {
      executeTask();
    }
  };

  // 生命周期钩子
  onMounted(() => {
    document.addEventListener("visibilitychange", handleVisibilityChange);
    startPolling();
  });

  onUnmounted(() => {
    stopPolling();
    document.removeEventListener("visibilitychange", handleVisibilityChange);
  });

  return {
    startPolling,
    pausePolling,
    resumePolling,
    stopPolling,
    isPolling, // 暴露轮询状态
  };
}