登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

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

来源:17golang原创

时间:2026-10-14 15:37:45 483浏览 收藏

我在处理实时看板时遇到过一种很容易误判的“掉线”:用户把标签页切到后台,回来后页面先显示离线,几秒后又自己恢复。后来顺着日志看,真正异常的不是 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删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>