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

WebSocket 重连退避与页面卸载的收尾

来源:17golang原创

时间:2026-09-29 06:10:25 381浏览 收藏

实时看板、在线协作和消息页常见一个细小但顽固的故障:网络断开后 WebSocket 能重连,用户离开页面后却还在继续排队;或者页面从前进后退缓存恢复时,旧连接和新连接同时存在。稳定的做法是把“异常断开”和“页面收尾”拆开处理:前者允许退避重连,后者设置主动关闭标记、清掉定时器,并在需要时由 pageshow 恢复。

重连只由非主动关闭触发;页面离开先清理状态,再关闭 socket。这样退避策略不会把页面卸载重新变成无限重连。
要点速览
  • 连接对象、重试次数、定时器和收尾标记必须共享一份状态。
  • 延迟使用指数退避加少量随机抖动,并在 open、close 和主动收尾时清理。
  • pagehide 处理离开,pageshow 处理 bfcache 恢复;不要把 unload 当作唯一保证。

先把重连状态和页面生命周期拆开

WebSocket 的 close 事件可能来自服务端、网络错误或页面代码主动调用 close()。如果所有 close 都直接进入重连函数,页面离开时就会出现“刚关闭又创建”的竞态。先定义一份小状态:当前 socket、重试次数、重连定时器,以及表示页面是否正在收尾的 closing。

WebSocket 重连状态图,展示 socket、close 事件、退避定时器与页面生命周期边界的关系
图1:WebSocket 重连状态说明图,区分异常 close 的重连域与页面收尾域。
状态/事件应做的事不能做的事
open重置重试次数,确认连接属于当前实例保留旧的重连定时器
close清理引用,再按主动标记决定是否重连无条件创建新 socket
pagehide设置 closing,取消定时器并 close(1000)等待 unload 才清理
pageshow.persisted解除 closing,重新建立一条连接复用已经关闭的 socket 对象

退避函数只接受一次“允许重连”的判断。下面的代码把延迟上限设为 30 秒,抖动只用于错开同时重连的客户端;重试次数在新连接成功后归零。

const state = {
  socket: null,
  retryTimer: 0,
  retryCount: 0,
  closing: false,
};

function scheduleReconnect() {
  // 页面正在收尾或已经排队时,不再创建第二个定时器。
  if (state.closing || state.retryTimer) return;
  const base = Math.min(30000, 500 * 2 ** state.retryCount);
  const delay = base + Math.floor(Math.random() * 300);
  state.retryCount += 1;
  state.retryTimer = window.setTimeout(() => {
    // 回调执行前再次判断,覆盖 pagehide 与定时器竞态。
    state.retryTimer = 0;
    if (!state.closing) connect();
  }, delay);
}

退避只绑定异常关闭,正常收尾不再拉起

每次连接都要绑定自己的 close 监听器,监听器里先确认它仍是当前 socket。否则旧连接的延迟 close 事件可能覆盖新连接状态。close() 会启动关闭握手,不能把它当成“立即丢弃所有已发送数据”的硬切断;主动收尾的目标是停止后续工作,而不是伪造网络故障。

WebSocket 主动关闭与异常关闭的边界图,展示 close 事件、closing 标记和退避重连之间的静态关系
图2:关闭边界说明图,主动 close 只回收资源,异常 close 才连接到退避重连。
function connect() {
  if (state.closing || state.socket) return;
  const socket = new WebSocket("wss://example.test/realtime");
  state.socket = socket;

  socket.addEventListener("open", () => {
    // 成功连接后清零退避计数,避免短暂故障后长期等待。
    if (state.socket === socket) state.retryCount = 0;
  });

  socket.addEventListener("close", (event) => {
    // 旧 socket 的延迟事件不能清掉新 socket 的引用。
    if (state.socket === socket) state.socket = null;
    if (!state.closing && !event.wasClean) scheduleReconnect();
  });
}

function closeForNavigation() {
  // 收尾顺序固定:先阻断重连,再取消定时器,最后关闭连接。
  state.closing = true;
  if (state.retryTimer) {
    window.clearTimeout(state.retryTimer);
    state.retryTimer = 0;
  }
  if (state.socket) {
    state.socket.close(1000, "page navigation");
    state.socket = null;
  }
}

页面卸载、隐藏和 bfcache 的边界

对于“用户离开当前页面”这个动作,pagehide 比 unload 更适合,它与 bfcache 兼容;页面从 bfcache 恢复时,pageshow 的 persisted 可以提示重新建立连接。只是移动端的页面生命周期并不保证每个事件都发生,所以不能把一次 close 当作服务端会话已经完成的证明。

window.addEventListener("pagehide", () => {
  // pagehide 只负责页面离开场景,不把普通后台隐藏误判为卸载。
  closeForNavigation();
});

window.addEventListener("pageshow", (event) => {
  // bfcache 恢复后,旧的 WebSocket 实例不能复用。
  if (event.persisted) {
    state.closing = false;
    connect();
  }
});

document.addEventListener("visibilitychange", () => {
  // 隐藏页暂停非必要 UI 工作;是否断开实时连接应由业务策略决定。
  if (document.visibilityState === "hidden") pauseRendering();
});

如果业务要求切到后台就断开,应把策略写成独立的 closeForBackground,并在恢复时走同一套 connect,不要直接复用页面卸载函数后忘记解除 closing。若只是减少渲染和心跳成本,保留 socket 通常更符合实时页面的预期。

常见问题

为什么加了指数退避仍然会重复连接?

通常是定时器没有清零,或旧 socket 的 close 事件没有做实例比较。把“当前 socket 是谁”和“是否已有 retryTimer”作为两个独立条件检查。

主动调用 close() 后还需要监听 close 吗?

需要。close 事件仍是统一回收引用和心跳资源的入口,但监听器必须看到 closing 后跳过重连。

pagehide 能保证所有手机都触发吗?

不能。它适合监听页面离开,但移动端切换应用或系统回收页面时可能没有可靠的最后事件;重要数据要提前保存,不能押在 unload 上。

收尾检查清单

  • 异常 close 会重连,主动 close 不会重连。
  • 每个重连定时器在执行、成功连接或页面收尾时都能被清理。
  • pageshow 的 bfcache 恢复不会复用旧 socket,也不会留下 closing 标记。
  • 隐藏页策略和卸载策略分开,移动端不可靠事件不会被当成业务确认。
声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>