关于 web-realtime-websockets
面向实现实时 Web 功能的开发者,介绍浏览器 WebSocket 的常用模式,涵盖连接恢复、消息处理、二进制传输和页面生命周期。
主要功能
- 01使用抖动和上限进行指数重连
- 02通过心跳检测连接状态
- 03断连期间暂存消息
- 04类型化消息约定与二进制帧
- 05React Hook 与共享连接
- 06处理认证和 bfcache 生命周期
适用场景
- 01构建聊天或协同编辑功能
- 02在网络中断后恢复连接
- 03分块传输文件并显示进度
- 04在多个 UI 组件间共享连接
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-realtime-websockets安装后在 Claude Code 中输入/web-realtime-websockets确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.yamlreference.mdexamples/binary.mdcore.mdpresence.mdstate-machine.md详细介绍
WebSocket 实时通信模式
快速指南: 原生 WebSocket API 只提供全双工通道,其他一概不管——重新连接、存活检测、连接中断期间的消息传递和消息类型都需要你自行实现,而本指南介绍了这些功能各自的实现方式。会影响方案选择的事实包括:API 不接受自定义标头,因此身份验证需要通过第一条消息而非标头进行;
onerror后总会触发onclose,因此恢复逻辑应集中在一处;开放的连接会阻止浏览器使用前进/后退缓存;而且readyState的变化与触发它们的调用并不同步。
详细资源:
- examples/core.md — 生命周期、带抖动的退避、心跳、排队、类型化消息、二进制协议、身份验证、房间、
useWebSocket、共享连接、bfcache - examples/state-machine.md — 将连接状态作为 reducer 处理,以及布尔值何时不再够用
- examples/binary.md — 带进度显示的分块文件上传
- examples/presence.md — 在线状态和离开检测
- reference.md — 带重新连接建议的关闭代码表、readyState 值、附带代码的反模式、部署检查清单
适用的方案
- 一个组件拥有连接 — 由一个钩子创建套接字、管理退避和心跳计时器,并在卸载时将其关闭。从 examples/core.md 开始。
- 多个组件共享一个连接 — 由一个 provider 拥有套接字,并通过返回自身取消订阅函数的
subscribe(type, handler)API 按消息类型路由。每个组件都打开一个套接字,会让针对同一服务的握手和心跳成倍增加。另请参阅 examples/core.md。
一旦连接需要区分的状态超过两三个——例如带尝试次数的重新连接、永久失败——布尔标志就难以组合,examples/state-machine.md 介绍了替代方案。
<critical_requirements>
编写 WebSocket 代码前
采用带抖动的指数退避,并限制延迟和尝试次数。 一次服务重启后断开的所有客户端都会同时尝试重新连接,而抖动能将它们错开,避免再次造成服务中断。
为每条消息添加 type 字段,并将消息集合建模为可辨识联合类型。 这样,在默认分支中赋值给 never,就能让新增的服务端消息触发编译错误,而不是作为未处理的值漏过去。
套接字未处于 OPEN 状态时,将发送请求加入队列,并在重连后刷新队列。 在已关闭的套接字上调用 send(),根据状态不同,可能会抛出异常,也可能丢弃消息;有界队列能将两秒的断连转变为延迟,而不是数据丢失。
运行心跳检测,并将缺少回复视为连接已失效。 代理和 NAT 会在没有关闭帧的情况下丢弃空闲连接,因此套接字可能在停止传输任何内容后数分钟内仍显示为 OPEN。
只要页面通过 HTTPS 提供服务,就使用 wss://。 除 localhost 外,浏览器会阻止安全源使用不安全协议。
在 pagehide 时关闭连接,并在 event.persisted 为真时于 pageshow 时重新连接。 打开的套接字会使页面无法进入前进/后退缓存,因此这能确保即时返回导航正常工作。
</critical_requirements>
自动检测: new WebSocket、wss://、ws://、socket.onmessage、socket.onopen、socket.onclose、socket.onerror、readyState、WebSocket.OPEN、bufferedAmount、binaryType、CloseEvent、event.code、pagehide、pageshow、event.persisted、WebSocketStream
适用场景:
- 需要低延迟的双向消息传递——聊天、协作编辑、实时仪表盘
- 采用退避、抖动和重试上限的重连
- 通过心跳和关闭代码解释检测连接是否存活
- 通过有界队列确保短暂断连期间的消息送达
- 类型化消息契约和穷尽式处理
- 二进制帧,包括带进度的分块传输
- 在组件树中共享连接
其他部分会处理:
- 客户端只接收、不发送的服务端推送——这种情况下,双工通道及其整套重连机制毫无用处,而长连接 HTTP 响应是更轻量的选择。
- 协议层提供的房间、命名空间、确认机制和传输回退——这里的房间模式是基于原始 API 手动构建的,而提供这些功能的库属于不同主题,并使用不同的线格式。
- 服务端的连接注册表、广播扇出和房间成员关系。
- 接收的消息存储在何处,以及如何渲染这些消息。
- 签发和刷新首条消息携带的令牌。
WebSocket 是一条保持打开状态的 TCP 连接,其上叠加了帧。HTTP 原本免费提供的一切都不复存在,而你现在需要负责的内容,正是本技能的主题。
- 网络会断开连接。 重连并非边缘情况,因此退避和抖动应从第一个版本就纳入,而不是留到以后。
- 失效的连接看起来只是安静的连接。 只有心跳才能区分两者,因为被中间设备切断的连接不会发送关闭帧。
- 断连期间发送的消息会丢失。 有界队列能将其转化为延迟。
- 帧是无类型字符串。 你在其上建模的内容决定了类型安全性。
- 保持连接打开的代价不只是带宽。 这会使页面无法进入 bfcache,表现为返回导航变慢,而非报错。
CONNECTING -> OPEN <-> (messages) -> CLOSING -> CLOSED
| |
(error) <- reconnect <- (close)
<decision_framework>
阅读关闭事件
event.code 表示是否适合重新连接:1000 表示正常、有意关闭,重新连接会违背用户的意愿;1006 表示异常关闭且没有收到帧,这是常见的网络断开情形,也是设置退避机制的原因;1012 和 1013 表示服务端要求等待更长时间;100x 协议和数据错误意味着客户端有问题,重试只会再次遇到相同错误。包含重连列的完整表格见 reference.md。
请将有意关闭与关闭代码分开记录——用户按下断开按钮和服务端发送 1000 都会以 1000 到达,但只有前者应该彻底停止重试循环。
选择消息格式
默认使用带判别联合的 JSON,主要原因是它在线路上可读。只有当测量结果确实显示负载大小造成影响时,才使用二进制帧;并保留混合结构——控制消息使用 JSON,负载使用二进制——而不是将所有内容都编码成同一种格式。
发送大型负载
send() 接受任何内容,并会缓冲网络尚未传输的部分,因此快速生产数据会让 bufferedAmount 不断增长。发送大型负载前请检查该值并分块发送,这样也便于报告进度。除此数值外,没有内置的背压信号。
</decision_framework>
核心模式
模式 1:基本连接
四个处理程序都各有用途:onerror 不会提供有用的细节,而且总会接着触发 onclose,因此只需在 onclose 中处理恢复。
const socket = new WebSocket(WS_URL);
socket.onopen = () => flushQueue();
socket.onmessage = (event: MessageEvent) => handle(event.data);
socket.onerror = () => markUnhealthy(); // no detail available, onclose follows
socket.onclose = (event: CloseEvent) => maybeReconnect(event.code);
完整代码:examples/core.md
模式 2:带抖动的指数退避
关键在于抖动:没有它,所有同时断开的客户端也会同时重连。
function calculateBackoff(attempt: number): number {
const exponential = Math.min(
INITIAL_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, attempt),
MAX_BACKOFF_MS,
);
const jitter = exponential * JITTER_FACTOR * (Math.random() * 2 - 1);
return Math.floor(exponential + jitter);
}
完整代码:examples/core.md
模式 3:心跳与 ping-pong
按间隔发送消息,设置一个更短的超时,并在收到回复时清除超时。超时触发就意味着连接已断开,无论 readyState 看起来多么正常。
const ping = setInterval(() => {
socket.send(JSON.stringify({ type: "ping" }));
pongTimer = setTimeout(
() => socket.close(4000, "heartbeat timeout"),
HEARTBEAT_TIMEOUT_MS,
);
}, HEARTBEAT_INTERVAL_MS);
// on receiving { type: "pong" }: clearTimeout(pongTimer)
完整代码:examples/core.md
模式 4:断开连接期间的消息排队
每次发送前都检查 readyState,因为它的变化独立于前后调用。
public send(data: unknown): void {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(data));
} else {
this.queueMessage(data); // bounded — oldest dropped at MAX_QUEUE_SIZE
}
}
完整代码:examples/core.md
模式 5:使用判别联合实现类型安全的消息
按方向分别定义联合类型,并在接收端进行穷尽性检查。
type ServerMessage =
| { type: "subscribed"; channel: string; members: string[] }
| { type: "message"; channel: string; content: string; sender: string }
| { type: "error"; code: number; message: string };
function handleServerMessage(message: ServerMessage): void {
switch (message.type) {
case "subscribed":
return onSubscribed(message);
case "message":
return onMessage(message);
case "error":
return onError(message);
default: {
const exhaustive: never = message;
return exhaustive;
}
}
}
完整代码:examples/core.md
模式 6:二进制数据处理
binaryType = "arraybuffer" 可通过 DataView 同步读取帧;默认的 Blob 则会强制每条消息都进行异步读取。
socket.binaryType = "arraybuffer";
socket.onmessage = (event: MessageEvent) => {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
const messageType = view.getUint8(0);
} else {
handleJson(JSON.parse(event.data));
}
};
完整代码:examples/core.md · 分块上传:examples/binary.md
模式 7:通过 WebSocket 进行身份验证
该 API 不支持设置自定义标头,因此令牌要放在第一帧中,而不是会被记录到日志里的 URL 中。其他所有操作都要等验证结果返回后再进行。
socket.onopen = () => {
socket.send(JSON.stringify({ type: "auth", token }));
};
// queue all other sends until { type: "auth_result", success: true } arrives
完整代码:examples/core.md
模式 8:房间与频道
协议中没有房间概念——房间只是消息约定加本地成员状态;而防止向未加入的房间发送消息的检查,正是保留此状态的意义所在。
public joinRoom(roomId: string): void {
if (this.rooms.has(roomId)) return;
this.rooms.set(roomId, { id: roomId, members: new Set(), joined: false });
this.send({ type: "join_room", roomId });
}
// the server's room_joined reply flips joined to true and seeds members
完整代码:examples/core.md
模式 9:自定义 React Hook
由一个 Hook 管理套接字、退避计时器、心跳和队列,并提供状态和操作方法。
const { status, send, close, reconnect } = useWebSocket(WS_URL, {
onMessage: handleServerMessage,
heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
});
完整代码:examples/core.md
模式 10:通过上下文共享连接
由提供程序管理套接字,并按消息类型分发;每个订阅者都会获得自己的取消订阅函数,以便从 effect 中返回。
const { status, send, subscribe } = useWebSocketContext();
useEffect(() => subscribe("notification", handleNotification), [subscribe]);
完整代码:examples/core.md
模式 11:与 bfcache 兼容
在 pagehide 时关闭连接,以使页面符合缓存条件;只有页面确实从缓存中恢复时,才在 pageshow 时重新连接。
window.addEventListener("pagehide", () => socket?.close(1000, "Page hidden"));
window.addEventListener("pageshow", (event: PageTransitionEvent) => {
if (event.persisted) connect();
});
完整代码:examples/core.md
<red_flags>
注意事项
会导致运行时问题:
- 在 HTTPS 页面上使用
ws://——除 localhost 外,浏览器会拒绝连接。 - 未检查
readyState就调用send()——状态会独立于外围代码变化,因此消息会丢失或抛出异常。 - 卸载时不清理——套接字、心跳间隔和重试计时器都会在组件销毁后继续运行。
- 不使用退避机制重新连接——一次重启断开的客户端会同时重新连接,导致故障再次发生。
- 未使用
try就对event.data调用JSON.parse——一帧格式错误的数据会导致处理程序失效,后续所有帧也无法处理。 - 假设
event.data是文本——开始处理二进制数据后,应先检查instanceof ArrayBuffer。 - 在连接 URL 中放置令牌——令牌会出现在服务器和代理日志中——应将其作为第一帧发送。
- 使用
beforeunload进行清理——仅注册该事件就足以使页面失去使用 bfcache 的资格——应使用pagehide。 - 收到关闭代码
1000时重新连接——这表示正常、有意的关闭,包括用户主动请求的关闭。
容易引起意外的行为:
onerror不包含诊断细节,而且后面总会触发onclose;如果在两处都编写恢复逻辑,就会运行两次。- 中间设备中断连接时不会发送关闭帧,因此
readyState可能会在数分钟内保持OPEN——只有心跳才能发现问题。 bufferedAmount是唯一的背压信号;快速生产数据、速度超过网络传输能力时,不会抛出任何异常。- 默认的
binaryType是Blob,这会导致每条消息都需要异步读取——在收到第一帧二进制数据前,将其设为arraybuffer。 - 关闭代码
1006不会在线路上传输;浏览器会在异常关闭时合成该代码,因此它不包含服务器提供的原因。 - 能够提供真正背压的
WebSocketStream仅适用于 Chromium。
</red_flags>