WebSocketStream 解决了传统 WebSocket 的什么问题?
WebSocketStream 把 WebSocket 连接暴露为 ReadableStream 与 WritableStream,可以利用流的读取和写入背压;它仍是实验性能力,不能直接当成普通 WebSocket 的兼容替代。
传统 WebSocket 的 message 事件会持续把消息交给应用;发送端可以通过 bufferedAmount 观察积压,但应用需要自己管理消费速度和队列。WebSocketStream 把连接的接收和发送暴露为 Web Streams,可使用流的背压与取消语义。
流的背压不等于消息协议
async function consumeStream(url, onMessage) {
if (!('WebSocketStream' in globalThis)) throw new Error('当前浏览器不支持 WebSocketStream');
const socket = new WebSocketStream(url);
const { readable, writable } = await socket.opened;
const writer = writable.getWriter();
try {
await writer.write(JSON.stringify({ type: 'hello' }));
for await (const chunk of readable) {
await onMessage(chunk);
}
} finally {
writer.releaseLock();
await socket.close();
}
}
这个示例只展示流生命周期,真实应用还要确认 chunk 的类型与消息边界。ReadableStream 的 chunk 不一定对应业务 JSON 消息;如果协议是字节流,需要自行 framing、长度限制和解码。
WebSocketStream 当前支持度有限,且 API 语义可能随浏览器实现变化。正式项目要做特性检测和 WebSocket 降级,不能因为本机 Chromium 可运行就覆盖所有用户。
背压能限制积压,但不能消除所有成本
读取循环里的 await onMessage 让消费速度反馈到流;如果回调不断启动后台 Promise 却立即返回,应用仍可能积压未完成工作。下游应有界,必要时丢弃已过期的实时状态或合并更新。
写入时等待 writer.write 的 Promise,表示流接受写入的节奏;这不是服务端已经处理业务的确认。需要可靠业务语义时,还要定义序号、确认、重试和幂等。
压缩、网络缓冲和服务器队列也会占用内存。背压是协作机制,不是整个进程内存的硬上限。
opened、closed 和 close 需要分别处理
构造连接后要等待 opened 成功或失败;closed 用于观察结束原因。连接失败、服务端关闭和用户主动 close 是不同状态,界面应按需要提示或重连。
取消读取、释放 reader/writer 锁和关闭连接有先后关系。若流仍被锁定,直接 close 可能失败;异常路径也要确保锁释放,不能只在正常 EOF 时清理。
重连不应自动重复发送有副作用消息。连接 ID、消息序号和服务端状态查询可以帮助判断哪些数据已经处理;实时光标或位置快照则可按新值覆盖旧值。
WebSocketStream 解决的是流式传输和背压接口,不替代身份认证、业务协议、权限或 WebRTC 音视频能力。没有它的浏览器仍应能够通过成熟 WebSocket 完成核心功能。
读取和写入的 chunk 需要按类型处理
接收流可能产生 Uint8Array,也可能由实现提供其他二进制形态。应用协议如果传 JSON,应该先使用流式 TextDecoder,再按消息边界解析;不能直接对任意 chunk 调 JSON.parse。
async function readLines(readable, onLine) {
const reader = readable.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) if (line) await onLine(line);
}
buffer += decoder.decode();
if (buffer) await onLine(buffer);
} finally {
reader.releaseLock();
}
}
这是按换行分隔的教学协议,不是 WebSocketStream 标准规定的消息格式。生产协议应处理 CRLF、最大行长度、协议版本和解析错误;如果服务端发送的是长度前缀或二进制帧,需使用对应解析器。
backpressure 也要考虑业务丢弃策略
聊天消息通常不能丢;实时光标、传感器和行情快照则可能只需要最新值。当消费速度跟不上时,可以在应用层合并同一对象的旧快照,不能对所有数据都无条件丢弃。
如果下游必须按顺序处理每条消息,队列需要上限。达到上限后应暂停读取、拒绝新任务或让服务端降速;无限累积只会把问题从网络缓冲转移到 JavaScript 堆。
写入流的关闭、abort 和服务端关闭也要传达给业务。close 成功只表达连接关闭流程完成,不代表最后一条业务消息已被服务器持久化。需要确认的操作要有应用层 ack 和幂等键。
连接失败、重连和权限过期
opened 失败时不要进入读取循环;closed 结果应记录 code/reason,并区分用户主动关闭、网络断开和服务器拒绝。重连需要退避与最大尝试次数,避免网络断开时每个标签页同时高频重连。
认证令牌过期后,盲目重连同一 URL 不会恢复权限。应刷新或重新建立会话,并清理旧连接的 reader、writer 与事件监听。重连期间用户发出的操作要排队、失败或明确取消,不能悄悄丢失。
实验性 API 的降级方案
WebSocketStream 目前不是所有浏览器的 Baseline 能力。降级到 WebSocket 时,应用需要重新适配消息回调、bufferedAmount 和发送队列;不能只把构造函数替换成 new WebSocket() 就认为背压仍然存在。
如果实时业务不适合降级,应该给用户明确的不支持提示或切换到轮询/普通 HTTP,而不是在后台持续报错重试。测试要覆盖 API 不存在、连接超时、半包、慢消费者、服务端关闭和页面卸载。
先确定消息边界,再谈流式解析
WebSocket 的协议层本身保留消息边界,但转换为 Streams 后,应用拿到的 chunk 仍可能需要根据实现和数据类型重新组装。业务协议最好明确一种格式,例如“长度前缀 + 二进制 payload”或“每条消息一行 JSON”,并限制单条消息最大长度。解析器在遇到超限或非法 UTF-8 时应主动关闭连接,不能一直等待更多字节。
如果服务端发送的是 JSON 快照,可以在应用层给每条消息带 id、type 和 version。收到旧版本时丢弃,收到缺失字段时记录协议错误;这样即使重连后消息顺序发生变化,也不会把旧状态重新写回界面。解析、校验和渲染最好分开,避免异常消息直接影响 DOM。
写入背压和业务确认是两件事
writer.write() 解决的是数据是否已经交给浏览器流的节奏,不能证明服务端已执行操作。比如发送“删除文件”时,客户端仍应等待服务端返回带请求 ID 的成功或失败响应,并在超时后明确告诉用户结果未知。重试前要确认操作是否幂等,否则可能重复创建记录或重复发送消息。
发送队列应设置上限。达到上限时可以暂停输入、合并最新状态或拒绝低优先级消息;不要无限把对象放进数组等待网络恢复。页面切到后台时也要评估是否暂停非关键发送,避免多个标签页同时保持高频连接。
关闭流程要覆盖异常路径
应用主动退出时,可以先停止产生新消息,再关闭 writer、取消 reader,最后关闭连接;但不能无限等待服务端确认。pagehide、网络断开和令牌过期都可能让这个流程被中断,因此清理逻辑需要幂等,重复调用不会抛出新的业务错误。
封装重连器时要使用指数退避和随机抖动,限制最大等待时间,并在页面不可见时降低频率。重连成功后先重新认证、协商协议版本,再按业务决定补发未确认消息还是重新拉取完整快照;不要盲目重放所有本地队列。