【WebSocket+MQTT】day57-websocket-basics

作者:mario 发布时间: 2026-09-03 阅读量:4 评论数:0

Day 57 · WebSocket 协议与 API — 借 HTTP 的壳,干全双工的活

打开一个 WebSocket 连接,浏览器发出去的第一个请求长得完全像 HTTP 请求——这是协议设计史上最优雅的兼容方案之一:借 HTTP 的 101 状态码完成"协议升级",之后这条 TCP 连接就不再是 HTTP 了。今天从握手开始,把 WebSocket 的协议机制和浏览器 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() 的理由:

  1. 原生 API 事件不可重复绑定、无类型友好的消息分发
  2. 服务器地址要按环境切换(开发/生产)
  3. 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 的扩展地基

明天解决今天埋下的最大隐患:半开连接——为什么拔了网线你的页面还傻乎乎显示"连接正常",以及心跳 + 指数退避如何救场。

评论