Day 57 · WebSocket 协议与 API — 借 HTTP 的壳,干全双工的活
打开一个 WebSocket 连接,浏览器发出去的第一个请求长得完全像 HTTP 请求——这是协议设计史上最优雅的兼容方案之一:借 HTTP 的 101 状态码完成"协议升级",之后这条 TCP 连接就不再是 HTTP 了。今天从握手开始,把 WebSocket 的协议机制和浏览器 API 彻底吃透,最后封装一个生产可用的连接管理器。
目录
- 一、握手:借 HTTP 的壳升级协议
- 二、消息帧:握手之后的最小开销
- 三、浏览器 API:四个事件一个状态机
- 四、有状态是万恶之源:断连意味着什么
- 五、封装连接管理器
- 六、搭建本地测试服务器
- 七、常见坑点
- 八、自测挑战
- 九、总结
一、握手:借 HTTP 的壳升级协议
1.1 抓包看真相
// 客户端发起(new WebSocket 的瞬间):
GET /sensor-feed HTTP/1.1
Host: factory.example.com
Upgrade: websocket ⭐ 请求协议升级
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ⭐ 随机 base64(服务器用来生成校验值)
Sec-WebSocket-Version: 13
// 服务器同意(101 切换协议):
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ⭐ Key 的 SHA-1 + 固定盐(防普通 HTTP 服务器误响应)
// —— 从这个字节之后,这条连接说的不再是 HTTP ——
1.2 为什么要这么设计
兼容基础设施:2011 年的代理服务器、防火墙、负载均衡都认识 HTTP。握手伪装成 HTTP 请求,就能穿透气所有只放行 80/443 端口的网络——这是 WebSocket 能普及的关键历史决策。
💡 wss 与 ws 的关系 = https 与 http 的关系:wss 先走 TLS,再在加密层里做同样的握手。生产环境一律 wss(明文 ws 会被混合内容策略拦截)。
1.3 验证实验
DevTools → Network → 筛选 WS → 刷新页面 → 点击你的 WS 连接:
- Headers 标签页:看到完整的握手请求/响应(对照 1.1)
- Messages 标签页:每一帧收发记录(本周每天都要用这个面板)
二、消息帧:握手之后的最小开销
握手之后,数据被切成一个个**帧(frame)**传输:
帧结构(示意):
┌─────────┬─────────┬────────────┬──────────────┐
│ FIN/opcode│ Mask │ Payload Len │ Payload │
│ ~2 字节 │ 4 字节 │ 0~8 字节 │ 实际数据 │
└─────────┴─────────┴────────────┴──────────────┘
对比:
HTTP 每请求头:几百字节起
WebSocket 帧头:2~14 字节
| 要点 | 说明 |
|---|---|
| opcode | 0x1 文本帧 / 0x2 二进制帧 / 0x8 关闭 / 0x9 心跳 ping / 0xA 心跳 pong |
| Mask | 客户端→服务器的帧必须掩码(防中间代理的缓存投毒攻击),浏览器自动处理 |
| 消息边界 | WS 是消息导向的:一次 send 对应一次 message 事件(不像 TCP 裸流要自己粘包拆包) |
🎯 消息导向是 WS 对裸 TCP 最大的易用性贡献:服务器
ws.send(JSON.stringify(data)),客户端onmessage收到的就是完整的一条——第 59 天做二进制协议时你会庆幸不用自己处理粘包。
三、浏览器 API:四个事件一个状态机
3.1 最小示例
/**
* WebSocket 最小可用示例:连接本地测试服务器
*/
function connect(): WebSocket {
const ws = new WebSocket("ws://localhost:8080/sensor-feed");
// ⭐ 必须在 connect 前(即构造后立即)设置,否则二进制帧会被当 Blob
ws.binaryType = "arraybuffer";
// 四个事件 = WS 生命周期的全部
ws.onopen = () => {
console.log("连接建立");
ws.send(JSON.stringify({ type: "subscribe", channels: ["furnace-1"] }));
};
ws.onmessage = (ev: MessageEvent) => {
if (typeof ev.data === "string") {
const msg = JSON.parse(ev.data);
console.log("文本消息", msg);
} else {
console.log("二进制帧", new Uint8Array(ev.data)); // Day 59 的主战场
}
};
ws.onerror = () => console.error("连接错误(通常跟着一个 close)");
ws.onclose = (ev: CloseEvent) => {
console.log(`连接关闭 code=${ev.code} reason=${ev.reason} clean=${ev.wasClean}`);
};
return ws;
}
3.2 readyState 状态机
CONNECTING(0) ──open──▶ OPEN(1) ──close──▶ CLOSING(2) ──▶ CLOSED(3)
│ │
└── 握手失败 ─────────┘→ CLOSED
// 发送前必须检查状态(CONNECTING 状态 send 会直接抛异常!)
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
3.3 CloseEvent 的 code 语义(背下来,Day 58 重连策略依赖它)
| code | 含义 | 该重连吗 |
|---|---|---|
| 1000 | 正常关闭(双方协商) | ❌ 用户主动断开 |
| 1001 | 端点离开(服务器重启/页面关闭) | ✅ |
| 1006 | 异常关闭(没有 close 帧:断网/服务器崩了) | ✅✅ 最常见 |
| 1011 | 服务器内部错误 | ✅(带退避) |
| 4000-4999 | 自定义业务码(如"会话过期") | 看业务 |
四、有状态是万恶之源:断连意味着什么
4.1 HTTP vs WS 断连的本质区别
HTTP:无状态 → 一个请求失败,下一个请求全新开始,无损
WS: 有状态 → 断连后:
1. 服务器推的消息全部丢失(客户端根本不知道错过了什么)
2. 订阅状态、会话上下文全部清零
3. 重连后一切要从头建立(重新 subscribe)
4.2 由此推导出的本周路线图
| 问题 | 解法 | 对应天 |
|---|---|---|
| 怎么知道连接已经死了(半开连接) | 心跳 | Day 58 |
| 死了之后怎么回来 | 指数退避重连 | Day 58 |
| 断线期间丢的数据怎么办 | 缓冲 + 补传 | Day 61 |
| 重连后状态怎么恢复 | 重新订阅 + 会话重建 | Day 61 |
| 恢复后数据怎么无缝衔接 | 时间戳去重 | Day 61 |
今天先把连接本身写对,容错层后面逐天补齐。
五、封装连接管理器
5.1 设计目标
不直接用 new WebSocket() 的理由:
- 原生 API 事件不可重复绑定、无类型友好的消息分发
- 服务器地址要按环境切换(开发/生产)
- Day 58/61 会往这个类里加心跳、重连、缓冲——今天打好扩展地基
/**
* WebSocket 连接管理器:类型化消息 + 事件订阅
* Day 58 将扩展心跳与重连,Day 61 将扩展缓冲与补传
*/
export type WsMessage =
| { type: "snapshot"; payload: PanelSnapshot }
| { type: "subscribe"; channels: string[] }
| { type: "ack"; code: number };
export interface PanelSnapshot {
ts: number;
devices: { id: string; temperature: number }[];
}
export class WsConnection {
private ws: WebSocket | null = null;
private msgListeners = new Set<(msg: WsMessage) => void>();
private stateListeners = new Set<(open: boolean) => void>();
constructor(private url: string) {}
/** 建立连接(幂等:已连接则跳过) */
connect(): void {
if (this.ws && this.ws.readyState <= WebSocket.OPEN) return;
const ws = new WebSocket(this.url);
ws.binaryType = "arraybuffer";
ws.onopen = () => this.stateListeners.forEach((l) => l(true));
ws.onmessage = (ev) => this.dispatch(ev.data);
ws.onclose = () => {
this.stateListeners.forEach((l) => l(false));
this.ws = null; // 置空:下次 connect 可以重建
};
// onerror 不单独处理:浏览器保证 error 之后必有 close,统一在 close 处理
this.ws = ws;
}
/** 发送消息(只有 OPEN 状态才允许) */
send(msg: WsMessage): boolean {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(JSON.stringify(msg));
return true;
}
/** 订阅消息(返回退订函数——第 1 周的发布订阅模式复用) */
onMessage(listener: (msg: WsMessage) => void): () => void {
this.msgListeners.add(listener);
return () => this.msgListeners.delete(listener);
}
/** 订阅连接状态变化 */
onState(listener: (open: boolean) => void): () => void {
this.stateListeners.add(listener);
return () => this.stateListeners.delete(listener);
}
/** 主动关闭 */
close(): void {
this.ws?.close(1000, "client-shutdown");
this.ws = null;
}
/** 消息分发:文本解析 JSON,二进制留给 Day 59 */
private dispatch(data: string | ArrayBuffer): void {
if (typeof data === "string") {
try {
this.msgListeners.forEach((l) => l(JSON.parse(data)));
} catch {
console.warn("无法解析的消息", data);
}
}
}
}
5.2 环境感知的 URL(第 4 周工程化思想)
/**
* 按环境返回 WS 地址
* 开发:本地测试服务器;生产:走 wss + 网关域名
*/
export function wsUrl(env: "dev" | "prod" = "dev"): string {
if (env === "prod") return "wss://api.factory.example.com/sensor-feed";
return `ws://${location.hostname}:8080/sensor-feed`;
}
六、搭建本地测试服务器
本周所有实验都需要一个可控的服务器——自己搭,想怎么虐连接就怎么虐。
6.1 安装与代码
npm install ws @types/ws
/**
* 本地 WebSocket 测试服务器(Node + ws)
* 功能:接受连接、按订阅推送模拟传感器数据、支持手动踢连接(测试重连)
*/
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 8080, path: "/sensor-feed" });
console.log("WS 服务器已启动 ws://localhost:8080/sensor-feed");
wss.on("connection", (ws: WebSocket) => {
console.log("客户端接入");
// 每秒推送一帧模拟数据
const timer = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
const payload = {
type: "snapshot",
payload: {
ts: Date.now(),
devices: [
{ id: "f1", temperature: 80 + Math.random() * 10 },
{ id: "f2", temperature: 78 + Math.random() * 10 },
],
},
};
ws.send(JSON.stringify(payload));
}, 1000);
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "subscribe") {
ws.send(JSON.stringify({ type: "ack", code: 200 }));
}
});
ws.on("close", () => {
clearInterval(timer);
console.log("客户端断开");
});
// 测试用:10 秒后主动踢掉连接(模拟服务器重启,验证客户端 close code 1006 处理)
// setTimeout(() => ws.terminate(), 10_000);
});
6.2 跑通流程
npx tsx server.ts # 或编译后 node dist/server.js
浏览器里用 Day 5 节的 WsConnection 连接,Network → WS → Messages 面板看到每秒一帧的推送——恭喜,你的第一条实时数据链路通了。
七、常见坑点
坑 1:CONNECTING 状态 send 抛异常
new WebSocket() 构造函数立即返回(异步握手),onopen 之前 ws.send() 直接报 InvalidStateError。修复:send 前检查 readyState === OPEN(见 5.1 的 send 实现)。
坑 2:二进制消息收到 Blob 报错
默认 binaryType 是 "blob",new Uint8Array(ev.data) 报错。修复:构造后立即 ws.binaryType = "arraybuffer"。
坑 3:onclose 没触发就以为还连着
拔网线/中间设备静默断开 → 半开连接,任何事件都不触发。这是明天的主课题,今天先知道这个坑的存在。
坑 4:重复绑定事件
ws.onmessage = fn 是属性赋值,二次赋值覆盖前次;addEventListener 可多次绑定但组件卸载时要逐个移除。统一用 5.1 的管理器规避。
坑 5:HTTP 页面连 wss / HTTPS 页面连 ws 被拦
混合内容策略:HTTPS 页面只能连 wss。开发时用 localhost(豁免)或本地起 https。
坑 6:以为 onerror 之后不会 onclose
规范保证 error 后必触发 close。容错逻辑统一写在 onclose 里,onerror 只做日志。
八、自测挑战
T1 · 抓包验证握手(15 分钟)
用第六节的服务器 + WsConnection,在 Network 面板完整截图握手请求/响应,逐行对照 1.1 的字段。把 Sec-WebSocket-Accept 的计算规则(SHA-1(Key + 固定GUID))写进博客。
T2 · close code 全收集(25 分钟)
分别触发三种关闭:正常 close(1000)、服务器 terminate()(得 1006)、连接不存在的端口(得什么?)。把三种情况的 code、wasClean 记录成表。
T3 · 消息回声测试(20 分钟)
扩展 WsConnection:连接后每 3 秒 send 一个 {type:'ping', t: Date.now()},服务器收到原样回显,客户端算 RTT。观察 RTT 波动——这其实就是心跳的雏形(明天展开)。
T4 · 页面生命周期(20 分钟)
隐藏 Tab(visibilitychange)时暂停订阅,恢复时重新 subscribe。观察:连接在 Tab 隐藏期间是否被浏览器节流(Messages 面板帧间隔变化)。
九、总结
| 概念 | 要点 |
|---|---|
| 握手 | HTTP 101 协议升级,借壳穿代理 |
| 帧 | 2~14 字节头,消息导向,免粘包 |
| API | 四事件 + readyState 状态机 + close code 语义 |
| 有状态代价 | 断连丢消息/丢会话——本周路线图的根源 |
| WsConnection | 类型化封装,Day 58/61 的扩展地基 |
明天解决今天埋下的最大隐患:半开连接——为什么拔了网线你的页面还傻乎乎显示"连接正常",以及心跳 + 指数退避如何救场。