实时聊天应用
基于 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 事件
客户端 → 服务器事件
| 事件名 | 参数 | 说明 | 示例 |
|---|---|---|---|
join | username: 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 | 无 | 断开连接(自动触发) | - |
服务器 → 客户端事件
| 事件名 | 数据结构 | 说明 |
|---|---|---|
message | Message | 接收新消息 |
users | string[] | 在线用户列表更新 |
typing | username: 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 startpackage.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 save2. 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.0yaml
# 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