1. 从一次数据大屏的翻车说起
去年帮一个做运营数据看板的朋友救火,页面上的实时订单数、在线用户数、库存余量三个卡片,每隔几秒就集体闪一下,偶尔还会出现数字从 1200 跳回 800 再跳回 1200 的诡异回跳。他一开始以为是后端接口不稳定,查了半天日志发现接口响应时间稳定在 40ms 以内,数据本身也没问题。最后定位到问题出在前端——三个卡片各自跑了一个setInterval,间隔都是 3000ms,但启动时间差了十几毫秒,于是请求像散弹一样打出去,响应回来的顺序又不固定,旧请求后到就把新数据覆盖了。
这个场景几乎每个前端都遇到过。轮询后端接口这件事,看起来就是"定时发请求"四个字,但真正落地到生产环境,要考虑的东西远比想象中多:用setInterval还是setTimeout递归?请求还没回来下一次定时又触发了怎么办?页面切到后台标签页要不要继续轮询?什么时候该从轮询升级到 WebSocket?WebSocket 断了怎么重连、重连期间的数据怎么补?
这篇内容就把这些问题一次性讲透。不管你是刚接触实时数据展示的新手,还是正在为线上轮询抖动头疼的老手,都能从里面找到可以直接抄作业的方案。我会从最朴素的setInterval写法开始,一步步推到生产级的轮询封装,再讲清楚 WebSocket 的适用边界和落地细节,中间穿插我自己踩过的坑和实测数据。
2. setInterval 轮询的四个致命陷阱
2.1 请求堆积:为什么你的轮询会越跑越卡
setInterval(fn, 3000)的语义是"每隔 3000ms 把 fn 塞进任务队列",它不关心上一次 fn 有没有执行完。如果接口响应时间偶尔飙到 5 秒,那么在第 3 秒、第 6 秒会各触发一次新请求,此时第一个请求还没回来,于是同时有三个请求在飞。接口越慢,堆积越严重,最后浏览器连接池被占满,整个页面所有请求都开始排队。
我实测过一个极端案例:某接口在弱网下响应时间 8 秒,轮询间隔 2 秒,跑了 30 秒后同时有 4 个请求在 pending 状态,页面其他接口的响应时间从 200ms 涨到了 3 秒以上。这不是接口的问题,是轮询方式的问题。
正确的做法是用setTimeout递归,等上一次请求彻底结束后再排下一次:
let timer = null; let stopped = false; async function poll() { if (stopped) return; try { const data = await fetchData(); render(data); } catch (e) { console.error('poll error', e); } finally { if (!stopped) { timer = setTimeout(poll, 3000); } } } function start() { stopped = false; poll(); } function stop() { stopped = true; clearTimeout(timer); }这样无论接口多慢,同一时刻永远只有一个请求在飞。代价是实际轮询间隔变成了"响应时间 + 3000ms",如果接口平均 200ms,实际间隔就是 3200ms。对大多数看板场景来说这个偏差完全可以接受,稳定性远比精确间隔重要。
2.2 竞态覆盖:旧数据把新数据盖回去
回到开头那个回跳问题。三个卡片各自轮询,A 请求在 t=0 发出,t=500ms 返回;B 请求在 t=100ms 发出,t=800ms 返回。如果 B 返回的是更早时刻的数据快照,它就会把 A 的新数据覆盖掉,用户看到数字先涨后跌。
解决思路有两个层次。第一层是同一数据源只保留一个轮询器,三个卡片共享同一份数据,从根上消除多请求竞争。第二层是给每个请求打上时间戳或自增序号,渲染前判断"我是不是最新的那个":
let latestSeq = 0; async function poll() { const seq = ++latestSeq; const data = await fetchData(); if (seq !== latestSeq) return; // 已经有更新的请求发出,丢弃本次结果 render(data); }这个seq判断看起来简单,但在真实项目里能挡掉大量偶发的数据抖动。我建议只要涉及轮询,无论单请求还是多请求,都加上这个保护,成本几乎为零。
2.3 后台标签页:被浏览器掐住的定时器
浏览器对后台标签页的定时器有节流策略。页面不可见时,setInterval和setTimeout的最小间隔会被拉长到 1000ms 甚至更高,Chrome 在页面隐藏超过 5 分钟后,定时器可能被降到每分钟才执行一次。这意味着用户切到别的标签页再切回来,你的轮询可能已经"睡"了很久,数据严重过期。
更麻烦的是,有些浏览器在后台会直接冻结定时器,切回来瞬间积压的任务会集中爆发。我遇到过切回标签页后 1 秒内连发 5 个请求的情况,就是因为后台期间定时器被节流,恢复时补偿执行。
处理方案是监听visibilitychange,页面隐藏时暂停轮询,可见时立即拉一次最新数据再恢复定时:
document.addEventListener('visibilitychange', () => { if (document.hidden) { stop(); } else { poll(); // 立即拉一次 } });这样既避免了后台无效请求浪费带宽,又保证了用户切回来看到的是新鲜数据。对于数据大屏这种长期挂在前台的场景,还可以配合requestAnimationFrame或Page Visibility API做更精细的控制。
2.4 内存泄漏:组件卸载后定时器还在跑
React、Vue 组件里写轮询,最常见的 bug 就是组件销毁了但setInterval没清。定时器持有闭包引用,闭包里又持有组件状态和 DOM 引用,导致整棵组件树无法被 GC 回收。单页应用里反复进出同一个页面,内存曲线会一路向上。
在 React 里正确的写法是在useEffect的清理函数里停掉轮询:
useEffect(() => { start(); return () => stop(); }, []);Vue 3 的onUnmounted、Vue 2 的beforeDestroy同理。关键是启动和停止必须成对出现,而且停止逻辑要能真正中断正在飞的请求(用AbortController),否则请求回来时组件已经卸载,setState会报警告。
const controller = new AbortController(); async function poll() { try { const res = await fetch(url, { signal: controller.signal }); // ... } catch (e) { if (e.name === 'AbortError') return; } } function stop() { controller.abort(); clearTimeout(timer); }把这四个陷阱都堵上,你的setInterval轮询才算勉强达到生产可用。但说实话,轮询这种方式本身就有天花板,接下来聊聊什么时候该换 WebSocket。
3. 轮询间隔到底设多少才合理
3.1 从业务实时性倒推间隔
很多人设轮询间隔是拍脑袋,3 秒、5 秒、10 秒随手填。合理的做法是从业务对"实时"的定义倒推。问自己一个问题:数据延迟多少秒用户会觉得"卡"?
- 聊天消息、协同编辑光标:延迟超过 1 秒就难受,轮询根本扛不住,必须 WebSocket。
- 订单状态、支付结果:延迟 3-5 秒可接受,轮询够用。
- 数据大屏、监控指标:延迟 10-30 秒都行,轮询性价比最高。
- 报表、统计类:分钟级甚至小时级,用定时任务或手动刷新即可。
我一般把轮询间隔定在"业务可接受最大延迟"的 1/2 到 1/3。比如订单状态能接受 5 秒延迟,那间隔设 2 秒左右,这样平均延迟约 1 秒,最坏 2 秒,用户体验比较稳。
3.2 请求量估算:别把后端打挂
间隔定了要算一笔账。假设日活 1 万,平均在线 1000 人,每人页面开着轮询,间隔 3 秒,那么 QPS = 1000 / 3 ≈ 333。如果这个接口还要查数据库、做聚合,后端压力不小。
这时候有几个优化方向:
| 优化手段 | 效果 | 适用场景 |
|---|---|---|
| 拉长间隔 | QPS 线性下降 | 实时性要求不高的场景 |
| 页面隐藏时暂停 | 后台用户不产生请求 | 所有场景,必做 |
| 接口合并 | 多个轮询合成一个 | 同页面多数据源 |
| 增量返回 | 只返回变化部分 | 数据量大、变化少 |
| 服务端缓存 | 降低数据库压力 | 聚合类接口 |
| 升级 WebSocket | 从 N 次请求变 1 条长连接 | 高频、多客户端 |
接口合并这招特别实用。一个看板如果有 5 个卡片各自轮询,就是 5 倍 QPS。改成后端提供一个聚合接口,一次返回 5 份数据,QPS 直接降到 1/5。代价是单次响应体变大,但通常远小于 5 次请求的头部开销总和。
3.3 动态间隔:让轮询自己"呼吸"
固定间隔有个尴尬:数据变化快的时候嫌慢,变化慢的时候嫌浪费。可以做一个简单的自适应策略——连续 N 次数据没变化就把间隔翻倍,一旦检测到变化就恢复基础间隔。
let interval = 3000; const BASE = 3000; const MAX = 30000; let unchangedCount = 0; let lastHash = ''; async function poll() { const data = await fetchData(); const hash = JSON.stringify(data); if (hash === lastHash) { unchangedCount++; if (unchangedCount >= 3) { interval = Math.min(interval * 2, MAX); } } else { unchangedCount = 0; interval = BASE; } lastHash = hash; render(data); timer = setTimeout(poll, interval); }这个策略在监控类场景特别好用。业务低峰期数据不动,轮询自动拉长到 30 秒,省下大量无效请求;一旦有异常波动,立刻回到 3 秒高频。实测能把整体请求量降 60% 以上,而用户几乎感知不到延迟变化。
注意:动态间隔要设上限,否则数据长期不变时间隔会无限拉长,真出问题时反应太慢。我一般把上限设在基础间隔的 10 倍左右。
4. WebSocket 不是银弹:先搞清楚它解决什么问题
4.1 轮询 vs WebSocket 的本质差异
轮询是客户端主动问,WebSocket 是服务端主动推。这个差异决定了它们的适用边界。
轮询的每一次请求都是一次完整的 HTTP 往返:TCP 连接(可能复用)、请求头、响应头、业务数据。即使开了 keep-alive,请求头和响应头也有几百字节到几 KB 的开销。WebSocket 握手一次之后,后续每条消息只有数据帧本身,头部开销只有 2-14 字节。
但 WebSocket 的代价也很明显:服务端要为每个连接维护状态,1 万个连接就是 1 万个常驻内存对象;连接可能因为网络抖动、代理超时、服务重启而断开,需要完整的重连和补数据机制;调试比 HTTP 麻烦得多,抓包、日志、监控都要另做一套。
我见过不少团队一上来就上 WebSocket,结果发现业务是 30 秒更新一次的报表,白白背上了长连接的运维复杂度。判断标准很简单:如果轮询间隔大于 5 秒,且数据变化不频繁,老老实实用轮询。
4.2 什么时候必须上 WebSocket
有三类场景轮询真的扛不住:
第一类是高频双向通信,比如在线协作、即时聊天、多人游戏。这类场景延迟要求 100ms 级,轮询间隔再短也追不上,而且客户端也要频繁发消息,HTTP 的请求-响应模型天然别扭。
第二类是服务端事件驱动,比如订单状态变更、告警触发、任务完成通知。这类事件发生时间不可预测,轮询要么延迟高要么浪费大量空请求。WebSocket 让服务端在事件发生时立即推送,实时性和资源效率双赢。
第三类是海量客户端 + 低频更新,比如 10 万在线用户的公告推送。轮询的话 10 万客户端每 10 秒一次就是 1 万 QPS,而 WebSocket 只需要在公告发布时推 10 万条消息,平时连接静默,服务端压力小得多。
4.3 混合方案:轮询兜底 + WebSocket 加速
真实项目里我推荐混合方案:WebSocket 作为主通道负责实时推送,轮询作为兜底负责数据校准。
具体做法是:页面加载时先拉一次全量数据,然后建立 WebSocket 连接接收增量更新。同时启动一个低频轮询(比如 30 秒一次),用来校正可能丢失的消息。WebSocket 断开期间,轮询自动切换到高频模式(比如 3 秒一次),保证数据不断档;重连成功后恢复低频。
这样既享受了 WebSocket 的实时性,又用轮询兜住了长连接的不确定性。代价是代码复杂度上升,但换来的是真正生产级的可靠性。下面这节详细讲 WebSocket 的落地细节。
5. WebSocket 落地:重连、心跳、补数据三件套
5.1 指数退避重连:别把服务端打垮
WebSocket 断开后立刻重连是最常见的错误。如果服务端刚重启,1 万个客户端同时重连,服务端还没起来就被打垮,然后客户端又立刻重连,形成雪崩。
正确的做法是指数退避 + 随机抖动:
let retryCount = 0; const MAX_RETRY = 10; const BASE_DELAY = 1000; function reconnect() { if (retryCount >= MAX_RETRY) { console.warn('重连次数超限,切换到轮询模式'); switchToPolling(); return; } const delay = Math.min(BASE_DELAY * Math.pow(2, retryCount), 30000); const jitter = delay * 0.3 * Math.random(); retryCount++; setTimeout(connect, delay + jitter); }退避序列大概是 1s、2s、4s、8s、16s、30s、30s……加上 30% 的随机抖动,避免所有客户端在同一时刻重连。重连成功后把retryCount归零。超过最大次数就降级到轮询,保证功能可用。
5.2 心跳保活:识别"假连接"
WebSocket 连接可能处于一种"假死"状态:TCP 连接还在,但中间的网络设备(负载均衡、代理)已经悄悄断开了,客户端和服务端都以为连接正常,实际消息发不出去。这种情况靠onclose是检测不到的,必须靠心跳。
做法是客户端每隔 30 秒发一个 ping 消息,服务端回 pong。如果连续两次没收到 pong,就主动关闭连接触发重连:
let heartbeatTimer = null; let pongTimeout = null; function startHeartbeat() { heartbeatTimer = setInterval(() => { if (ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type: 'ping' })); pongTimeout = setTimeout(() => { console.warn('心跳超时,主动断开重连'); ws.close(); }, 5000); }, 30000); } ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'pong') { clearTimeout(pongTimeout); return; } handleMessage(msg); };心跳间隔要小于中间设备的空闲超时时间。常见的负载均衡空闲超时是 60 秒,所以心跳设 30 秒比较安全。如果你们的网关超时设得更短,心跳也要相应缩短。
5.3 断线补数据:重连后如何追平
WebSocket 断开期间的消息是丢失的,重连后必须补上。常见方案有两种:
方案一:基于消息序号。服务端给每条消息编递增序号,客户端记录最后收到的序号,重连时带上这个序号请求补发。服务端从该序号之后的消息全部推给客户端。这个方案要求服务端缓存历史消息,适合消息量可控的场景。
方案二:重连后拉全量。重连成功后先调一次 HTTP 接口拉全量最新数据,覆盖本地状态,然后继续接收增量。这个方案实现简单,不依赖服务端缓存,适合状态类数据(比如订单列表、库存数量)。缺点是全量数据可能很大。
我一般优先用方案二,因为大多数业务场景的数据是"状态"而非"事件流",全量覆盖语义更清晰,也不会因为序号对不上导致数据错乱。只有聊天记录、操作日志这类事件流场景才需要方案一。
ws.onopen = async () => { retryCount = 0; startHeartbeat(); // 重连后先拉全量校准 const full = await fetchFullData(); render(full); // 再通知服务端可以推增量了 ws.send(JSON.stringify({ type: 'ready' })); };注意:重连后的全量拉取和 WebSocket 增量推送之间可能有时序问题——全量拉取期间到达的增量消息可能被全量覆盖。稳妥的做法是先缓冲增量消息,全量渲染完成后再按序应用缓冲的消息。
6. 一套可直接复用的轮询封装
6.1 设计目标与接口定义
把前面所有经验打包成一个可复用的轮询器,需要满足这些要求:支持动态间隔、页面隐藏自动暂停、请求不堆积、组件卸载可清理、失败自动退避、支持手动触发。
接口设计成这样:
const poller = createPoller({ fetcher: async () => { /* 返回数据 */ }, onData: (data) => { /* 渲染 */ }, onError: (err) => { /* 错误处理 */ }, interval: 3000, maxInterval: 30000, pauseOnHidden: true, backoffOnError: true, }); poller.start(); poller.stop(); poller.trigger(); // 立即执行一次6.2 核心实现
function createPoller(options) { const { fetcher, onData, onError, interval = 3000, maxInterval = 30000, pauseOnHidden = true, backoffOnError = true, } = options; let timer = null; let stopped = true; let currentInterval = interval; let errorCount = 0; let seq = 0; let controller = null; async function tick() { if (stopped) return; const mySeq = ++seq; controller = new AbortController(); try { const data = await fetcher({ signal: controller.signal }); if (mySeq !== seq) return; // 有更新的请求,丢弃 errorCount = 0; currentInterval = interval; onData(data); } catch (e) { if (e.name === 'AbortError') return; errorCount++; if (backoffOnError) { currentInterval = Math.min(interval * Math.pow(2, errorCount), maxInterval); } onError && onError(e); } finally { if (!stopped) { timer = setTimeout(tick, currentInterval); } } } function start() { if (!stopped) return; stopped = false; tick(); } function stop() { stopped = true; clearTimeout(timer); controller && controller.abort(); } function trigger() { clearTimeout(timer); tick(); } if (pauseOnHidden) { document.addEventListener('visibilitychange', () => { if (document.hidden) { stop(); } else if (!stopped) { start(); } }); } return { start, stop, trigger }; }6.3 使用示例与注意事项
在 React 组件里这样用:
useEffect(() => { const poller = createPoller({ fetcher: () => fetch('/api/orders').then(r => r.json()), onData: (data) => setOrders(data), onError: (e) => console.error(e), interval: 3000, }); poller.start(); return () => poller.stop(); }, []);几个使用要点:
fetcher必须支持signal参数,否则stop()时正在飞的请求不会被中断。onData里如果调用了setState,要确保组件还挂载着,否则会有警告。用useRef记录挂载状态可以解决。- 多个组件需要同一份数据时,把 poller 提到共同的父组件或状态管理里,不要每个组件各起一个。
- 错误退避的
errorCount在成功一次后归零,避免偶发错误导致间隔永久拉长。
这套封装我在三个项目里用过,从数据看板到订单列表到监控告警,基本没再出过轮询相关的线上问题。代码量不大,但把该堵的坑都堵上了。
7. 几个真实踩坑记录
坑一:setInterval里用async函数,异常没人管。setInterval(async () => { await fetch() }, 3000)这种写法,如果fetch抛错,会变成 unhandled rejection,控制台一堆红字但业务逻辑静默失败。必须用try/catch包住,或者用setTimeout递归。
坑二:WebSocket 在 HTTPS 页面用ws://被浏览器拦截。页面是 HTTPS 时,WebSocket 必须用wss://,否则浏览器直接拒绝连接,控制台报 mixed content 错误。这个坑在本地开发时不会遇到(本地是 HTTP),一上生产就炸。
坑三:轮询接口返回 304 导致数据不更新。有些接口开了协商缓存,数据没变时返回 304,浏览器直接用本地缓存。如果前端代码没处理 304 分支,可能拿到空响应体导致渲染异常。轮询接口建议在请求头加Cache-Control: no-cache,或者后端直接禁用缓存。
坑四:移动端切到后台再回来,定时器行为不一致。iOS Safari 和 Android Chrome 对后台定时器的处理策略不同,有的直接冻结,有的降频。移动端场景建议完全依赖visibilitychange控制轮询,不要指望定时器在后台还能正常工作。
坑五:WebSocket 消息顺序不保证。虽然 WebSocket 基于 TCP,单条连接内消息是有序的,但如果服务端用了多线程推送或者消息经过多个中间件,到达客户端的顺序可能和发送顺序不一致。涉及状态变更的消息要带版本号或时间戳,客户端按序应用。
这些坑的共同点是:本地开发环境都不会暴露,只有真实网络、真实设备、真实并发下才会出现。所以轮询和 WebSocket 相关的代码,上线前一定要在弱网、后台切换、断网重连这些场景下测一遍。
8. 选型决策:一张表说清楚
最后给一个决策参考。面对一个实时数据需求,按这个顺序判断:
| 判断维度 | 选轮询 | 选 WebSocket |
|---|---|---|
| 实时性要求 | 秒级可接受 | 亚秒级/毫秒级 |
| 数据变化频率 | 低频、可预测 | 高频、事件驱动 |
| 通信方向 | 单向(服务端到客户端) | 双向 |
| 客户端规模 | 小到中等 | 大(长连接摊薄成本) |
| 服务端改造能力 | 弱(HTTP 接口即可) | 强(需维护连接状态) |
| 运维复杂度容忍度 | 低 | 高 |
| 典型场景 | 数据看板、订单状态、报表 | 聊天、协作、告警推送、游戏 |
如果两边都沾一点,就用混合方案:WebSocket 主推 + 低频轮询兜底。这套组合拳我在生产环境跑了两年多,稳定性比纯 WebSocket 高一个档次,代码复杂度也在可控范围内。
真正落地的时候,别一上来就追求完美架构。先用最简单的setTimeout递归轮询把功能跑通,观察真实的请求量、延迟分布、错误率,再决定要不要升级。很多需求其实轮询就够了,过早引入 WebSocket 只会给自己挖坑。