WebSocket实时通信:构建即时聊天应用完整教程

为什么需要 WebSocket

传统 HTTP 是“请求-响应”模型:客户端问,服务器答,连接就结束。服务器无法主动给客户端推消息。在即时聊天、实时协作、股票行情、在线游戏这类场景,如果用 HTTP 轮询,要么延迟高、要么浪费带宽。

WebSocket 解决了这个问题:建立一次 TCP 连接后,服务器和客户端可以随时双向收发数据,且帧头开销极小(2-10 字节),非常适合高频小消息。

WebSocket 协议速览

WebSocket 连接建立的过程是“HTTP 升级”:

  • 客户端发起一个带 Upgrade: websocket 头的 HTTP 请求。

  • 服务器返回 101 Switching Protocols

  • 之后这条 TCP 连接就切换为 WebSocket 协议,双方可随时发帧。
  • text
    GET /chat HTTP/1.1
    Host: server.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Sec-WebSocket-Version: 13

    数据以“帧”传输,有文本帧、二进制帧、ping/pong 心跳帧、关闭帧。

    服务端:用 ws 库搭建

    Node.js 生态里,ws 是最轻量高性能的 WebSocket 库,Socket.IO 则在它之上加了房间、自动重连、降级等功能。这里先用原生 ws 理解底层。

    javascript
    const { WebSocketServer } = require("ws");
    
    const wss = new WebSocketServer({ port: 8080 });
    
    // 房间模型:用 Map 维护房间名 -> 客户端集合
    const rooms = new Map();
    
    function joinRoom(ws, room) {
      if (!rooms.has(room)) rooms.set(room, new Set());
      rooms.get(room).add(ws);
      ws.room = room;
    }
    
    function broadcast(room, message, sender) {
      const members = rooms.get(room);
      if (!members) return;
      const payload = JSON.stringify(message);
      for (const client of members) {
        if (client !== sender && client.readyState === ws.OPEN) {
          client.send(payload);
        }
      }
    }
    
    wss.on("connection", (ws, req) => {
      ws.isAlive = true;
    
      ws.on("pong", () => { ws.isAlive = true; });
    
      ws.on("message", (raw) => {
        const msg = JSON.parse(raw.toString());
        if (msg.type === "join") {
          joinRoom(ws, msg.room);
          broadcast(msg.room, { type: "system", text: "有人加入了房间" }, ws);
        } else if (msg.type === "chat") {
          broadcast(ws.room, { type: "chat", user: msg.user, text: msg.text }, ws);
        }
      });
    
      ws.on("close", () => {
        if (ws.room && rooms.has(ws.room)) {
          rooms.get(ws.room).delete(ws);
        }
      });
    });

    这段代码实现了:加入房间、群内广播、断开清理。核心是用内存 Map 维护“房间->连接集合”的映射。

    心跳检测:清理死连接

    TCP 连接可能因为网络异常而“半开”——客户端已断,服务器却不知道,连接对象一直占内存。必须靠心跳定期探测。

    javascript
    // 每 30 秒检查一次所有连接
    const interval = setInterval(() => {
      wss.clients.forEach((ws) => {
        if (!ws.isAlive) return ws.terminate();
        ws.isAlive = false;
        ws.ping(); // 客户端收到 ping 会自动回 pong
      });
    }, 30000);
    
    wss.on("close", () => clearInterval(interval));

    ws 库在收到对端 pong 时会触发 pong 事件,我们据此把 isAlive 重置为 true。一轮检查后仍为 false 的连接,说明对端无响应,直接 terminate。

    客户端实现

    浏览器原生支持 WebSocket:

    javascript
    const ws = new WebSocket("ws://localhost:8080/chat");
    const messages = document.getElementById("messages");
    
    ws.onopen = () => {
      ws.send(JSON.stringify({ type: "join", room: "room1", user: "Tom" }));
    };
    
    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      const el = document.createElement("div");
      el.textContent = msg.user ? `${msg.user}: ${msg.text}` : msg.text;
      messages.appendChild(el);
    };
    
    function send() {
      const input = document.getElementById("input");
      ws.send(JSON.stringify({ type: "chat", user: "Tom", text: input.value }));
      input.value = "";
    }

    自动重连

    网络抖动会导致断连,客户端必须有重连机制,并配合指数退避避免雪崩。

    javascript
    class ReconnectingWebSocket {
      constructor(url) {
        this.url = url;
        this.retry = 0;
        this.connect();
      }
    
      connect() {
        this.ws = new WebSocket(this.url);
    
        this.ws.onopen = () => {
          this.retry = 0;
          console.log("已连接");
        };
    
        this.ws.onclose = () => {
          const delay = Math.min(1000 * 2 ** this.retry, 30000);
          this.retry++;
          console.log(`${delay}ms 后重连...`);
          setTimeout(() => this.connect(), delay);
        };
    
        this.ws.onmessage = (e) => this.onmessage && this.onmessage(e);
      }
    
      send(data) {
        if (this.ws.readyState === WebSocket.OPEN) {
          this.ws.send(data);
        }
      }
    }

    用 Socket.IO 简化开发

    如果不想手写房间、广播、重连,Socket.IO 把这些都封装好了:

    javascript
    // 服务端
    const io = require("socket.io")(3000, { cors: { origin: "*" } });
    
    io.on("connection", (socket) => {
      socket.on("join", (room) => {
        socket.join(room);
        socket.to(room).emit("message", "有人加入了房间");
      });
    
      socket.on("chat", ({ room, user, text }) => {
        io.to(room).emit("message", { user, text });
      });
    });
    
    // 客户端
    const socket = io("http://localhost:3000");
    socket.emit("join", "room1");
    socket.on("message", (msg) => console.log(msg));

    socket.to(room).emit 广播给房间内除自己外的所有人,io.to(room).emit 广播给房间所有人。Socket.IO 还自带断线重连、消息缓冲、ACK 机制。

    生产化要点


  • 横向扩展:单机 WebSocket 连接数有上限(通常几万到十几万)。多实例时用 Redis Pub/Sub 做跨实例广播。

  • 反向代理:Nginx 需开启 WebSocket 支持,设置 UpgradeConnection 头透传。

  • 鉴权:在连接建立时(握手阶段)通过 query 参数或子协议校验 token。

  • 消息可靠性:WebSocket 不保证消息不丢。关键消息要带 ID + ACK 确认。

  • 背压控制:发送速率超过对端处理速率时,要暂停读取或丢弃,避免内存撑爆。
  • nginx
    # Nginx WebSocket 代理配置
    location /chat {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400;
    }

    proxy_read_timeout 必须调大,否则 Nginx 默认 60 秒无数据就会断开长连接。

    跨实例广播:Redis 适配器

    javascript
    const { createAdapter } = require("@socket.io/redis-adapter");
    const { createClient } = require("redis");
    
    const pubClient = createClient({ url: "redis://localhost:6379" });
    const subClient = pubClient.duplicate();
    await Promise.all([pubClient.connect(), subClient.connect()]);
    
    io.adapter(createAdapter(pubClient, subClient));

    接入 Redis 适配器后,io.to(room).emit 会通过 Redis Pub/Sub 同步到所有实例,任一实例上的客户端都能收到,从而实现水平扩展。

    小结

    WebSocket 用一次握手换取全双工长连接,是实时通信的基石。构建聊天应用的核心要素:房间模型(Map 维护连接集合)、心跳清理死连接、广播与私聊、客户端自动重连。小项目用 ws 足够,复杂业务用 Socket.IO 省心,多实例扩展时接 Redis 适配器。生产环境别忘了鉴权、背压和消息可靠性。理解了这些,你就掌握了实时应用的基础设施,无论是聊天、协作还是推送,都游刃有余。

    💬 评论区 (0)

    暂无评论,快来抢沙发吧!