心跳检测机制在前端长连接中的实战应用与优化

Air-统赫 交互 阅读 2,064
赞 17 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个后台管理系统,用户反馈“页面一开就卡死”,我一开始还不信——本地跑得好好的啊。结果一连上测试环境,我也傻眼了:首页加载完,鼠标动一下都卡顿,滚动条拖不动,点击按钮要等两三秒才有反应。

心跳检测机制在前端长连接中的实战应用与优化

查了下监控日志,发现 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 连接数稳定多了。

以上是我对心跳检测这块的优化实战,核心就一点:别让保活机制变成性能杀手。频率够用就行,该停就停,别乱触发渲染。

有更好的方案欢迎评论区交流,比如你们是怎么处理弱网下心跳策略的?或者有没有更优雅的状态隔离方式?

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论