心跳检测机制在前端长连接中的实战应用与优化
优化前:卡得不行
上周上线一个后台管理系统,用户反馈“页面一开就卡死”,我一开始还不信——本地跑得好好的啊。结果一连上测试环境,我也傻眼了:首页加载完,鼠标动一下都卡顿,滚动条拖不动,点击按钮要等两三秒才有反应。
查了下监控日志,发现 CPU 使用率飙到 90%+,内存也在持续增长。这哪是心跳检测,简直是“心梗检测”了。
问题出在我们用 WebSocket 做实时状态同步时,搞了个“心跳保活”机制。但写得太糙了:每隔 1 秒就发一次 ping,而且没做任何节流、清理,页面切到后台还在狂发请求。更离谱的是,每次收到 pong 回复,还触发了一整套组件重新渲染——哪怕数据根本没变。
找到瓶颈了!
先打开 Chrome DevTools 的 Performance 面板录了个 5 秒操作,一看火焰图密密麻麻全是 setTimeout 和 React 的 render 调用。再看 Network,一堆 /api/ping 请求密集排列,间隔精确到 1000ms,跟钟表一样准。
用 Memory 快照对比了一下,发现每过几秒就新增几十个闭包和定时器引用,明显是没清理干净。最关键的是,这些心跳逻辑被塞进了全局状态管理(用的 Zustand),每次 pong 回来就 set({ lastPing: Date.now() }),导致所有监听这个状态的组件全量 rerender。
折腾了半天才发现:其实业务根本不需要这么高频的心跳。后端同事说,只要 30 秒内有通信就算活跃,超过 60 秒没动静才断开。我们却用 1 秒一次去“证明自己活着”,纯属自我感动式开发。
核心代码就这几行
试了几种方案:
- 方案一:把间隔从 1s 改成 30s。简单粗暴,但万一网络抖动丢包,可能误判断开。
- 方案二:收到 pong 后才设置下一次 ping。但这样如果服务器挂了,客户端永远不会再发 ping。
- 方案三(最终采用):带超时重试 + 智能暂停。页面不可见时暂停心跳,恢复后再续上;同时用指数退避处理失败。
这里注意我踩过好几次坑:别在 visibilitychange 里直接 clear/setTimeout,容易漏掉。也别用 setInterval,它不会自动对齐系统节流,后台照样跑。
下面是优化后的核心逻辑(简化版,实际项目加了 reconnect 逻辑):
class Heartbeat {
constructor(ws, interval = 30000, timeout = 5000) {
this.ws = ws;
this.interval = interval;
this.timeout = timeout;
this.pingTimer = null;
this.pongTimer = null;
this.isVisible = true;
// 监听页面可见性
document.addEventListener('visibilitychange', () => {
this.isVisible = !document.hidden;
if (this.isVisible) {
this.start(); // 切回前台立刻尝试一次
} else {
this.stop(); // 后台暂停
}
});
// 监听 pong
ws.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.type === 'pong') {
this.handlePong();
}
});
}
start() {
this.stop(); // 先清掉旧的
if (!this.isVisible || this.ws.readyState !== WebSocket.OPEN) return;
this.pingTimer = setTimeout(() => {
this.sendPing();
// 等待 pong 超时
this.pongTimer = setTimeout(() => {
console.warn('Heartbeat timeout, closing connection');
this.ws.close();
}, this.timeout);
}, this.interval);
}
sendPing() {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
}
}
handlePong() {
clearTimeout(this.pongTimer);
this.pongTimer = null;
this.start(); // 成功收到 pong,安排下一次
}
stop() {
if (this.pingTimer) clearTimeout(this.pingTimer);
if (this.pongTimer) clearTimeout(this.pongTimer);
this.pingTimer = null;
this.pongTimer = null;
}
}
配合前端状态管理时,千万别把 lastPingTime 存进全局状态。我们改成只在本地组件用 useRef 存,或者干脆不存——除非 UI 要显示“最后活跃时间”。否则就是无谓的渲染浪费。
性能数据对比
改完之后,效果立竿见影:
- 首页加载完成后的 CPU 占用从平均 85% 降到 5% 以下
- 内存增长曲线几乎水平,不再持续上升
- Network 面板里,ping 请求从每分钟 60 次降到 2 次(30 秒一次)
- 页面切换 tab 再切回来,不会突然卡住“补发”一堆请求
最直观的感受是:滚动流畅了,输入框打字不延迟了,连 Safari 都不再弹“页面无响应”警告了。
加载时间本身没变(因为心跳不影响首屏),但交互响应速度从原来的平均 1.2s 降到 50ms 以内——这才是用户真正感知到的“快”。
踩坑提醒:这三点一定注意
1. 别在心跳里触发状态更新。除非业务强依赖(比如显示在线状态),否则就是给自己挖坑。React/Vue 对这种高频更新极其敏感。
2. 页面不可见时一定要暂停。很多人以为 setInterval 在后台会被浏览器节流,但实测 Chrome 只会降低频率(比如 1s 变成 10s),不会停。而我们的场景根本不需要后台保活,暂停是最省资源的。
3. 超时时间要小于心跳间隔。比如间隔 30s,超时设 5s。这样即使丢一个包,也不会等到下次 ping 才发现断连。我们之前设成 35s,结果断网后要等快一分钟才重连,用户早就骂街了。
还有点小瑕疵,但够用了
现在的方案不是理论最优——比如没做连接质量自适应(弱网下调低频率),也没集成到 reconnect 逻辑里(断线重连后要手动 start)。但胜在简单、可控、副作用少。
上线一周,没再收到卡顿反馈。运维说服务器压力也小了,WebSocket 连接数稳定多了。
以上是我对心跳检测这块的优化实战,核心就一点:别让保活机制变成性能杀手。频率够用就行,该停就停,别乱触发渲染。
有更好的方案欢迎评论区交流,比如你们是怎么处理弱网下心跳策略的?或者有没有更优雅的状态隔离方式?

暂无评论