前端在线状态检测的实战方案与WebSocket心跳优化实践

IT人国红 交互 阅读 2,275
赞 27 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年年底接手一个内部协作平台,类似轻量版的飞书+钉钉混合体。核心需求之一是「实时显示同事在线状态」——不是那种“最后活跃时间”,而是真·在线/离线,带心跳检测和状态同步。产品经理说:“用户打开页面就该知道谁在干活,谁刚切走了微信。”

前端在线状态检测的实战方案与WebSocket心跳优化实践

一开始我心想,这还不简单?用 WebSocket 搞个连接状态监听,前端监听 online / offline 事件,后端推个状态变更就行。结果上线前两天测试,发现 iOS Safari 下 navigator.onLine 基本等于摆设:Wi-Fi 断了它不触发,切到后台再回来它也不更新,甚至有次连着 4G 它都返回 false……折腾了半天才发现,这个 API 只判断浏览器是否“认为自己连着网络”,跟真实连接状态半毛钱关系没有。

后来改用轮询 + 心跳保活,但又怕影响性能,尤其移动端常驻后台时还疯狂 fetch,被 iOS 的 Background Task 机制直接 kill 掉。最后定下来方案:前端主动发心跳,后端维护连接池+超时踢出,前端再结合 visibilitychange + 页面聚焦/失焦 + 自定义心跳响应做兜底判断。

最大的坑:性能问题

第一版上线当天,运维同学微信甩来截图:某用户连续 3 分钟内发了 187 次 /api/status/ping 请求。我一看代码,好家伙——用了 setInterval(() => ping(), 5000),没做任何节流、没判页面可见性、没管网络状态、甚至没加 try-catch。用户切去微信,页面还在后台狂刷请求,直到被系统干掉或者服务器限流。

更绝的是,我们用了 Vue 3 + Pinia,状态更新没做防抖,每次心跳成功就把 userStatusMap 全量更新一遍,导致列表组件反复 re-render。有个同事反馈“点开通讯录卡顿两秒”,查了一下,是因为他好友列表有 200+ 人,每 5 秒全量 diff 一次,CPU 直接拉满。

这里注意我踩过好几次坑:别信 visibilitychange 在所有机型上都准时;别在 mounted 里无脑 startInterval;千万别把心跳逻辑写在组件内部,否则组件卸载了定时器还在跑(Vue 3 的 onBeforeUnmount 有时也靠不住)。

最终的解决方案

现在用的是一套组合拳:

  • 页面可见时,每 15 秒发一次心跳(/api/status/ping
  • 页面不可见时,降频到每 60 秒一次,并且只在 visibilitychange 触发时才发(避免后台静默刷)
  • 心跳失败连续 2 次,标记为“疑似离线”,第 3 次失败才真正置为 offline
  • 前端缓存最后一次成功响应的时间戳,和服务端时间对比,防止客户端时间歪掉太多
  • 状态变更只通过 patch 更新具体 key,不全量替换对象

核心代码就这几行,放这儿备查:

// statusService.js
let heartbeatTimer = null;
const PING_INTERVAL_VISIBLE = 15000;
const PING_INTERVAL_HIDDEN = 60000;

export const startHeartbeat = () => {
  const ping = async () => {
    try {
      const res = await fetch('https://jztheme.com/api/status/ping', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ timestamp: Date.now() }),
      });
      if (res.ok) {
        const data = await res.json();
        // 只更新对应 uid,不全量覆盖
        updateStatus(data.uid, { status: 'online', lastActive: data.timestamp });
      }
    } catch (e) {
      handlePingFailure();
    }
  };

  const schedule = () => {
    const interval = document.hidden ? PING_INTERVAL_HIDDEN : PING_INTERVAL_VISIBLE;
    clearInterval(heartbeatTimer);
    heartbeatTimer = setInterval(ping, interval);
  };

  // 首次立即执行
  ping();
  schedule();

  // 页面可见性变化时重新调度
  document.addEventListener('visibilitychange', schedule);
};

export const stopHeartbeat = () => {
  clearInterval(heartbeatTimer);
  document.removeEventListener('visibilitychange', () => {});
};

还有点小尾巴没剪干净

目前最明显的遗留问题是:用户切到其他 App 后,iOS 会大概率在 30 秒内冻结 JS 执行,这时候心跳停摆,服务端等 90 秒超时才标记 offline,导致“明明人还在看手机,状态却变灰了”。我们也试过用 Service Worker 拦截心跳请求,但 iOS 对 SW 支持太差,连 install 都不稳定,最后放弃了。

折中方案是加了个「状态模糊期」:当服务端超过 45 秒没收到心跳,前端不立刻显示 offline,而是显示「暂无响应」(黄色小圆点),等满 90 秒再切红。用户反馈说比原来“突然消失”体验好很多,虽然技术上还是没根治。

另外,我们没做 WebRTC 或 DataChannel 这类更底层的连接探测,因为项目周期压得太紧,而且目标用户 95% 是企业内网环境,NAT 穿透成本太高,没必要。

回顾与反思

回过头看,在线状态看着简单,实际是前端、网络层、服务端、OS 行为四层耦合的结果。最值得记一笔的是:别迷信浏览器原生 API,尤其是移动端。navigator.onLinePage Visibility APIBackground Sync 这些东西,每个都有至少两个主流机型表现异常,必须自己补逻辑。

做得比较好的一点是状态更新粒度控制。之前用 Vuex 时代习惯 commit 全量状态,现在改用 Map + reactive proxy + patch 更新,通讯录列表滚动完全不卡,这点亲测有效。

如果重来一次,我会提前把心跳逻辑抽成独立的 Web Worker(虽然 iOS 限制多,但至少能保证主线程不被拖垮),也会和服务端约定一个更细粒度的状态码(比如 idleawaydnd),而不是一刀切 online/offline。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如配合消息未读数做状态聚合、或用 IntersectionObserver 懒加载状态图标,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

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

暂无评论