WebSocket 聊天室:多房间与消息广播

FreeGuideOnline 最新 2026-07-03

什么是 WebSocket 聊天室

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。与传统 HTTP 请求-响应模式不同,WebSocket 允许服务器主动向客户端推送数据,特别适合需要实时交互的应用场景,比如在线聊天室、协同编辑和游戏等。

本教程将带你从零构建一个支持多房间消息广播的 WebSocket 聊天室。你将学会:

  • 使用 Node.js 搭建 WebSocket 服务端
  • 创建房间机制,让用户加入不同聊天频道
  • 实现消息广播,将某房间的消息推送给该房间所有人
  • 编写前端页面完成实时聊天交互

工具准备

你需要安装以下环境:

  • Node.js (v14 或更高版本)
  • npm (通常随 Node.js 一起安装)
  • 现代浏览器 (Chrome / Firefox / Edge)

我们将使用原生 WebSocket API,无需额外框架。但服务端会用到 ws 这个轻量级 WebSocket 库。


搭建服务端

初始化项目并安装依赖

mkdir websocket-chat
cd websocket-chat
npm init -y
npm install ws

编写服务端代码 (server.js)

创建一个文件 server.js,以下代码实现了:

  • 管理多个房间(每个房间是一个客户端集合)
  • 处理客户端的加入房间、离开房间和发送消息请求
  • 将消息广播给同一房间内的其他人
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

// 存储房间信息: Map<房间名, Set<客户端>>
const rooms = new Map();

wss.on('connection', (ws) => {
  let currentRoom = null;

  ws.on('message', (rawData) => {
    let data;
    try {
      data = JSON.parse(rawData.toString());
    } catch (e) {
      return;
    }

    const { type, payload } = data;

    switch (type) {
      case 'join': {
        const roomName = payload.room;
        if (!roomName) return;

        // 如果之前在其他房间,先离开
        if (currentRoom && rooms.has(currentRoom)) {
          rooms.get(currentRoom).delete(ws);
          if (rooms.get(currentRoom).size === 0) {
            rooms.delete(currentRoom);
          }
          broadcast(currentRoom, {
            type: 'system',
            payload: { message: `${payload.username} 离开了房间` }
          });
        }

        // 加入新房间
        currentRoom = roomName;
        if (!rooms.has(roomName)) {
          rooms.set(roomName, new Set());
        }
        rooms.get(roomName).add(ws);

        // 将用户信息附加到 ws 对象上,方便后续使用
        ws.username = payload.username;

        // 向房间内的其他人广播加入消息(不包括自己)
        broadcast(roomName, {
          type: 'system',
          payload: { message: `${payload.username} 加入了房间` }
        }, ws);

        // 发送加入成功的确认消息给自己
        ws.send(JSON.stringify({
          type: 'join_success',
          payload: { room: roomName }
        }));
        break;
      }

      case 'message': {
        if (!currentRoom) return;
        // 向房间内所有人广播聊天消息(包括自己)
        broadcast(currentRoom, {
          type: 'chat',
          payload: {
            username: ws.username,
            text: payload.text,
            timestamp: new Date().toISOString()
          }
        });
        break;
      }

      case 'leave': {
        if (currentRoom && rooms.has(currentRoom)) {
          rooms.get(currentRoom).delete(ws);
          if (rooms.get(currentRoom).size === 0) {
            rooms.delete(currentRoom);
          }
          broadcast(currentRoom, {
            type: 'system',
            payload: { message: `${ws.username} 离开了房间` }
          });
          currentRoom = null;
        }
        break;
      }
    }
  });

  ws.on('close', () => {
    // 处理客户端意外关闭连接时的离开逻辑
    if (currentRoom && rooms.has(currentRoom)) {
      rooms.get(currentRoom).delete(ws);
      if (rooms.get(currentRoom).size === 0) {
        rooms.delete(currentRoom);
      }
      broadcast(currentRoom, {
        type: 'system',
        payload: { message: `${ws.username} 断开了连接` }
      });
    }
  });
});

/**
 * 向指定房间的所有客户端广播消息
 * @param {string} roomName 房间名
 * @param {object} message 要发送的消息对象
 * @param {WebSocket} exclude 要排除的客户端(可选)
 */
function broadcast(roomName, message, exclude = null) {
  if (!rooms.has(roomName)) return;
  const clients = rooms.get(roomName);
  const data = JSON.stringify(message);
  clients.forEach((client) => {
    if (client !== exclude && client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  });
}

console.log('WebSocket 服务器运行在 ws://localhost:8080');

关键设计说明

  • 房间数据结构:使用 Map 存储房间名到客户端 Set 的映射,方便快速增删查找。
  • 消息协议:客户端与服务端通信采用 JSON 格式,type 字段标识动作,payload 携带数据。
  • 广播函数broadcast 负责将消息发送给房间内所有有效连接,支持排除某个客户端(例如加入通知不应该发给加入者自己)。
  • 断线处理ws.on('close') 确保客户端异常断开时也能正确清理资源并通知其他用户。

编写前端页面

创建一个 index.html 文件,包含:

  • 房间选择与加入界面
  • 聊天消息显示区域
  • 消息输入框
  • 基于 WebSocket 的实时通信脚本
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WebSocket 多房间聊天室</title>
  <style>
    body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; }
    .room-controls { margin-bottom: 1rem; }
    #messages { border: 1px solid #ccc; height: 300px; overflow-y: auto; padding: 0.5rem; margin-bottom: 1rem; }
    .system-msg { color: #888; font-style: italic; }
    .chat-msg { margin: 0.3rem 0; }
    .chat-msg .user { font-weight: bold; }
    .input-area { display: flex; gap: 0.5rem; }
    #messageInput { flex: 1; padding: 0.5rem; }
  </style>
</head>
<body>
  <h1>多房间聊天室</h1>

  <div class="room-controls">
    <label>用户名:<input type="text" id="usernameInput" placeholder="输入昵称" /></label>
    <label>房间:<input type="text" id="roomInput" placeholder="房间名" /></label>
    <button id="joinBtn">加入房间</button>
    <button id="leaveBtn" disabled>离开房间</button>
  </div>

  <div id="messages"></div>

  <div class="input-area">
    <input type="text" id="messageInput" placeholder="输入消息..." disabled />
    <button id="sendBtn" disabled>发送</button>
  </div>

  <script>
    let ws = null;
    let currentRoom = null;
    const usernameInput = document.getElementById('usernameInput');
    const roomInput = document.getElementById('roomInput');
    const joinBtn = document.getElementById('joinBtn');
    const leaveBtn = document.getElementById('leaveBtn');
    const messageInput = document.getElementById('messageInput');
    const sendBtn = document.getElementById('sendBtn');
    const messagesDiv = document.getElementById('messages');

    function addMessage(type, payload) {
      const div = document.createElement('div');
      if (type === 'system') {
        div.className = 'system-msg';
        div.textContent = payload.message;
      } else if (type === 'chat') {
        div.className = 'chat-msg';
        div.innerHTML = `<span class="user">${payload.username}:</span> ${payload.text}`;
      }
      messagesDiv.appendChild(div);
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
    }

    function connectWebSocket() {
      ws = new WebSocket('ws://localhost:8080');

      ws.onopen = () => {
        console.log('WebSocket 连接已建立');
      };

      ws.onmessage = (event) => {
        const data = JSON.parse(event.data);
        switch (data.type) {
          case 'join_success':
            currentRoom = data.payload.room;
            document.getElementById('roomInput').disabled = true;
            joinBtn.disabled = true;
            leaveBtn.disabled = false;
            messageInput.disabled = false;
            sendBtn.disabled = false;
            addMessage('system', { message: `你已加入房间 ${currentRoom}` });
            break;
          case 'system':
            addMessage('system', data.payload);
            break;
          case 'chat':
            addMessage('chat', data.payload);
            break;
        }
      };

      ws.onclose = () => {
        console.log('WebSocket 连接关闭');
      };

      ws.onerror = (error) => {
        console.error('WebSocket 错误:', error);
      };
    }

    joinBtn.addEventListener('click', () => {
      const username = usernameInput.value.trim();
      const room = roomInput.value.trim();
      if (!username || !room) {
        alert('请输入用户名和房间名');
        return;
      }

      if (!ws || ws.readyState !== WebSocket.OPEN) {
        connectWebSocket();
        // 等待连接成功后再发送加入请求
        ws.addEventListener('open', () => {
          ws.send(JSON.stringify({
            type: 'join',
            payload: { username, room }
          }));
        }, { once: true });
      } else {
        ws.send(JSON.stringify({
          type: 'join',
          payload: { username, room }
        }));
      }
    });

    leaveBtn.addEventListener('click', () => {
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'leave' }));
      }
      resetUI();
    });

    sendBtn.addEventListener('click', () => {
      const text = messageInput.value.trim();
      if (text && ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({
          type: 'message',
          payload: { text }
        }));
        messageInput.value = '';
      }
    });

    messageInput.addEventListener('keypress', (e) => {
      if (e.key === 'Enter') {
        sendBtn.click();
      }
    });

    function resetUI() {
      currentRoom = null;
      document.getElementById('roomInput').disabled = false;
      joinBtn.disabled = false;
      leaveBtn.disabled = true;
      messageInput.disabled = true;
      sendBtn.disabled = true;
    }

    // 初始状态检查
    leaveBtn.disabled = true;
    messageInput.disabled = true;
    sendBtn.disabled = true;
  </script>
</body>
</html>

运行并测试多房间聊天

1. 启动服务端

在项目根目录执行:

node server.js

终端输出 WebSocket 服务器运行在 ws://localhost:8080 表示启动成功。

2. 打开前端页面

直接用浏览器打开 index.html 文件(或者通过 Live Server 等工具打开)。

3. 模拟多房间体验

  • 打开两个浏览器标签页(或两个不同浏览器)。
  • 在第一个页面输入用户名 Alice,房间 general,点击“加入房间”。
  • 在第二个页面输入用户名 Bob,房间 general,点击“加入房间”。
  • 此时双方都能看到系统消息提示加入。
  • 输入消息并发送,两人都能收到对方的聊天内容。
  • 再开第三个标签,用户名 Charlie,加入房间 random。Charlie 和 general 房间的用户互相看不见消息,因为属于不同房间。

4. 广播验证

  • Alice 发送一条消息,Bob 立即收到,而 Charlie 不会收到。
  • 离开房间或关闭标签页时,系统会广播离开提示。

扩展与优化方向

增加房间列表查看

可以在服务端维护一个房间列表的查询接口,或者通过 WebSocket 自定义消息类型 list_rooms,返回 rooms Map 的所有键。

消息持久化

目前消息只存在于内存,刷新页面后历史消息丢失。可以引入 Redis 或数据库存储房间消息,用户加入时拉取最近的历史记录。

身份认证

使用 JWT 或 session 验证用户身份,避免随意修改用户名。

前端框架集成

用 React、Vue 等框架重构前端,实现更丰富的 UI 和状态管理。

生产环境部署

  • 使用 Nginx 反向代理 WebSocket 连接
  • 开启 SSL/TLS(wss://),保证通信安全
  • 考虑负载均衡时的 WebSocket 会话保持(如 sticky session 或使用 Redis 广播)

总结

本教程通过一个完整示例展示了如何用原生 WebSocket 实现多房间聊天室,重点解决了房间管理消息广播两个核心难题。你学到的不只是调用 API,更是实时通信系统的典型架构设计。动手实践后,你可以轻松将其改造为各类实时协作应用的基础。