当前位置:首页 > 文章列表 > 文章 > 前端 > WebSocket 心跳怎样避免页面休眠后误判断线

WebSocket 心跳怎样避免页面休眠后误判断线

来源:17golang原创 2026-10-14 15:37:45 0浏览 收藏

我在处理实时看板时遇到过一种很容易误判的“掉线”:用户把标签页切到后台,回来后页面先显示离线,几秒后又自己恢复。后来顺着日志看,真正异常的不是 WebSocket 立刻断开,而是后台页的定时器晚执行了,前端自己的超时判断先响了。解决思路不是把心跳间隔调得更短,而是把页面可见性、连接状态和服务端确认拆成三层信号。

后台页不要用一次迟到的客户端定时器直接判定断线。隐藏时暂停激进的超时判断,页面恢复时用 visibilitychange 重新建立心跳基线;真正的存活依据交给应用层 heartbeat-ack、WebSocket 的 close/error 事件和服务端超时。

下面按我排查这类问题时采用的顺序展开:先识别误判路径,再建立连接层防护,最后把恢复和审计信息收拢到同一套状态机。示例使用浏览器原生 WebSocket API,配图是静态结构说明图,不是浏览器运行截图或实际运行证据。

先把“页面休眠”和“连接断开”分开

当用户切换标签页、最小化窗口或锁定屏幕时,页面会进入 hidden 状态。MDN 说明,后台页的 setTimeout 等计时器可能被浏览器节流,具体延迟还取决于浏览器、页面活跃度和设备状态。因此,“本地定时器没有按 30 秒执行”只能说明调度晚了,不能单独证明 TCP 或 WebSocket 已经断开。

WebSocket 对象本身有 readyState,连接关闭时会触发 close 事件,发生发送错误时可能触发 error 事件。这些是连接层信号;document.visibilityState 和 visibilitychange 是页面生命周期信号,不能混用。

页面可见性、后台定时器与 WebSocket 连接状态边界的浅色工程蓝图说明图
图1:静态结构说明图,查看页面可见性、定时器与 WebSocket 状态之间的边界。

这一区分带来一个直接影响:页面隐藏后,不要因为本地时间差一次超过阈值就立即把状态改成“断线”;页面重新可见时,也不要无条件再创建一个 WebSocket,否则旧连接和新连接可能同时存在。

用 visibilitychange 重置前台恢复窗口

页面从 hidden 回到 visible 时,最适合做一次连接状态收敛:先取消旧的重连计时器,再检查当前 socket 是否仍处于 OPEN;如果仍然打开,刷新本地心跳基线即可。如果已经 CLOSING 或 CLOSED,就交给唯一的 connect 函数按退避规则建立连接。

下面的代码刻意把“页面恢复”和“服务端确认”分开。代码中的 heartbeat 是应用层消息,不是浏览器脚本可以直接调用的 WebSocket 协议级 ping。

const HEARTBEAT_MS = 30000;
const ACK_TIMEOUT_MS = 90000;

let socket = null;
let heartbeatTimer = 0;
let reconnectTimer = 0;
let reconnectAttempt = 0;
let lastAckAt = 0;
let pageHidden = document.hidden;

function connect() {
  // 单连接锁:已有连接或正在连接时,不再创建第二个 WebSocket。
  if (socket && (socket.readyState === WebSocket.OPEN ||
      socket.readyState === WebSocket.CONNECTING)) {
    return;
  }

  socket = new WebSocket("wss://example.com/realtime");

  socket.addEventListener("open", () => {
    // 连接成功后以当前时刻作为新的确认窗口起点。
    reconnectAttempt = 0;
    lastAckAt = Date.now();
    scheduleHeartbeat();
  });

  socket.addEventListener("message", (event) => {
    // 服务端回执才更新确认时间,不把本地 send 当成存活证明。
    if (event.data === "heartbeat-ack") {
      lastAckAt = Date.now();
    }
  });

  socket.addEventListener("close", () => {
    // close 是连接层信号;页面隐藏时只保留状态,恢复时再加速连接。
    stopHeartbeat();
    scheduleReconnect();
  });

  socket.addEventListener("error", () => {
    // error 后通常还会收到 close,这里只记录信号,不重复创建连接。
    stopHeartbeat();
  });
}

function scheduleHeartbeat() {
  stopHeartbeat();
  heartbeatTimer = window.setInterval(() => {
    // 隐藏页的定时器可能延迟,避免用一次迟到直接宣布断线。
    if (pageHidden || !socket || socket.readyState !== WebSocket.OPEN) return;
    if (Date.now() - lastAckAt > ACK_TIMEOUT_MS) {
      socket.close();
      return;
    }
    socket.send("heartbeat");
  }, HEARTBEAT_MS);
}

function stopHeartbeat() {
  window.clearInterval(heartbeatTimer);
  heartbeatTimer = 0;
}

function scheduleReconnect() {
  // 退避并加随机扰动,避免大量页面同时恢复时打满服务端。
  if (reconnectTimer || pageHidden) return;
  const delay = Math.min(30000, 1000 * 2 ** reconnectAttempt) + Math.random() * 500;
  reconnectAttempt += 1;
  reconnectTimer = window.setTimeout(() => {
    reconnectTimer = 0;
    connect();
  }, delay);
}

document.addEventListener("visibilitychange", () => {
  pageHidden = document.hidden;
  if (pageHidden) {
    // 隐藏期间停止客户端主动心跳,避免把调度延迟误判成断线。
    stopHeartbeat();
    return;
  }

  // 回到前台时先刷新本地窗口,再决定保持连接还是重连。
  lastAckAt = Date.now();
  if (socket && socket.readyState === WebSocket.OPEN) {
    scheduleHeartbeat();
  } else {
    window.clearTimeout(reconnectTimer);
    reconnectTimer = 0;
    connect();
  }
});

connect();

这里的关键不是“隐藏就永远不重连”,而是隐藏期间不拿一个不可靠的前端计时器做结论。回到前台后,如果 socket 仍为 OPEN,就继续使用它;如果已经关闭,单连接锁和退避计时器会把恢复动作合并起来。

把服务端确认作为心跳依据

客户端 send 成功只表示数据进入发送流程,不等于服务端已经处理,更不等于业务链路仍然健康。更稳妥的约定是客户端发送 heartbeat,服务端返回 heartbeat-ack,客户端只在收到回执时更新 lastAckAt。服务端也应记录每个连接最后一次收到心跳的时间,超时后主动关闭连接。

应用层 heartbeat、服务端 heartbeat-ack 与退避重连职责关系说明图
图2:静态关系说明图,查看应用层心跳确认与重连决策的职责边界。

如果项目使用 JSON 消息,可以把心跳和业务消息统一成带 type 字段的协议;严格限制服务端只回当前连接的确认,避免旧 socket 的回执误更新新连接状态。

function sendHeartbeat() {
  // 只在 OPEN 状态发送,避免 InvalidStateError 干扰重连状态机。
  if (!socket || socket.readyState !== WebSocket.OPEN) return;
  socket.send(JSON.stringify({ type: "heartbeat", sentAt: Date.now() }));
}

function isAckExpired(now = Date.now()) {
  // 隐藏页恢复后由调用方先重置基线,再进入正常超时判断。
  return now - lastAckAt > ACK_TIMEOUT_MS;
}

心跳周期和超时阈值要一起设计。例如心跳约 30 秒一次、连续约 90 秒没有服务端确认才关闭,给一次后台调度延迟留出空间。这个数字不是浏览器标准,应该结合代理空闲超时、服务端连接策略和业务容忍度调整。

重连策略要覆盖恢复、关闭和重复连接

可以把重连条件收敛成四条:

  • 收到 close 或 error 后,前台页面按指数退避安排一次重连。
  • 页面隐藏时不启动新的激进重连计时器,避免用户切回页面前不断创建连接。
  • 页面恢复时先清理旧计时器,再根据 readyState 决定复用或创建连接。
  • 每次新建 socket 都给它自己的事件处理上下文,旧连接关闭时不能修改新连接的 lastAckAt。

还要注意浏览器生命周期并不等于可靠的后台运行环境。页面可能被系统挂起、卸载,或者网络在用户离开期间发生变化,所以恢复页面时做一次主动状态同步通常比试图让后台心跳永远准时更可靠。WebSocket 连接也应在页面真正结束时释放,避免影响页面缓存或留下无主连接。

把风险收拢到最小排查清单

现象先看什么不要直接下的结论
切后台后出现一次超时visibilityState、页面隐藏时长、lastAckAt不是“必然断线”
回到前台出现两个连接reconnectTimer 与 CONNECTING 状态不是把重连间隔调短
服务端仍收到连接但业务无响应heartbeat-ack 是否由同一连接返回不是只看 readyState === OPEN

我最后会把页面隐藏与恢复事件、socket 的 readyState 变化、close 的 code/reason、最近一次 heartbeat-ack 时间,以及重连次数放进同一条诊断记录。这样日志才能区分调度延迟、网络断开和服务端超时,不会把“定时器晚了”直接写成“连接已断”。

相关问题

WebSocket 心跳应该用 setInterval 还是 setTimeout?

两者都可能受到页面调度影响。重点是用服务端确认更新存活时间,并在发送前检查 readyState;如果需要根据回执安排下一次心跳,也可以改用递归 setTimeout。

页面切后台后要不要立刻 close?

取决于业务。如果后台不需要实时数据,可以主动暂停并在恢复时重连;如果需要保持会话,就保留连接但不要因单次本地定时器延迟误判。无论哪种选择,都应让服务端超时和前端恢复逻辑保持一致。

为什么 readyState 是 OPEN 仍然可能收不到业务消息?

OPEN 只表示 WebSocket 对象当前处于打开状态,不能替代业务层确认。网络路径、代理、服务端处理和业务订阅都可能另外出问题,所以心跳回执和恢复后的状态同步仍然必要。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
WebTransport 怎样选择双向流和 DatagramWebTransport 怎样选择双向流和 Datagram
上一篇
WebTransport 怎样选择双向流和 Datagram
CSS @property 怎样让自定义属性支持平滑动画
下一篇
CSS @property 怎样让自定义属性支持平滑动画
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    471次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    531次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    550次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    501次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    318次使用