在做实时监控系统时,比如服务器状态面板、订单处理中心或物联网设备看板,每隔 5 秒自动拉取最新数据是再常见不过的需求了。
但你有没有遇到过这些问题?
- 页面切到后台还在疯狂发请求,浪费资源
- 上一次请求还没回来,下一次又发了,接口雪崩
- 用户切换标签页回来,发现数据"卡"在旧状态
- 页面销毁了定时器还在跑,内存泄漏
今天我就以一个运维监控平台的真实场景为例,带你从"能用"做到"好用"。
一、问题场景:设备在线状态轮询
假设我们要做一个 IDC 机房设备监控页,需求如下:
- 每 5 秒查询一次所有服务器的在线状态
- 接口
/api/servers/status
响应较慢(平均 1.2s) - 用户可能切换到其他标签页处理邮件
- 页面关闭时必须停止轮询
如果直接写个 setInterval
,很容易踩坑。我们一步步来优化。
二、第一版:基础轮询(能跑,但有隐患)
js
import { ref, onMounted, onUnmounted } from 'vue'
const servers = ref([])
let timer = null
onMounted(() => {
const poll = () => {
fetch('/api/servers/status')
.then(res => res.json())
.then(data => {
servers.value = data
})
}
poll() // 首次立即执行
timer = setInterval(poll, 5000) // 每5秒轮询
})
onUnmounted(() => {
clearInterval(timer) // 🔍 清理定时器
})
✅ 实现了基本功能
❌ 但存在三个致命问题:
- 接口未完成就发起下一次请求 → 可能雪崩
- 页面不可见时仍在轮询 → 浪费带宽和电量
- 异常未处理 → 网络错误可能导致后续不再轮询
三、第二版:可控轮询 + 可见性优化
我们改用"请求完成后再延迟 5 秒"的策略,避免并发:
js
import { ref, onMounted, onUnmounted } from 'vue'
const servers = ref([])
let abortController = null // 用于取消请求
const poll = async () => {
try {
// 支持取消上一次请求
abortController?.abort()
abortController = new AbortController()
const res = await fetch('/api/servers/status', {
signal: abortController.signal
})
if (!res.ok) throw new Error('Network error')
const data = await res.json()
servers.value = data
} catch (err) {
if (err.name !== 'AbortError') {
console.warn('轮询失败,将重试...', err)
}
} finally {
// 🔍 请求结束后再等5秒发起下一次
setTimeout(poll, 5000)
}
}
onMounted(() => {
poll() // 启动轮询
})
onUnmounted(() => {
abortController?.abort()
})
🔍 关键点解析:
finally
中setTimeout
实现"串行轮询",避免并发AbortController
可在组件卸载时主动取消进行中的请求- 错误被捕获后仍继续轮询,保证稳定性
四、第三版:智能节流 ------ 页面可见性控制
现在解决"页面不可见时是否轮询"的问题。我们引入 visibilitychange
事件:
js
let isVisible = true
const handleVisibilityChange = () => {
isVisible = !document.hidden
console.log('页面可见性:', isVisible ? '可见' : '隐藏')
}
onMounted(() => {
// 监听页面可见性
document.addEventListener('visibilitychange', handleVisibilityChange)
const poll = async () => {
try {
abortController?.abort()
abortController = new AbortController()
const res = await fetch('/api/servers/status', {
signal: abortController.signal
})
const data = await res.json()
servers.value = data
} catch (err) {
if (err.name !== 'AbortError') {
console.warn('轮询失败:', err)
}
} finally {
// 🔍 只有页面可见时才继续轮询
if (isVisible) {
setTimeout(poll, 5000)
} else {
// 页面隐藏,等待恢复后再请求
document.addEventListener('visibilitychange', function waitVisible() {
if (!document.hidden) {
document.removeEventListener('visibilitychange', waitVisible)
setTimeout(poll, 1000) // 恢复后1秒再查
}
}, { once: true })
}
}
}
poll()
})
🔍 这里做了两层控制:
- 页面隐藏时,不再自动发起下一轮请求
- 页面重新可见时,延迟 1 秒触发一次查询,避免瞬间唤醒过多资源
五、封装成可复用的轮询 Hook
把这套逻辑抽象成通用 usePolling
Hook:
js
// composables/usePolling.js
import { ref } from 'vue'
export function usePolling(fetchFn, interval = 5000) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
let abortController = null
let isVisible = true
const poll = async () => {
if (loading.value) return // 防止重复执行
loading.value = true
error.value = null
try {
abortController?.abort()
abortController = new AbortController()
const result = await fetchFn(abortController.signal)
data.value = result
} catch (err) {
if (err.name !== 'AbortError') {
error.value = err
console.warn('Polling error:', err)
}
} finally {
loading.value = false
// 🔍 根据可见性决定是否继续
if (isVisible) {
setTimeout(poll, interval)
}
}
}
const start = () => {
// 移除旧监听避免重复
document.removeEventListener('visibilitychange', handleVisibility)
document.addEventListener('visibilitychange', handleVisibility)
poll()
}
const stop = () => {
abortController?.abort()
document.removeEventListener('visibilitychange', handleVisibility)
}
const handleVisibility = () => {
isVisible = !document.hidden
if (isVisible) {
setTimeout(poll, 1000)
}
}
return { data, loading, error, start, stop }
}
使用方式极其简洁:
vue
<script setup>
import { usePolling } from '@/composables/usePolling'
const fetchStatus = async (signal) => {
const res = await fetch('/api/servers/status', { signal })
return res.json()
}
const { data, loading } = usePolling(fetchStatus, 5000)
// 自动在 onMounted 启动
</script>
<template>
<div v-if="loading">加载中...</div>
<ul v-else>
<li v-for="server in data" :key="server.id">
{{ server.name }} - {{ server.status }}
</li>
</ul>
</template>
六、对比主流轮询方案
方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
setInterval |
固定间隔触发 | 简单直观 | 不考虑响应时间,易并发 | 快速原型 |
串行 setTimeout | 请求完再延时 | 避免并发,稳定 | 周期不严格 | 多数业务场景 ✅ |
WebSocket | 服务端推送 | 实时性最高 | 成本高,兼容性差 | 股票行情、聊天 |
Server-Sent Events | 单向流式推送 | 轻量级实时 | 不支持 IE | 日志流、通知 |
智能轮询(本方案) | 可见性+串行控制 | 节能、稳定、用户体验好 | 略复杂 | 生产环境推荐 ✅ |
七、举一反三:三个变体场景实现思路
-
动态轮询频率
如网络异常时降频至 30s 一次,正常后恢复 5s。可在
finally
中根据error.value
动态调整setTimeout
时间。 -
多接口协同轮询
多个 API 轮询但希望错峰发送。可用
Promise.all
组合请求,在finally
统一控制下一轮时机,避免瞬间并发。 -
离线重连机制
当检测到网络断开(fetch 超时),改为指数退避重试(1s → 2s → 4s → 8s),恢复后再切回 5s 正常轮询。
小结
实现"每 5 秒轮询"看似简单,但要做到稳定、节能、用户体验好,需要考虑:
- ✅ 使用 串行 setTimeout 替代 setInterval,避免请求堆积
- ✅ 利用 AbortController 主动取消无用请求
- ✅ 结合 页面可见性 API 节省资源
- ✅ 封装为 可复用 Hook,提升工程化水平
记住一句话:好的轮询,是"聪明地少做事",而不是"拼命做事情"。
下次当你接到"每隔 X 秒刷新"的需求时,别急着写 setInterval
,先问问自己:用户真的需要这么频繁吗?能不能用 WebSocket?页面看不见的时候还要刷吗?