前端在线状态检测的实战方案与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.onLine、Page Visibility API、Background Sync 这些东西,每个都有至少两个主流机型表现异常,必须自己补逻辑。
做得比较好的一点是状态更新粒度控制。之前用 Vuex 时代习惯 commit 全量状态,现在改用 Map + reactive proxy + patch 更新,通讯录列表滚动完全不卡,这点亲测有效。
如果重来一次,我会提前把心跳逻辑抽成独立的 Web Worker(虽然 iOS 限制多,但至少能保证主线程不被拖垮),也会和服务端约定一个更细粒度的状态码(比如 idle、away、dnd),而不是一刀切 online/offline。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如配合消息未读数做状态聚合、或用 IntersectionObserver 懒加载状态图标,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论