{T}

实时聊天应用

基于 WebSocket 的实时聊天应用完整示例,支持群聊、私聊、在线状态等核心功能。

项目概述

功能特性

功能描述状态
用户登录/退出支持用户昵称登录,自动广播用户状态变化
实时消息收发基于 WebSocket 的实时双向通信
在线用户列表实时显示当前在线用户
私聊功能支持点对点私密消息
正在输入提示实时显示用户输入状态
消息历史记录内存存储最近消息记录
聊天室多房间支持🔄 扩展功能
消息持久化数据库存储🔄 扩展功能
文件传输图片/文件发送🔄 扩展功能
已读回执消息已读状态🔄 扩展功能

技术栈

  • 后端: Node.js + Express + Socket.io
  • 前端: 原生 HTML5 + CSS3 + JavaScript
  • 通信协议: WebSocket (Socket.io)
  • 存储: 内存存储(可扩展为 MongoDB/Redis)

系统架构

架构图

code
┌─────────────────────────────────────────────────────────────────┐
│                         客户端层 (Client)                        │
├─────────────────────────────────────────────────────────────────┤
│  ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐     │
│  │ Browser  │   │ Browser  │   │ Browser  │   │ Browser  │     │
│  │ Client 1 │   │ Client 2 │   │ Client 3 │   │ Client N │     │
│  └────┬─────┘   └────┬─────┘   └────┬─────┘   └────┬─────┘     │
└───────┼──────────────┼──────────────┼──────────────┼───────────┘
        │              │              │              │
        └──────────────┴──────────────┴──────────────┘
                              │
                    WebSocket 连接 (Socket.io)
                              │
┌─────────────────────────────┴───────────────────────────────────┐
│                      服务器层 (Server)                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    Express HTTP Server                    │  │
│  │  - 静态文件服务                                            │  │
│  │  - CORS 配置                                              │  │
│  └──────────────────────────────────────────────────────────┘  │
│                              │                                   │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                   Socket.io Server                        │  │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐       │  │
│  │  │  连接管理    │  │  事件处理    │  │  消息路由    │       │  │
│  │  │ Connection  │  │   Events    │  │   Routing   │       │  │
│  │  └─────────────┘  └─────────────┘  └─────────────┘       │  │
│  └──────────────────────────────────────────────────────────┘  │
│                              │                                   │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ 用户管理模块  │  │ 消息处理模块  │  │ 私聊处理模块  │          │
│  │   Users      │  │   Message    │  │   Private    │          │
│  │   Map        │  │   Model      │  │   Handler    │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────┴───────────────────────────────────┐
│                      数据层 (Data)                               │
├─────────────────────────────────────────────────────────────────┤
│  ┌──────────────────────────────────────────────────────────┐  │
│  │              内存存储 (可扩展为数据库)                      │  │
│  │  - 用户在线状态: Map<socketId, username>                  │  │
│  │  - 消息历史: Array<Message>                               │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                  │
│  可扩展存储:                                                     │
│  ┌────────────┐  ┌────────────┐  ┌────────────┐                │
│  │  MongoDB   │  │   Redis    │  │   MySQL    │                │
│  │  持久化存储  │  │  缓存/会话  │  │  关系存储   │                │
│  └────────────┘  └────────────┘  └────────────┘                │
└──────────────────────────────────────────────────────────────────┘

数据流图

code
用户 A 发送消息流程:

┌─────────┐         ┌─────────┐         ┌─────────┐
│ User A  │         │ Server  │         │ User B  │
└────┬────┘         └────┬────┘         └────┬────┘
     │                   │                   │
     │  1. join事件      │                   │
     │ ─────────────────>│                   │
     │                   │  2. 广播用户加入   │
     │                   │──────────────────>│
     │                   │                   │
     │  3. chat事件      │                   │
     │ ─────────────────>│                   │
     │                   │  4. 保存消息       │
     │                   │  5. 广播消息       │
     │                   │──────────────────>│
     │                   │                   │
     │                   │  6. typing提示    │
     │                   │<──────────────────│
     │                   │                   │
     │  7. private事件   │                   │
     │ ─────────────────>│                   │
     │                   │  8. 私聊消息       │
     │                   │──────────────────>│
     │                   │                   │
     │  9. disconnect    │                   │
     │ ─────────────────>│                   │
     │                   │  10. 广播用户离开  │
     │                   │──────────────────>│
     │                   │                   │

核心功能模块

1. 用户管理模块

负责管理在线用户的状态和映射关系。

javascript
// 用户数据结构
users: Map<socketId, username>

// 核心操作
- addUser(socketId, username)    // 添加用户
- removeUser(socketId)           // 移除用户
- getUser(socketId)              // 获取用户
- getAllUsers()                  // 获取所有在线用户

2. 消息处理模块

处理消息的接收、存储和分发。

消息类型

类型说明格式
normal普通群聊消息{ username, content, time }
system系统通知消息{ type: 'system', content, time }
private私聊消息{ type: 'private', from, to, content, time }

3. 连接管理模块

处理 WebSocket 连接的生命周期。

code
连接流程:
1. 客户端建立 WebSocket 连接
2. 触发 'connection' 事件
3. 用户发送 'join' 事件注册
4. 正常通信阶段
5. 断开连接触发 'disconnect' 事件
6. 清理用户状态

4. 私聊处理模块

实现点对点私密消息传递。

javascript
// 私聊消息路由算法
function sendPrivateMessage(from, to, content) {
  // 1. 查找目标用户 socket
  for (const [socketId, username] of users) {
    if (username === to) {
      // 2. 发送给目标用户
      io.to(socketId).emit('message', message);
      // 3. 同时发送给发送者(用于同步)
      socket.emit('message', message);
      break;
    }
  }
}

API 接口说明

Socket.io 事件

客户端 → 服务器事件

事件名参数说明示例
joinusername: string用户加入聊天室socket.emit('join', '张三')
chat{ content: string }发送群聊消息socket.emit('chat', { content: '你好' })
private{ to: string, content: string }发送私聊消息socket.emit('private', { to: '李四', content: '私密消息' })
typing触发正在输入提示socket.emit('typing')
disconnect断开连接(自动触发)-

服务器 → 客户端事件

事件名数据结构说明
messageMessage接收新消息
usersstring[]在线用户列表更新
typingusername: string用户正在输入提示

数据结构定义

typescript
// 消息结构
interface Message {
  username?: string;      // 发送者用户名(群聊/私聊)
  content: string;        // 消息内容
  time: string;          // ISO 8601 时间戳
  type?: 'system' | 'private';  // 消息类型
  from?: string;         // 发送者(私聊)
  to?: string;           // 接收者(私聊)
}

// 用户信息
interface UserInfo {
  socketId: string;      // Socket 连接 ID
  username: string;      // 用户昵称
}

RESTful API(可扩展)

如需扩展 REST API,可添加以下接口:

code
GET  /api/messages          # 获取消息历史
GET  /api/messages/:user    # 获取用户消息
GET  /api/users             # 获取在线用户
POST /api/auth/login        # 用户登录
POST /api/auth/logout       # 用户登出

配置参数

服务器配置

javascript
// server/app.js 配置项
const config = {
  // 服务器端口
  port: process.env.PORT || 3000,
  
  // Socket.io 配置
  socket: {
    cors: {
      origin: '*',                    // 允许的源
      methods: ['GET', 'POST']        // 允许的方法
    },
    pingTimeout: 60000,               // 心跳超时(毫秒)
    pingInterval: 25000,              // 心跳间隔(毫秒)
    maxHttpBufferSize: 1e7           // 最大消息大小(10MB)
  },
  
  // 静态文件目录
  staticDir: 'public'
};

环境变量

创建 .env 文件配置环境变量:

bash
# 服务器配置
PORT=3000
NODE_ENV=development

# 数据库配置(扩展功能)
MONGODB_URI=mongodb://localhost:27017/chat
REDIS_URL=redis://localhost:6379

# JWT 配置(扩展功能)
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=7d

客户端配置

javascript
// public/client.js 配置
const config = {
  // Socket.io 连接选项
  socketOptions: {
    reconnection: true,              // 自动重连
    reconnectionAttempts: 10,        // 重连尝试次数
    reconnectionDelay: 1000,         // 重连延迟(毫秒)
    reconnectionDelayMax: 5000,      // 最大重连延迟
    timeout: 20000                   // 连接超时
  },
  
  // 输入防抖延迟
  typingDebounce: 1000               // 毫秒
};

项目结构

code
chat-app/
├── server/
│   ├── controllers/
│   │   └── chatController.js      # 聊天控制器(可扩展)
│   ├── models/
│   │   └── Message.js             # 消息模型
│   ├── sockets/
│   │   └── chatSocket.js          # Socket 事件处理
│   ├── middleware/
│   │   └── auth.js                # 认证中间件(可扩展)
│   └── app.js                     # 应用入口
├── public/
│   ├── index.html                 # 主页面
│   ├── style.css                  # 样式文件
│   └── client.js                  # 客户端脚本
├── package.json                   # 项目配置
├── .env                           # 环境变量(需创建)
└── README.md                      # 项目文档

快速开始

安装依赖

bash
# 创建项目目录
mkdir chat-app && cd chat-app

# 初始化项目
npm init -y

# 安装依赖
npm install express socket.io

# 开发依赖(可选)
npm install --save-dev nodemon dotenv

运行项目

bash
# 开发环境
npm run dev

# 生产环境
npm start

package.json 配置

json
{
  "name": "chat-app",
  "version": "1.0.0",
  "description": "实时聊天应用",
  "main": "server/app.js",
  "scripts": {
    "start": "node server/app.js",
    "dev": "nodemon server/app.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "socket.io": "^4.7.2"
  },
  "devDependencies": {
    "nodemon": "^3.0.1",
    "dotenv": "^16.3.1"
  }
}

访问应用

启动服务器后,打开浏览器访问:

code
http://localhost:3000

实现代码

server/app.js

javascript
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const chatSocket = require('./sockets/chatSocket');

const app = express();
const server = http.createServer(app);

// Socket.io 配置
const io = new Server(server, {
  cors: {
    origin: process.env.CORS_ORIGIN || '*',
    methods: ['GET', 'POST']
  },
  pingTimeout: 60000,
  pingInterval: 25000
});

// 静态文件服务
app.use(express.static('public'));

// Socket.io 连接处理
chatSocket(io);

// 健康检查端点
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`聊天服务器运行在 http://localhost:${PORT}`);
});

server/sockets/chatSocket.js

javascript
const Message = require('../models/Message');

module.exports = (io) => {
  // 在线用户映射: socketId -> username
  const users = new Map();

  io.on('connection', (socket) => {
    console.log(`用户连接: ${socket.id}`);

    // 用户加入
    socket.on('join', (username) => {
      // 验证用户名
      if (!username || typeof username !== 'string') {
        return socket.emit('error', { message: '无效的用户名' });
      }

      // 检查用户名是否已存在
      for (const [_, name] of users) {
        if (name === username) {
          return socket.emit('error', { message: '用户名已被占用' });
        }
      }

      users.set(socket.id, username);
      
      // 广播用户加入消息
      socket.broadcast.emit('message', {
        type: 'system',
        content: `${username} 加入了聊天室`,
        time: new Date().toISOString()
      });

      // 发送在线用户列表
      io.emit('users', Array.from(users.values()));
      
      // 发送历史消息给新用户
      const history = Message.getHistory(50);
      history.forEach(msg => socket.emit('message', msg));
    });

    // 接收并发送群聊消息
    socket.on('chat', async (data) => {
      const username = users.get(socket.id);
      if (!username) {
        return socket.emit('error', { message: '请先登录' });
      }

      if (!data.content || typeof data.content !== 'string') {
        return socket.emit('error', { message: '消息内容无效' });
      }

      const message = {
        username,
        content: data.content.trim(),
        time: new Date().toISOString()
      };

      // 保存消息
      Message.save(message);

      // 广播消息给所有用户
      io.emit('message', message);
    });

    // 私聊消息
    socket.on('private', async (data) => {
      const from = users.get(socket.id);
      if (!from) {
        return socket.emit('error', { message: '请先登录' });
      }

      const { to, content } = data;
      if (!to || !content) {
        return socket.emit('error', { message: '参数不完整' });
      }

      const message = {
        from,
        to,
        content: content.trim(),
        type: 'private',
        time: new Date().toISOString()
      };

      // 找到目标用户的 socket
      let found = false;
      for (const [socketId, username] of users) {
        if (username === to) {
          io.to(socketId).emit('message', message);
          socket.emit('message', message);
          found = true;
          break;
        }
      }

      if (!found) {
        socket.emit('error', { message: `用户 ${to} 不在线` });
      }
    });

    // 正在输入提示
    let typingUsers = new Set();
    socket.on('typing', () => {
      const username = users.get(socket.id);
      if (username) {
        typingUsers.add(username);
        socket.broadcast.emit('typing', username);
        
        // 1秒后清除提示
        setTimeout(() => {
          typingUsers.delete(username);
        }, 1000);
      }
    });

    // 用户断开连接
    socket.on('disconnect', () => {
      const username = users.get(socket.id);
      if (username) {
        users.delete(socket.id);

        // 广播用户离开消息
        socket.broadcast.emit('message', {
          type: 'system',
          content: `${username} 离开了聊天室`,
          time: new Date().toISOString()
        });

        // 更新在线用户列表
        io.emit('users', Array.from(users.values()));
      }
    });
  });
};

server/models/Message.js

javascript
const messages = [];
const MAX_MESSAGES = 1000; // 最大存储消息数

module.exports = {
  /**
   * 保存消息
   * @param {Object} message - 消息对象
   * @returns {Object} 保存的消息
   */
  save(message) {
    messages.push(message);
    
    // 限制消息数量
    if (messages.length > MAX_MESSAGES) {
      messages.shift();
    }
    
    return message;
  },

  /**
   * 获取历史消息
   * @param {number} limit - 返回消息数量
   * @returns {Array} 消息数组
   */
  getHistory(limit = 50) {
    return messages.slice(-limit);
  },

  /**
   * 按用户获取消息
   * @param {string} username - 用户名
   * @returns {Array} 消息数组
   */
  getByUser(username) {
    return messages.filter(m => m.username === username);
  },

  /**
   * 清空消息
   */
  clear() {
    messages.length = 0;
  },

  /**
   * 获取消息总数
   * @returns {number}
   */
  count() {
    return messages.length;
  }
};

public/index.html

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>实时聊天室</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="chat-container">
    <!-- 侧边栏 -->
    <div class="sidebar">
      <h3>在线用户 <span id="user-count">(0)</span></h3>
      <ul id="user-list"></ul>
    </div>
    
    <!-- 主聊天区域 -->
    <div class="chat-main">
      <div id="messages" class="messages"></div>
      
      <!-- 输入区域 -->
      <div class="input-area">
        <input 
          type="text" 
          id="message-input" 
          placeholder="输入消息... (按 Enter 发送)"
          autocomplete="off"
        >
        <button id="send-btn">发送</button>
      </div>
      
      <!-- 正在输入提示 -->
      <div id="typing-indicator" class="typing-indicator"></div>
    </div>
  </div>

  <!-- 登录弹窗 -->
  <div id="login-modal" class="modal">
    <div class="modal-content">
      <h2>加入聊天室</h2>
      <input 
        type="text" 
        id="username-input" 
        placeholder="输入你的昵称"
        maxlength="20"
        autofocus
      >
      <button id="join-btn">加入</button>
      <p class="hint">昵称长度 1-20 个字符</p>
    </div>
  </div>

  <!-- 错误提示 -->
  <div id="error-toast" class="toast"></div>

  <script src="/socket.io/socket.io.js"></script>
  <script src="client.js"></script>
</body>
</html>

public/client.js

javascript
const socket = io({
  reconnection: true,
  reconnectionAttempts: 10,
  reconnectionDelay: 1000
});

let username = '';

// DOM 元素
const elements = {
  loginModal: document.getElementById('login-modal'),
  usernameInput: document.getElementById('username-input'),
  joinBtn: document.getElementById('join-btn'),
  messageInput: document.getElementById('message-input'),
  sendBtn: document.getElementById('send-btn'),
  messagesDiv: document.getElementById('messages'),
  userList: document.getElementById('user-list'),
  userCount: document.getElementById('user-count'),
  typingIndicator: document.getElementById('typing-indicator'),
  errorToast: document.getElementById('error-toast')
};

// ========== 事件处理 ==========

// 加入聊天室
elements.joinBtn.addEventListener('click', joinChat);
elements.usernameInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') joinChat();
});

function joinChat() {
  username = elements.usernameInput.value.trim();
  
  if (!username) {
    showError('请输入昵称');
    return;
  }
  
  if (username.length > 20) {
    showError('昵称不能超过 20 个字符');
    return;
  }
  
  socket.emit('join', username);
  elements.loginModal.style.display = 'none';
  elements.messageInput.focus();
}

// 发送消息
elements.sendBtn.addEventListener('click', sendMessage);
elements.messageInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault();
    sendMessage();
  }
});

function sendMessage() {
  const content = elements.messageInput.value.trim();
  
  if (!content) return;
  
  if (content.length > 500) {
    showError('消息不能超过 500 个字符');
    return;
  }
  
  socket.emit('chat', { content });
  elements.messageInput.value = '';
}

// 正在输入提示(防抖)
let typingTimeout;
elements.messageInput.addEventListener('input', () => {
  socket.emit('typing');
  clearTimeout(typingTimeout);
  typingTimeout = setTimeout(() => {}, 1000);
});

// ========== Socket 事件监听 ==========

// 接收消息
socket.on('message', (message) => {
  appendMessage(message);
});

// 更新在线用户列表
socket.on('users', (users) => {
  elements.userCount.textContent = `(${users.length})`;
  elements.userList.innerHTML = users
    .map(user => `<li class="${user === username ? 'me' : ''}">${user}</li>`)
    .join('');
});

// 正在输入提示
socket.on('typing', (user) => {
  if (user !== username) {
    elements.typingIndicator.textContent = `${user} 正在输入...`;
    setTimeout(() => {
      elements.typingIndicator.textContent = '';
    }, 1000);
  }
});

// 错误处理
socket.on('error', (error) => {
  showError(error.message);
});

// 连接状态
socket.on('connect', () => {
  console.log('已连接到服务器');
});

socket.on('disconnect', () => {
  showError('与服务器断开连接,正在重连...');
});

socket.on('reconnect', () => {
  showError('已重新连接');
  if (username) {
    socket.emit('join', username);
  }
});

// ========== 辅助函数 ==========

function appendMessage(message) {
  const div = document.createElement('div');
  div.className = `message ${message.type || ''}`;
  
  if (message.type === 'system') {
    div.innerHTML = `<span class="system-text">${escapeHtml(message.content)}</span>`;
  } else if (message.type === 'private') {
    div.innerHTML = `
      <span class="username">[私聊] ${escapeHtml(message.from)} → ${escapeHtml(message.to)}</span>
      <span class="content">${escapeHtml(message.content)}</span>
      <span class="time">${formatTime(message.time)}</span>
    `;
  } else {
    const isMe = message.username === username;
    div.classList.add(isMe ? 'me' : 'other');
    div.innerHTML = `
      <span class="username">${escapeHtml(message.username)}</span>
      <span class="content">${escapeHtml(message.content)}</span>
      <span class="time">${formatTime(message.time)}</span>
    `;
  }
  
  elements.messagesDiv.appendChild(div);
  scrollToBottom();
}

function scrollToBottom() {
  elements.messagesDiv.scrollTop = elements.messagesDiv.scrollHeight;
}

function formatTime(isoString) {
  return new Date(isoString).toLocaleTimeString('zh-CN', {
    hour: '2-digit',
    minute: '2-digit'
  });
}

function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

function showError(message) {
  elements.errorToast.textContent = message;
  elements.errorToast.classList.add('show');
  setTimeout(() => {
    elements.errorToast.classList.remove('show');
  }, 3000);
}

public/style.css

css
/* ========== 基础样式 ========== */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  background: #f0f2f5;
  height: 100vh;
  overflow: hidden;
}

/* ========== 布局 ========== */
.chat-container {
  display: flex;
  height: 100vh;
  max-width: 1400px;
  margin: 0 auto;
  box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}

/* ========== 侧边栏 ========== */
.sidebar {
  width: 220px;
  background: linear-gradient(180deg, #2c3e50 0%, #34495e 100%);
  color: white;
  padding: 20px;
  display: flex;
  flex-direction: column;
}

.sidebar h3 {
  margin-bottom: 15px;
  padding-bottom: 10px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.2);
  font-size: 16px;
}

#user-count {
  font-size: 14px;
  opacity: 0.8;
}

#user-list {
  list-style: none;
  overflow-y: auto;
  flex: 1;
}

#user-list li {
  padding: 10px 12px;
  margin: 5px 0;
  border-radius: 6px;
  background: rgba(255, 255, 255, 0.1);
  transition: all 0.2s;
}

#user-list li:hover {
  background: rgba(255, 255, 255, 0.2);
}

#user-list li.me {
  background: rgba(52, 152, 219, 0.3);
  font-weight: 500;
}

/* ========== 主聊天区域 ========== */
.chat-main {
  flex: 1;
  display: flex;
  flex-direction: column;
  background: white;
  position: relative;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background: #fafafa;
}

.messages::-webkit-scrollbar {
  width: 6px;
}

.messages::-webkit-scrollbar-thumb {
  background: #ccc;
  border-radius: 3px;
}

/* ========== 消息样式 ========== */
.message {
  margin-bottom: 16px;
  padding: 12px 16px;
  border-radius: 12px;
  max-width: 70%;
  word-wrap: break-word;
  animation: fadeIn 0.3s ease;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.message.me {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  margin-left: auto;
  border-bottom-right-radius: 4px;
}

.message.other {
  background: white;
  border: 1px solid #e8e8e8;
  border-bottom-left-radius: 4px;
}

.message .username {
  font-weight: 600;
  font-size: 13px;
  color: #666;
  display: block;
  margin-bottom: 6px;
}

.message.me .username {
  color: rgba(255, 255, 255, 0.9);
}

.message .content {
  font-size: 15px;
  line-height: 1.5;
  display: block;
}

.message .time {
  font-size: 11px;
  color: #999;
  margin-top: 6px;
  display: block;
}

.message.me .time {
  color: rgba(255, 255, 255, 0.7);
}

.message.system {
  text-align: center;
  background: none;
  max-width: 100%;
  padding: 8px 0;
}

.message .system-text {
  font-size: 13px;
  color: #999;
  background: #f5f5f5;
  padding: 4px 12px;
  border-radius: 12px;
}

/* ========== 输入区域 ========== */
.input-area {
  display: flex;
  padding: 15px 20px;
  border-top: 1px solid #eee;
  background: white;
  gap: 10px;
}

.input-area input {
  flex: 1;
  padding: 12px 16px;
  border: 2px solid #e8e8e8;
  border-radius: 24px;
  outline: none;
  font-size: 15px;
  transition: border-color 0.2s;
}

.input-area input:focus {
  border-color: #667eea;
}

.input-area button {
  padding: 12px 28px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  border-radius: 24px;
  cursor: pointer;
  font-size: 15px;
  font-weight: 500;
  transition: transform 0.2s, box-shadow 0.2s;
}

.input-area button:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}

.input-area button:active {
  transform: translateY(0);
}

/* ========== 正在输入提示 ========== */
.typing-indicator {
  position: absolute;
  bottom: 70px;
  left: 20px;
  font-size: 13px;
  color: #999;
  padding: 4px 12px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

/* ========== 登录弹窗 ========== */
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  backdrop-filter: blur(4px);
}

.modal-content {
  background: white;
  padding: 40px;
  border-radius: 16px;
  text-align: center;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
  min-width: 320px;
}

.modal-content h2 {
  margin-bottom: 24px;
  color: #333;
  font-size: 24px;
}

.modal-content input {
  width: 100%;
  padding: 14px 18px;
  border: 2px solid #e8e8e8;
  border-radius: 8px;
  font-size: 16px;
  margin-bottom: 12px;
  outline: none;
  transition: border-color 0.2s;
}

.modal-content input:focus {
  border-color: #667eea;
}

.modal-content button {
  width: 100%;
  padding: 14px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-size: 16px;
  font-weight: 500;
  transition: transform 0.2s;
}

.modal-content button:hover {
  transform: translateY(-2px);
}

.modal-content .hint {
  margin-top: 12px;
  font-size: 13px;
  color: #999;
}

/* ========== 错误提示 ========== */
.toast {
  position: fixed;
  top: 20px;
  left: 50%;
  transform: translateX(-50%) translateY(-100px);
  background: rgba(0, 0, 0, 0.8);
  color: white;
  padding: 12px 24px;
  border-radius: 8px;
  font-size: 14px;
  transition: transform 0.3s;
  z-index: 2000;
}

.toast.show {
  transform: translateX(-50%) translateY(0);
}

/* ========== 响应式设计 ========== */
@media (max-width: 768px) {
  .sidebar {
    display: none;
  }
  
  .message {
    max-width: 85%;
  }
  
  .modal-content {
    min-width: 280px;
    padding: 30px 20px;
  }
}

部署说明

生产环境配置

1. 使用 PM2 进程管理

bash
# 安装 PM2
npm install -g pm2

# 启动应用
pm2 start server/app.js --name chat-app

# 查看状态
pm2 status

# 查看日志
pm2 logs chat-app

# 设置开机自启
pm2 startup
pm2 save

2. Nginx 反向代理

nginx
# /etc/nginx/sites-available/chat-app
server {
    listen 80;
    server_name your-domain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /socket.io/ {
        proxy_pass http://localhost:3000/socket.io/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

3. Docker 部署

dockerfile
# Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install --production

COPY . .

EXPOSE 3000

CMD ["node", "server/app.js"]
bash
# 构建镜像
docker build -t chat-app:1.0 .

# 运行容器
docker run -d -p 3000:3000 --name chat-app chat-app:1.0
yaml
# docker-compose.yml
version: '3.8'

services:
  chat-app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
    restart: unless-stopped

安全性考虑

1. 输入验证

javascript
// 验证用户名
function validateUsername(username) {
  if (!username || typeof username !== 'string') {
    return { valid: false, error: '无效的用户名' };
  }
  
  if (username.length < 1 || username.length > 20) {
    return { valid: false, error: '用户名长度应为 1-20 个字符' };
  }
  
  // 防止 XSS
  const sanitized = username.replace(/[<>]/g, '');
  return { valid: true, username: sanitized };
}

// 验证消息内容
function validateContent(content) {
  if (!content || typeof content !== 'string') {
    return { valid: false, error: '无效的消息内容' };
  }
  
  if (content.length > 500) {
    return { valid: false, error: '消息不能超过 500 个字符' };
  }
  
  return { valid: true, content };
}

2. 速率限制

javascript
// 消息速率限制
const rateLimit = require('express-rate-limit');
const messageLimiter = new Map();

function checkRateLimit(socketId, limit = 10, windowMs = 60000) {
  const now = Date.now();
  const userLimit = messageLimiter.get(socketId) || { count: 0, resetAt: now + windowMs };
  
  if (now > userLimit.resetAt) {
    userLimit.count = 0;
    userLimit.resetAt = now + windowMs;
  }
  
  if (userLimit.count >= limit) {
    return false;
  }
  
  userLimit.count++;
  messageLimiter.set(socketId, userLimit);
  return true;
}

3. CORS 配置

javascript
// 生产环境 CORS 配置
const io = new Server(server, {
  cors: {
    origin: process.env.ALLOWED_ORIGINS?.split(',') || ['https://your-domain.com'],
    methods: ['GET', 'POST'],
    credentials: true
  }
});

4. 认证扩展(JWT)

javascript
// middleware/auth.js
const jwt = require('jsonwebtoken');

function authenticateSocket(socket, next) {
  const token = socket.handshake.auth.token;
  
  if (!token) {
    return next(new Error('未提供认证令牌'));
  }
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    socket.userId = decoded.userId;
    next();
  } catch (error) {
    next(new Error('无效的认证令牌'));
  }
}

性能优化

1. 消息压缩

javascript
const io = new Server(server, {
  // 启用 WebSocket 压缩
  perMessageDeflate: {
    threshold: 1024,  // 超过 1KB 的消息启用压缩
    zlibDeflateOptions: {
      level: 3
    }
  }
});

2. 房间隔离

javascript
// 使用房间隔离不同聊天室
socket.on('join-room', (roomId) => {
  socket.join(roomId);
  io.to(roomId).emit('message', {
    type: 'system',
    content: `${username} 加入了房间`
  });
});

// 发送房间消息
socket.on('room-chat', (data) => {
  io.to(data.roomId).emit('message', {
    username,
    content: data.content,
    time: new Date().toISOString()
  });
});

3. Redis 适配器(集群支持)

javascript
// 安装: npm install @socket.io/redis-adapter
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

io.adapter(createAdapter(pubClient, subClient));

4. 消息队列处理高并发

javascript
// 使用 Bull 队列处理消息
const Queue = require('bull');
const messageQueue = new Queue('message-queue', process.env.REDIS_URL);

// 生产者
socket.on('chat', async (data) => {
  await messageQueue.add({
    username: users.get(socket.id),
    content: data.content
  });
});

// 消费者
messageQueue.process(async (job) => {
  const message = {
    ...job.data,
    time: new Date().toISOString()
  };
  
  await Message.save(message);
  io.emit('message', message);
});

常见问题

Q1: WebSocket 连接失败怎么办?

原因

  • 防火墙阻止 WebSocket 连接
  • Nginx 配置不正确
  • 代理服务器不支持 WebSocket

解决方案

javascript
// 启用 WebSocket 轮询降级
const socket = io({
  transports: ['websocket', 'polling']  // 优先使用 WebSocket,失败则降级
});

Q2: 消息延迟或丢失?

原因

  • 网络不稳定
  • 服务器负载过高
  • 心跳检测配置不当

解决方案

javascript
const io = new Server(server, {
  pingTimeout: 60000,   // 心跳超时 60 秒
  pingInterval: 25000,  // 心跳间隔 25 秒
  upgradeTimeout: 30000 // 升级超时 30 秒
});

Q3: 如何实现断线重连?

javascript
const socket = io({
  reconnection: true,           // 启用重连
  reconnectionAttempts: 10,     // 最大重连次数
  reconnectionDelay: 1000,      // 初始重连延迟
  reconnectionDelayMax: 5000,   // 最大重连延迟
  randomizationFactor: 0.5      // 随机化因子
});

socket.on('reconnect', (attemptNumber) => {
  console.log(`重连成功,尝试次数: ${attemptNumber}`);
  if (username) {
    socket.emit('join', username);
  }
});

socket.on('reconnect_failed', () => {
  alert('重连失败,请刷新页面');
});

Q4: 用户名重复怎么处理?

javascript
// 服务器端检查
socket.on('join', (username) => {
  for (const [_, name] of users) {
    if (name === username) {
      return socket.emit('error', {
        message: '用户名已被占用',
        code: 'USERNAME_TAKEN'
      });
    }
  }
  // 继续处理...
});

Q5: 如何实现消息已读回执?

javascript
// 客户端发送已读确认
socket.emit('read', { messageId: 'xxx' });

// 服务器处理
socket.on('read', (data) => {
  const message = messages.find(m => m.id === data.messageId);
  if (message) {
    message.readBy = message.readBy || [];
    message.readBy.push(users.get(socket.id));
    
    // 通知发送者
    io.emit('read-receipt', {
      messageId: data.messageId,
      readBy: message.readBy
    });
  }
});

Q6: 内存占用过高怎么办?

解决方案

  • 限制内存消息数量
  • 使用 Redis 缓存
  • 定期清理过期数据
javascript
// 定期清理
setInterval(() => {
  const maxMessages = 1000;
  if (messages.length > maxMessages) {
    messages.splice(0, messages.length - maxMessages);
  }
}, 60000);  // 每分钟清理一次

最佳实践

1. 使用 Namespace 隔离业务

javascript
// 不同业务使用不同命名空间
const chatNsp = io.of('/chat');
const notificationNsp = io.of('/notification');

chatNsp.on('connection', (socket) => {
  // 聊天相关逻辑
});

notificationNsp.on('connection', (socket) => {
  // 通知相关逻辑
});

2. 使用 Room 实现聊天室

javascript
// 创建/加入房间
socket.on('join-room', (roomId) => {
  socket.join(roomId);
  socket.roomId = roomId;
});

// 离开房间
socket.on('leave-room', (roomId) => {
  socket.leave(roomId);
});

// 发送房间消息
socket.on('room-message', (data) => {
  io.to(socket.roomId).emit('message', data);
});

3. 心跳检测保持连接

javascript
// Socket.io 内置心跳机制,无需额外实现
// 配置参数:
const io = new Server(server, {
  pingTimeout: 60000,
  pingInterval: 25000
});

4. 错误处理与日志记录

javascript
// 统一错误处理
socket.on('error', (error) => {
  console.error(`Socket error [${socket.id}]:`, error);
  socket.emit('error', {
    message: '服务器错误',
    code: 'INTERNAL_ERROR'
  });
});

// 日志中间件
io.use((socket, next) => {
  console.log(`[${new Date().toISOString()}] Connection attempt: ${socket.id}`);
  next();
});

5. 优雅关闭

javascript
// 优雅关闭服务器
process.on('SIGTERM', () => {
  console.log('收到 SIGTERM 信号,开始优雅关闭...');
  
  // 1. 停止接受新连接
  io.close();
  
  // 2. 通知所有客户端
  io.emit('server-shutdown', {
    message: '服务器正在关闭,请稍后重连'
  });
  
  // 3. 等待现有连接处理完成
  setTimeout(() => {
    server.close(() => {
      console.log('服务器已关闭');
      process.exit(0);
    });
  }, 5000);
});

6. 监控与告警

javascript
// 连接监控
setInterval(() => {
  const stats = {
    connectedClients: io.sockets.sockets.size,
    onlineUsers: users.size,
    memoryUsage: process.memoryUsage(),
    uptime: process.uptime()
  };
  
  console.log('Server stats:', stats);
  
  // 告警阈值
  if (stats.connectedClients > 1000) {
    console.warn('连接数超过阈值!');
  }
}, 60000);

扩展功能建议

1. 消息持久化(MongoDB)

javascript
// models/Message.js
const mongoose = require('mongoose');

const messageSchema = new mongoose.Schema({
  username: { type: String, required: true },
  content: { type: String, required: true },
  type: { type: String, default: 'normal' },
  room: { type: String, default: 'default' },
  time: { type: Date, default: Date.now }
});

module.exports = mongoose.model('Message', messageSchema);

2. 文件传输

javascript
// 客户端
socket.emit('file', {
  name: file.name,
  type: file.type,
  data: await file.arrayBuffer()
});

// 服务器
socket.on('file', (data) => {
  // 保存文件到存储服务
  // 发送文件 URL 给其他用户
  io.emit('file-message', {
    username,
    fileName: data.name,
    fileUrl: uploadedUrl,
    time: new Date().toISOString()
  });
});

3. 表情支持

javascript
// 表情映射
const emojis = {
  ':smile:': '😊',
  ':heart:': '❤️',
  ':thumbsup:': '👍',
  // ...
};

function parseEmojis(text) {
  return text.replace(/:\w+:/g, match => emojis[match] || match);
}

总结

本文档详细介绍了基于 WebSocket 的实时聊天应用的完整实现,包括:

  • 系统架构:客户端-服务器-数据三层架构
  • 核心功能:用户管理、消息处理、私聊、在线状态
  • API 接口:完整的 Socket.io 事件说明
  • 部署方案:PM2、Nginx、Docker 多种部署方式
  • 安全考虑:输入验证、速率限制、认证授权
  • 性能优化:消息压缩、房间隔离、集群支持
  • 最佳实践:命名空间、错误处理、优雅关闭

通过本文档,开发者可以快速构建一个功能完整、性能良好的实时聊天应用。


Node.js 22+ 实时聊天新特性

原生 WebSocket 客户端

Node.js 23+ 内置全局 WebSocket,客户端无需安装 ws

javascript
// 原生 WebSocket 客户端连接
const ws = new WebSocket('ws://localhost:8080/chat')

ws.addEventListener('open', () => {
  ws.send(JSON.stringify({ type: 'join', room: 'general' }))
})

ws.addEventListener('message', (event) => {
  const message = JSON.parse(event.data)
  displayMessage(message)
})

node:test 测试聊天功能

javascript
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'

describe('聊天服务', () => {
  it('应该成功连接 WebSocket', async () => {
    const ws = new WebSocket('ws://localhost:8080/chat')
    await new Promise((resolve) => {
      ws.addEventListener('open', () => {
        assert.strictEqual(ws.readyState, WebSocket.OPEN)
        ws.close()
        resolve()
      })
    })
  })

  it('应该发送和接收消息', async () => {
    const ws = new WebSocket('ws://localhost:8080/chat')
    await new Promise((resolve) => {
      ws.addEventListener('open', () => {
        ws.send(JSON.stringify({ type: 'message', text: 'Hello!' }))
      })
      ws.addEventListener('message', (event) => {
        const data = JSON.parse(event.data)
        assert.ok(data.text)
        ws.close()
        resolve()
      })
    })
  })
})

原生 --watch 开发模式

bash
# 开发环境自动重启
node --watch --env-file=.env.development server.js