WebSocket聊天室搭建指南 为什么你的实时通信总断线 一文教你解决兼容性和性能问题 附完整代码
昨天有个朋友来找我吐槽,说他写了一个WebSocket聊天室,测试的时候还挺好,结果上线没两天就全是用户反馈”消息收不到”或者”经常掉线”。他跑来问我,我一看代码,好家伙,基本上踩了所有新手容易踩的坑。
今天就把我这些年积累的WebSocket实战经验都拿出来,从连接断开的根本原因,到兼容性处理,再到性能优化,最后附上一个能直接跑起来的完整聊天室代码。不管你是刚入门还是想优化现有项目,这篇都能帮到你。
一、先搞懂:你的WebSocket为什么总是断
很多开发者以为WebSocket是”建立连接后就永远在线”的,这其实是个大误区。WebSocket确实比HTTP更适合实时通信,但它并不是真正的”长连接永不断”。
1.1 连接断开的常见原因
网络设备层面的断开
这是最常见的问题。你的客户端和服务器之间,可能隔着路由器、负载均衡器、CDN节点、防火墙等各种设备。这些中间设备都有连接超时设置:
- 大部分云厂商的负载均衡器(比如阿里云SLB、AWS ELB)默认空闲超时是60秒到300秒不等
- Nginx默认proxy_read_timeout是60秒
- 很多企业防火墙会主动清理长时间没有数据流动的连接
这意味着什么?想象一下,你和朋友用微信语音通话,中间隔了三四个路由器,如果你们俩都不说话,可能几秒钟后连接就断了,但你们谁都不知道。
客户端网络切换
这个更容易被忽略。用户用着WiFi,突然进了电梯,WiFi断了,4G/5G连上了。操作系统不会优雅地关闭WebSocket连接,而是直接断开。你的服务器端还觉得这个连接活着,但实际上客户端已经换了网络。
服务端重启或部署
开发阶段这个问题特别常见。你改了代码,重启服务,所有已建立的WebSocket连接瞬间全部断开。生产环境也有类似情况,比如Kubernetes滚动更新Pod,旧Pod被终止时连接直接丢失。
连接数超限
Node.js的libuv(它的事件循环底层)默认最大文件描述符数是1024,这意味着如果你用Node.js做WebSocket服务端,不调整配置的话,连接数超过1024就会出错。更高级的限制还有操作系统的文件描述符上限、内存限制等。
1.2 如何判断连接状态
很多开发者根本不检查连接状态,以为ws.on('connection', ...)之后就不用管了。这是完全错误的。
你需要在两个层面监控连接状态:
服务端要监控:
const WebSocket = require('ws');
const http = require('http');
const server = http.createServer();
const wss = new WebSocket.Server({ server });
// 每5秒发一次ping,测试连接是否还活着
const heartbeat = setInterval(function ping() {
console.log(`[心跳] 当前连接数: ${wss.clients.size}`);
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) {
// 连续两次没有响应,断开连接
console.log('[断开] 连接无响应,主动关闭');
return ws.terminate();
}
ws.isAlive = false;
ws.ping(); // 发送WebSocket Ping帧
});
}, 30000); // 每30秒检查一次
wss.on('connection', function connection(ws, req) {
console.log(`[连接] 新连接建立, IP: ${req.socket.remoteAddress}`);
ws.isAlive = true; // 标记为活跃
// 监听二进制ping响应
ws.on('pong', function() {
console.log('[响应] 收到客户端pong响应');
ws.isAlive = true;
});
// 监听连接关闭
ws.on('close', function(code, reason) {
console.log(`[关闭] 连接关闭, code: ${code}, reason: ${reason.toString()}`);
// code 1000 = 正常关闭
// code 1001 = 服务端离开(比如客户端切换网络)
// code 1006 = 异常关闭(网络断开,没有完成握手)
});
// 监听错误
ws.on('error', function error(err) {
console.error('[错误] WebSocket错误:', err.message);
});
// 发送欢迎消息
ws.send(JSON.stringify({
type: 'system',
message: '欢迎来到聊天室',
timestamp: Date.now()
}));
});
server.listen(8080, function listening() {
console.log('[启动] 服务器已启动,监听端口8080');
});
客户端也要监控:
class ChatClient {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectTimer = null;
this.reconnectDelay = 1000; // 初始重连延迟1秒
this.maxReconnectDelay = 30000; // 最大重连延迟30秒
this.maxRetries = 10; // 最多重试10次
this.retryCount = 0;
this.isManualClose = false;
// 心跳机制
this.heartbeatTimer = null;
this.heartbeatInterval = 30000; // 每30秒发一次心跳
}
connect() {
console.log(`[客户端] 尝试连接: ${this.url}`);
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('[客户端] 连接成功');
this.retryCount = 0;
this.reconnectDelay = 1000; // 重置重连延迟
this.startHeartbeat();
this.notifyStateChange('connected');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
// 收到服务器消息时重置心跳,避免误判
if (data.type !== 'pong') {
this.resetHeartbeat();
}
};
this.ws.onclose = (event) => {
console.log(`[客户端] 连接关闭, code: ${event.code}, reason: ${event.reason || '无'}`);
this.stopHeartbeat();
this.ws = null;
this.notifyStateChange('disconnected');
// 如果是手动关闭,不重连
if (this.isManualClose) {
console.log('[客户端] 手动断开,不重连');
return;
}
// 自动重连
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('[客户端] 连接错误:', error.message || error);
};
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
console.log('[客户端] 发送心跳');
}
}, this.heartbeatInterval);
}
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
resetHeartbeat() {
// 可以在这里添加心跳超时检测逻辑
}
scheduleReconnect() {
if (this.retryCount >= this.maxRetries) {
console.log('[客户端] 超过最大重连次数,停止重连');
this.notifyStateChange('failed');
return;
}
this.retryCount++;
// 指数退避:1s, 2s, 4s, 8s, 16s, 30s
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.retryCount - 1), this.maxReconnectDelay);
// 加一点随机抖动,避免大量客户端同时重连
const jitter = Math.random() * 1000;
console.log(`[客户端] ${this.retryCount}秒后尝试重连 (delay: ${delay + jitter}ms)`);
this.notifyStateChange('reconnecting', { retryCount: this.retryCount, delay });
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay + jitter);
}
disconnect() {
this.isManualClose = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.stopHeartbeat();
if (this.ws) {
this.ws.close(1000, '客户端主动断开');
}
}
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.warn('[客户端] 连接未建立,消息已丢弃');
this.notifyStateChange('no_connection');
}
}
handleMessage(data) {
// 子类或调用方可以自定义消息处理
console.log('[消息]', data);
}
notifyStateChange(state, detail = {}) {
// 可以触发事件,让UI层响应
window.dispatchEvent(new CustomEvent('chat-state-change', { detail: { state, ...detail } }));
}
}
二、兼容性:浏览器差异和代理问题
你以为所有浏览器都支持WebSocket?大部分是的,但支持程度不一样。而且你代码里用的ws库,在Node.js环境是OK的,但在浏览器里你用的是原生WebSocket对象,两者的API有些微差别。
2.1 浏览器WebSocket的基本用法
// 这是浏览器端的标准写法
const ws = new WebSocket('ws://localhost:8080');
// readyState 有4个值:
// 0 = CONNECTING
// 1 = OPEN
// 2 = CLOSING
// 3 = CLOSED
ws.onopen = () => {
console.log('连接已建立');
ws.send('你好,服务器!');
};
ws.onmessage = (event) => {
console.log('收到消息:', event.data);
};
ws.onerror = (error) => {
console.error('WebSocket错误:', error);
};
ws.onclose = (event) => {
console.log(`连接已关闭, code: ${event.code}`);
};
2.2 兼容性问题排查清单
不同浏览器和代理对WebSocket的支持差异:
| 问题 | 表现 | 解决方案 |
|---|---|---|
| 某些公司防火墙拦截WebSocket | 连接直接失败,或超时 | 使用WSS(加密WebSocket),HTTPS代理通常不拦截 |
| 旧版IE(<10)不支持 | 完全没有WebSocket | 降级到Server-Sent Events或长轮询 |
| 反向代理配置错误 | 连接建立但无法通信 | 确保代理正确配置了Upgrade头 |
| 跨域问题 | 被浏览器CORS策略拦截 | 服务端设置正确的Access-Control-Allow-Origin |
| 移动端浏览器后台连接被杀 | 连接突然关闭 | 实现重连机制,使用Service Worker保持后台连接 |
2.3 反向代理配置
这是生产环境最容易踩的坑。你的Node.js WebSocket服务端通常不会直接暴露给用户,前面会有一层Nginx或Caddy做反向代理。如果代理配置不对,WebSocket连接要么建立不了,要么建立后无法通信。
Nginx配置示例:
upstream websocket_backend {
server 127.0.0.1:8080;
}
server {
listen 80;
server_name chat.example.com;
# 普通HTTP请求
location / {
root /var/www/html;
index index.html;
}
# WebSocket连接的关键配置
location /ws {
proxy_pass http://websocket_backend;
# 必须设置这些头,否则WebSocket握手会失败
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 保持连接超时,根据实际业务调整
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
# 缓冲区设置,避免大消息被截断
proxy_buffering off;
proxy_buffer_size 4m;
proxy_buffers 4 4m;
# 禁用请求缓冲,实时转发
proxy_request_buffering off;
}
}
Caddy配置示例(更简洁):
chat.example.com {
root * /var/www/html
# 静态文件
file_server
# WebSocket路径
route /ws/* {
reverse_proxy 127.0.0.1:8080
}
}
Caddy会自动处理WebSocket的Upgrade头,不用你操心。
2.4 跨域问题
如果你的前端和WebSocket服务端不在同一个域名下,会遇到CORS问题:
// 服务端需要设置这些响应头
const WebSocket = require('ws');
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'Access-Control-Allow-Origin': '*', // 生产环境换成具体域名
'Access-Control-Allow-Methods': 'GET, POST',
'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept'
});
res.end('OK');
});
const wss = new WebSocket.Server({ server, path: '/ws' });
wss.on('connection', (ws, req) => {
// 检查Origin头,防止CSRF攻击
const origin = req.headers.origin;
const allowedOrigins = ['https://chat.example.com', 'https://www.example.com'];
if (origin && allowedOrigins.includes(origin)) {
ws.headers = { ...req.headers, 'x-allowed-origin': origin };
} else {
ws.close(1008, 'Origin not allowed');
return;
}
console.log(`[连接] 来自: ${origin || '未知'}`);
ws.send(JSON.stringify({ type: 'system', message: '连接成功' }));
});
server.listen(8080);
三、性能优化:连接数上去了,怎么办
聊天室做得好,用户多了,连接数上去了,这时候性能问题就来了。一个WebSocket连接虽然占用资源比HTTP少,但成千上万个连接同时存在,服务端压力也不小。
3.1 连接管理策略
按房间/频道分组管理
class RoomManager {
constructor() {
// Map<roomId, Set<WebSocket>>
this.rooms = new Map();
// Map<ws, roomId> 记录每个连接属于哪个房间
this.wsToRoom = new Map();
}
join(ws, roomId) {
// 如果之前在别的房间,先离开
if (this.wsToRoom.has(ws)) {
this.leave(ws);
}
if (!this.rooms.has(roomId)) {
this.rooms.set(roomId, new Set());
}
this.rooms.get(roomId).add(ws);
this.wsToRoom.set(ws, roomId);
console.log(`[房间] 用户加入房间 ${roomId}, 当前人数: ${this.rooms.get(roomId).size}`);
// 通知房间内其他人
this.broadcastToRoom(roomId, {
type: 'system',
message: `${ws.username || '用户'} 加入了房间`,
userId: ws.userId
}, ws);
}
leave(ws) {
const roomId = this.wsToRoom.get(ws);
if (!roomId) return;
const room = this.rooms.get(roomId);
if (room) {
room.delete(ws);
if (room.size === 0) {
this.rooms.delete(roomId);
}
}
this.wsToRoom.delete(ws);
console.log(`[房间] 用户离开房间 ${roomId}, 剩余人数: ${room?.size || 0}`);
// 通知房间内其他人
if (room && room.size > 0) {
this.broadcastToRoom(roomId, {
type: 'system',
message: `${ws.username || '用户'} 离开了房间`,
userId: ws.userId
});
}
}
broadcastToRoom(roomId, message, excludeWs = null) {
const room = this.rooms.get(roomId);
if (!room) return;
const data = JSON.stringify(message);
for (const ws of room) {
if (ws !== excludeWs && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
}
}
getRoomUsers(roomId) {
const room = this.rooms.get(roomId);
if (!room) return [];
return Array.from(room).map(ws => ({
id: ws.userId,
name: ws.username,
joinedAt: ws.joinedAt
}));
}
getStats() {
let totalConnections = 0;
const roomStats = [];
for (const [roomId, members] of this.rooms) {
totalConnections += members.size;
roomStats.push({ roomId, memberCount: members.size });
}
return {
totalConnections,
totalRooms: this.rooms.size,
roomStats
};
}
}
使用连接池和最大连接数控制
const MAX_CONNECTIONS = 10000;
const CONNECTIONS_PER_IP = 5; // 每个IP最多5个连接
class ConnectionManager {
constructor() {
this.connections = new Map(); // ws -> metadata
this.ipConnections = new Map(); // ip -> count
this.maxConnections = MAX_CONNECTIONS;
}
canConnect(ip) {
if (this.connections.size >= this.maxConnections) {
return { allowed: false, reason: '服务器连接数已满' };
}
const count = this.ipConnections.get(ip) || 0;
if (count >= CONNECTIONS_PER_IP) {
return { allowed: false, reason: '单个IP连接数超限' };
}
return { allowed: true };
}
addConnection(ws, ip, metadata = {}) {
this.connections.set(ws, {
...metadata,
ip,
connectedAt: Date.now(),
lastActivity: Date.now(),
messageCount: 0
});
this.ipConnections.set(ip, (this.ipConnections.get(ip) || 0) + 1);
console.log(`[连接] IP ${ip} 的第${this.ipConnections.get(ip)}个连接`);
}
removeConnection(ws, ip) {
this.connections.delete(ws);
const count = (this.ipConnections.get(ip) || 1) - 1;
if (count <= 0) {
this.ipConnections.delete(ip);
} else {
this.ipConnections.set(ip, count);
}
}
updateActivity(ws) {
const meta = this.connections.get(ws);
if (meta) {
meta.lastActivity = Date.now();
meta.messageCount++;
}
}
getInactiveConnections(thresholdMs = 60000) {
const now = Date.now();
const inactive = [];
for (const [ws, meta] of this.connections) {
if (now - meta.lastActivity > thresholdMs) {
inactive.push({ ws, meta });
}
}
return inactive;
}
getStats() {
return {
totalConnections: this.connections.size,
uniqueIPs: this.ipConnections.size,
connectionsByIP: Object.fromEntries(this.ipConnections)
};
}
}
3.2 内存优化:别让数据把内存吃光
WebSocket连接越多,内存占用越大。你需要定期检查内存使用情况:
// 定期检查内存,超过阈值就清理不活跃连接
setInterval(() => {
const used = process.memoryUsage();
console.log(`[内存] RSS: ${Math.round(used.rss / 1024 / 1024)}MB, Heap: ${Math.round(used.heapUsed / 1024 / 1024)}MB`);
// 如果内存使用超过80%,清理不活跃连接
if (used.heapUsed / used.heapTotal > 0.8) {
const inactive = connectionManager.getInactiveConnections(30000); // 30秒无活动
console.log(`[清理] 发现 ${inactive.length} 个不活跃连接`);
for (const { ws } of inactive) {
ws.close(1000, '服务器内存压力,清理不活跃连接');
}
}
}, 60000);
3.3 消息序列化优化
发送大量JSON消息时,序列化本身也有开销:
// 方法1:预编译消息模板(针对固定格式的消息)
const SYSTEM_MESSAGES = {
join: (username) => JSON.stringify({ type: 'system', message: `${username}加入了聊天室` }),
leave: (username) => JSON.stringify({ type: 'system', message: `${username}离开了聊天室` }),
ready: () => JSON.stringify({ type: 'system', message: '连接成功,可以开始聊天' })
};
// 方法2:使用Buffer发送二进制数据(高性能场景)
function serializeMessage(type, data) {
// 简单协议:1字节类型 + 4字节长度 + JSON数据
const json = JSON.stringify(data);
const buffer = Buffer.alloc(5 + json.length);
buffer[0] = type; // 消息类型
buffer.writeUInt32BE(json.length, 1); // 消息长度
buffer.write(json, 5);
return buffer;
}
function parseMessage(buffer) {
const type = buffer[0];
const length = buffer.readUInt32BE(1);
const json = buffer.toString('utf8', 5);
return { type, data: JSON.parse(json) };
}
// 方法3:对于高频小消息,使用MessagePack替代JSON
// npm install msgpack5
const msgpack = require('msgpack5')();
const compressedData = msgpack.encode({ type: 'chat', content: '你好', timestamp: Date.now() });
// 通常比JSON小30%-50%
3.4 集群部署:单个进程扛不住怎么办
当连接数超过几千时,单个Node.js进程可能不够用。这时需要考虑集群:
// 使用pm2进行集群部署
// ecosystem.config.js
module.exports = {
apps: [{
name: 'websocket-chat',
script: './server.js',
instances: 'max', // 使用所有CPU核心
exec_mode: 'cluster',
max_memory_restart: '500M',
// 共享内存存储,用于集群间通信
env_production: {
NODE_ENV: 'production',
REDIS_HOST: 'localhost',
REDIS_PORT: 6379
}
}]
};
集群模式下,不同进程间的WebSocket连接无法直接通信,需要借助Redis等外部存储来做消息广播:
const Redis = require('ioredis');
const RedisPubSub = require('graphql-subscriptions').RedisPubSub;
class ClusterMessageBroker {
constructor() {
this.redis = new Redis({ host: 'localhost', port: 6379 });
this.rooms = new Map(); // 本地房间数据
this.subscriber = this.redis.duplicate();
// 订阅集群消息
this.subscriber.subscribe('chat-messages', (channel, message) => {
const { roomId, message: msg } = JSON.parse(message);
this.broadcastLocally(roomId, msg);
});
}
// 广播消息到所有节点的指定房间
broadcast(roomId, message) {
this.redis.publish('chat-messages', JSON.stringify({ roomId, message }));
// 同时广播到本地
this.broadcastLocally(roomId, message);
}
broadcastLocally(roomId, message) {
const room = this.rooms.get(roomId);
if (!room) return;
const data = JSON.stringify(message);
for (const ws of room) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
}
}
join(ws, roomId) {
if (!this.rooms.has(roomId)) {
this.rooms.set(roomId, new Set());
}
this.rooms.get(roomId).add(ws);
}
leave(ws, roomId) {
const room = this.rooms.get(roomId);
if (room) {
room.delete(ws);
if (room.size === 0) {
this.rooms.delete(roomId);
}
}
}
}
四、完整聊天室代码
下面是一个相对完整的、可以直接运行的WebSocket聊天室。它包含了前面讲的大部分机制:心跳、断线重连、房间管理、基础权限校验等。
服务端代码(server.js):
const WebSocket = require('ws');
const http = require('http');
const url = require('url');
// ============ 配置 ============
const PORT = process.env.PORT || 8080;
const HEARTBEAT_INTERVAL = 30000; // 30秒心跳
const MAX_CONNECTIONS = 10000;
const CONNECTIONS_PER_IP = 10;
// ============ 工具函数 ============
function generateId() {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
function createResponse(type, data = {}) {
return JSON.stringify({
type,
id: generateId(),
timestamp: Date.now(),
...data
});
}
// ============ 房间管理器 ============
class RoomManager {
constructor() {
this.rooms = new Map();
this.userRooms = new Map(); // ws -> roomId
}
createRoom(roomId, options = {}) {
if (this.rooms.has(roomId)) {
return null;
}
const room = {
id: roomId,
name: options.name || roomId,
users: new Map(),
maxUsers: options.maxUsers || 100,
createdAt: Date.now(),
history: [] // 消息历史,最多保留100条
};
this.rooms.set(roomId, room);
console.log(`[房间] 创建房间 "${room.name}" (ID: ${roomId})`);
return room;
}
join(ws, roomId, username) {
const room = this.rooms.get(roomId);
if (!room) {
ws.send(createResponse('error', { message: '房间不存在' }));
return null;
}
if (room.users.size >= room.maxUsers) {
ws.send(createResponse('error', { message: '房间已满' }));
return null;
}
// 如果已经在其他房间,先离开
if (this.userRooms.has(ws)) {
this.leave(ws);
}
const user = {
id: generateId().slice(0, 8),
username,
joinedAt: Date.now(),
ws
};
room.users.set(user.id, user);
this.userRooms.set(ws, roomId);
// 记录用户信息到ws对象
ws.userId = user.id;
ws.username = username;
ws.roomId = roomId;
// 广播用户加入
this.broadcast(roomId, createResponse('user_join', {
user: { id: user.id, username: user.username },
users: this.getUsersList(roomId)
}));
// 发送历史消息
if (room.history.length > 0) {
ws.send(createResponse('history', { messages: room.history }));
}
// 发送当前在线用户列表
ws.send(createResponse('user_list', {
users: this.getUsersList(roomId)
}));
console.log(`[用户] ${username} 加入房间 ${roomId}, 当前在线: ${room.users.size}`);
return user;
}
leave(ws) {
const roomId = this.userRooms.get(ws);
if (!roomId) return;
const room = this.rooms.get(roomId);
if (room && room.users.has(ws.userId)) {
const user = room.users.get(ws.userId);
room.users.delete(ws.userId);
this.userRooms.delete(ws);
this.broadcast(roomId, createResponse('user_leave', {
user: { id: user.id, username: user.username },
users: this.getUsersList(roomId)
}));
console.log(`[用户] ${user.username} 离开房间 ${roomId}, 剩余: ${room.users.size}`);
}
}
sendMessage(roomId, message) {
const room = this.rooms.get(roomId);
if (!room) return false;
const msg = {
id: generateId(),
userId: this.userRooms.has(message.senderWs) ?
room.users.get(this.userRooms.get(message.senderWs))?.id : 'unknown',
username: message.username || '匿名',
content: message.content,
type: message.type || 'text',
timestamp: Date.now()
};
// 保存到历史
room.history.push(msg);
if (room.history.length > 100) {
room.history.shift();
}
// 广播给房间内所有用户
const data = createResponse('message', { message: msg });
for (const user of room.users.values()) {
if (user.ws.readyState === WebSocket.OPEN) {
user.ws.send(data);
}
}
return true;
}
getUsersList(roomId) {
const room = this.rooms.get(roomId);
if (!room) return [];
return Array.from(room.users.values()).map(u => ({
id: u.id,
username: u.username,
joinedAt: u.joinedAt
}));
}
getRoomInfo(roomId) {
const room = this.rooms.get(roomId);
if (!room) return null;
return {
id: room.id,
name: room.name,
userCount: room.users.size,
maxUsers: room.maxUsers,
historyCount: room.history.length
};
}
getAllRooms() {
const rooms = [];
for (const [id, room] of this.rooms) {
rooms.push(this.getRoomInfo(id));
}
return rooms.sort((a, b) => b.userCount - a.userCount);
}
getStats() {
let totalUsers = 0;
for (const room of this.rooms.values()) {
totalUsers += room.users.size;
}
return {
totalRooms: this.rooms.size,
totalUsers,
rooms: this.getAllRooms()
};
}
}
// ============ 主服务器 ============
const wss = new WebSocket.Server({ noServer: true });
const roomManager = new RoomManager();
const connectionCount = new Map(); // ip -> count
// 心跳检测
const heartbeatInterval = setInterval(() => {
wss.clients.forEach(ws => {
if (ws.isAlive === false) {
console.log(`[心跳] 断开无响应连接 (用户: ${ws.username || '未知'})`);
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, HEARTBEAT_INTERVAL);
wss.on('close', () => {
clearInterval(heartbeatInterval);
});
// 处理连接
wss.on('connection', function connection(ws, req) {
const ip = req.socket.remoteAddress;
// 检查IP连接数
const ipCount = (connectionCount.get(ip) || 0) + 1;
connectionCount.set(ip, ipCount);
if (ipCount > CONNECTIONS_PER_IP) {
console.log(`[拒绝] IP ${ip} 连接数超限 (${ipCount})`);
ws.close(1008, '连接数超限');
return;
}
// 总连接数检查
if (wss.clients.size > MAX_CONNECTIONS) {
console.log(`[拒绝] 服务器连接数已满 (${wss.clients.size})`);
ws.close(1008, '服务器繁忙');
return;
}
ws.isAlive = true;
// 解析查询参数
const params = url.parse(req.url, true).query;
const roomId = params.room;
const username = params.username || '匿名' + Math.floor(Math.random() * 10000);
console.log(`[连接] 新用户: ${username} (IP: ${ip}, Room: ${roomId})`);
ws.send(createResponse('connected', {
userId: ws.userId || generateId().slice(0, 8),
username,
roomId
}));
// 加入房间(如果指定了)
if (roomId) {
roomManager.join(ws, roomId, username);
}
// 处理消息
ws.on('message', function message(data) {
ws.isAlive = true;
try {
const msg = JSON.parse(data);
switch (msg.type) {
case 'chat':
if (!ws.roomId) {
ws.send(createResponse('error', { message: '请先加入房间' }));
return;
}
roomManager.sendMessage(ws.roomId, {
senderWs: ws,
username: ws.username,
content: msg.content,
type: 'text'
});
break;
case 'join':
roomManager.join(ws, msg.roomId, msg.username || ws.username);
break;
case 'leave':
roomManager.leave(ws);
break;
case 'ping':
ws.send(createResponse('pong', { timestamp: Date.now() }));
break;
case 'get_rooms':
ws.send(createResponse('rooms', {
rooms: roomManager.getAllRooms()
}));
break;
case 'get_stats':
ws.send(createResponse('stats', {
stats: roomManager.getStats()
}));
break;
default:
ws.send(createResponse('error', { message: '未知的消息类型' }));
}
} catch (e) {
console.error('[解析错误]', e.message);
ws.send(createResponse('error', { message: '消息格式错误' }));
}
});
// 处理关闭
ws.on('close', function close(code, reason) {
connectionCount.set(ip, Math.max(0, (connectionCount.get(ip) || 1) - 1));
if (ws.roomId) {
roomManager.leave(ws);
}
console.log(`[断开] 连接关闭, code: ${code}, reason: ${reason || '无'}`);
});
// 处理错误
ws.on('error', function error(err) {
console.error('[WebSocket错误]', err.message);
});
// 处理pong响应
ws.on('pong', function() {
ws.isAlive = true;
});
});
// HTTP服务器
const server = http.createServer((req, res) => {
const urlObj = url.parse(req.url, true);
if (urlObj.pathname === '/api/rooms') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ rooms: roomManager.getAllRooms() }));
} else if (urlObj.pathname === '/api/stats') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ stats: roomManager.getStats() }));
} else {
res.writeHead(404);
res.end('Not Found');
}
});
// 升级WebSocket连接
server.on('upgrade', function upgrade(req, socket, head) {
const params = url.parse(req.url, true).query;
// 简单的Origin校验
const origin = req.headers.origin;
const allowedOrigins = [
'http://localhost:3000',
'http://127.0.0.1:3000',
'https://your-domain.com'
];
if (origin && !allowedOrigins.includes(origin)) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, function done(ws) {
wss.emit('connection', ws, req);
});
});
server.listen(PORT, function listening() {
console.log(`
╔═══════════════════════════════════════════════════════╗
║ WebSocket 聊天室服务器已启动 ║
║ 端口: ${PORT} ║
║ 最大连接数: ${MAX_CONNECTIONS} ║
║ 心跳间隔: ${HEARTBEAT_INTERVAL/1000}秒 ║
╠═══════════════════════════════════════════════════════╣
║ 访问地址: ws://localhost:${PORT}/?room=test&username=你的名字 ║
║ API: http://localhost:${PORT}/api/rooms ║
║ API: http://localhost:${PORT}/api/stats ║
╚═══════════════════════════════════════════════════════╝
`);
});
// 优雅退出
process.on('SIGINT', () => {
console.log('\n[关闭] 正在关闭服务器...');
// 关闭所有连接
wss.clients.forEach(ws => {
ws.close(1001, '服务器关闭');
});
server.close(() => {
console.log('[关闭] 服务器已关闭');
process.exit(0);
});
});
客户端代码(client.html):
<!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>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
height: 100vh;
display: flex;
flex-direction: column;
}
/* 登录界面 */
#loginScreen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.login-box {
background: #16213e;
padding: 40px;
border-radius: 16px;
width: 400px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
}
.login-box h1 {
text-align: center;
margin-bottom: 8px;
color: #00d4ff;
}
.login-box p {
text-align: center;
color: #888;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #aaa;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 1px solid #333;
border-radius: 8px;
background: #0f3460;
color: #fff;
font-size: 16px;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #00d4ff;
}
.btn {
width: 100%;
padding: 14px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s, opacity 0.2s;
}
.btn:hover { opacity: 0.9; }
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* 聊天界面 */
#chatScreen {
display: none;
flex-direction: column;
height: 100vh;
}
.chat-header {
background: #16213e;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #333;
}
.chat-header .room-info {
display: flex;
align-items: center;
gap: 12px;
}
.chat-header .room-name {
font-size: 18px;
font-weight: 600;
}
.chat-header .user-count {
font-size: 14px;
color: #888;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #e74c3c;
}
.status-dot.connected { background: #2ecc71; }
.status-dot.reconnecting { background: #f39c12; animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: 70%;
padding: 12px 16px;
border-radius: 12px;
font-size: 15px;
line-height: 1.5;
word-wrap: break-word;
}
.message.system {
align-self: center;
background: #2a2a4a;
color: #888;
font-size: 13px;
padding: 8px 16px;
border-radius: 20px;
}
.message.self {
align-self: flex-end;
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: #fff;
}
.message.other {
align-self: flex-start;
background: #16213e;
border: 1px solid #333;
}
.message .sender {
font-size: 12px;
opacity: 0.7;
margin-bottom: 4px;
}
.message .time {
font-size: 11px;
opacity: 0.5;
margin-top: 4px;
}
.chat-input-area {
background: #16213e;
padding: 16px 24px;
display: flex;
gap: 12px;
border-top: 1px solid #333;
}
.chat-input-area input {
flex: 1;
padding: 14px 20px;
border: 1px solid #333;
border-radius: 25px;
background: #0f3460;
color: #fff;
font-size: 15px;
outline: none;
}
.chat-input-area input:focus {
border-color: #00d4ff;
}
.chat-input-area button {
padding: 14px 28px;
border: none;
border-radius: 25px;
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.chat-input-area button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 滚动条样式 */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px;
}
/* 重连提示 */
.reconnecting-banner {
display: none;
background: #f39c12;
color: #000;
text-align: center;
padding: 8px;
font-size: 14px;
}
</style>
</head>
<body>
<!-- 登录界面 -->
<div id="loginScreen">
<div class="login-box">
<h1>💬 聊天室</h1>
<p>输入你的信息和房间名,开始聊天</p>
<div class="form-group">
<label>用户名</label>
<input type="text" id="usernameInput" placeholder="输入你的用户名" maxlength="20" autocomplete="off">
</div>
<div class="form-group">
<label>房间名</label>
<input type="text" id="roomInput" placeholder="输入房间名(如:general)" maxlength="20" autocomplete="off">
</div>
<button class="btn" id="joinBtn" onclick="joinRoom()">加入聊天</button>
</div>
</div>
<!-- 聊天界面 -->
<div id="chatScreen">
<div class="reconnecting-banner" id="reconnectBanner">
🔌 连接中断,正在重连...
</div>
<div class="chat-header">
<div class="room-info">
<div class="status-dot" id="statusDot"></div>
<span class="room-name" id="roomName">房间</span>
<span class="user-count" id="userCount">0 人在线</span>
</div>
<button class="btn" style="width:auto;padding:8px 20px;font-size:14px;" onclick="leaveRoom()">退出</button>
</div>
<div class="chat-messages" id="messages"></div>
<div class="chat-input-area">
<input type="text" id="messageInput" placeholder="输入消息..." autocomplete="off" disabled>
<button id="sendBtn" onclick="sendMessage()" disabled>发送</button>
</div>
</div>
<script>
// ============ 配置 ============
const WS_URL = `ws://${location.hostname}:${location.port || 8080}/?room=general&username=游客`;
const HEARTBEAT_INTERVAL = 30000;
// ============ 状态 ============
let ws = null;
let reconnectTimer = null;
let heartbeatTimer = null;
let reconnectDelay = 1000;
let maxReconnectDelay = 30000;
let retryCount = 0;
let isConnected = false;
let currentRoom = 'general';
let currentUser = '游客';
let isManualClose = false;
// ============ 登录 ============
function joinRoom() {
const username = document.getElementById('usernameInput').value.trim();
const room = document.getElementById('roomInput').value.trim();
if (!username) {
alert('请输入用户名');
return;
}
if (!room) {
alert('请输入房间名');
return;
}
currentUser = username;
currentRoom = room;
const url = `ws://${location.hostname}:${location.port || 8080}/?room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}`;
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('chatScreen').style.display = 'flex';
connect(url);
}
// 回车键登录
document.getElementById('roomInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') joinRoom();
});
document.getElementById('usernameInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') document.getElementById('roomInput').focus();
});
// ============ 连接管理 ============
function connect(url) {
console.log(`[连接] 尝试连接: ${url}`);
ws = new WebSocket(url);
ws.onopen = () => {
console.log('[连接] 连接成功');
isConnected = true;
retryCount = 0;
reconnectDelay = 1000;
updateStatus('connected');
startHeartbeat();
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleMessage(data);
} catch (e) {
console.error('[解析错误]', e);
}
};
ws.onclose = (event) => {
console.log(`[断开] code: ${event.code}, reason: ${event.reason || '无'}`);
isConnected = false;
stopHeartbeat();
if (!isManualClose) {
scheduleReconnect();
}
};
ws.onerror = (error) => {
console.error('[错误]', error);
};
}
function scheduleReconnect() {
if (retryCount >= 10) {
console.log('[重连] 超过最大重试次数');
updateStatus('failed');
document.getElementById('reconnectBanner').style.display = 'block';
document.getElementById('reconnectBanner').textContent = '❌ 连接失败,请刷新页面重试';
return;
}
retryCount++;
reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
const jitter = Math.random() * 1000;
console.log(`[重连] ${retryCount}次尝试,${reconnectDelay + jitter}ms后重试`);
updateStatus('reconnecting');
document.getElementById('reconnectBanner').style.display = 'block';
document.getElementById('reconnectBanner').textContent = `🔌 连接中断,正在重连... (${retryCount}次)`;
reconnectTimer = setTimeout(() => {
const url = `ws://${location.hostname}:${location.port || 8080}/?room=${encodeURIComponent(currentRoom)}&username=${encodeURIComponent(currentUser)}`;
connect(url);
}, reconnectDelay + jitter);
}
function disconnect() {
isManualClose = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
stopHeartbeat();
if (ws) ws.close(1000, '用户主动离开');
}
// ============ 心跳 ============
function startHeartbeat() {
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
// ============ 消息处理 ============
function handleMessage(data) {
switch (data.type) {
case 'connected':
console.log('[服务器] 连接成功, userId:', data.userId);
break;
case 'pong':
// 收到心跳响应,不做处理
break;
case 'message':
appendMessage(data.message, false);
break;
case 'user_join':
appendSystemMessage(`${data.user.username} 加入了聊天室`);
if (data.users) {
updateUserCount(data.users.length);
}
break;
case 'user_leave':
appendSystemMessage(`${data.user.username} 离开了聊天室`);
if (data.users) {
updateUserCount(data.users.length);
}
break;
case 'user_list':
updateUserCount(data.users.length);
break;
case 'history':
data.messages.forEach(msg => appendMessage(msg, true));
break;
case 'error':
appendSystemMessage(`❌ ${data.message}`);
break;
case 'rooms':
console.log('[房间列表]', data.rooms);
break;
case 'stats':
console.log('[服务器统计]', data.stats);
break;
}
}
// ============ UI 更新 ============
function updateStatus(status) {
const dot = document.getElementById('statusDot');
const input = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
dot.className = 'status-dot';
switch (status) {
case 'connected':
dot.classList.add('connected');
input.disabled = false;
sendBtn.disabled = false;
input.placeholder = '输入消息...';
break;
case 'reconnecting':
dot.classList.add('reconnecting');
input.disabled = true;
sendBtn.disabled = true;
input.placeholder = '连接中断...';
break;
case 'failed':
input.disabled = true;
sendBtn.disabled = true;
input.placeholder = '连接失败,请刷新';
break;
}
}
function updateUserCount(count) {
document.getElementById('userCount').textContent = `${count} 人在线`;
}
function appendMessage(msg, isHistory) {
const container = document.getElementById('messages');
const div = document.createElement('div');
const isSelf = msg.username === currentUser;
div.className = `message ${isSelf ? 'self' : 'other'}`;
const time = new Date(msg.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
div.innerHTML = `
${!isSelf ? `<div class="sender">${escapeHtml(msg.username)}</div>` : ''}
<div class="content">${escapeHtml(msg.content)}</div>
<div class="time">${time}</div>
`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function appendSystemMessage(text) {
const container = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'message system';
div.textContent = text;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ============ 发送消息 ============
function sendMessage() {
const input = document.getElementById('messageInput');
const content = input.value.trim();
if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({
type: 'chat',
content: content
}));
input.value = '';
}
// 回车发送
document.getElementById('messageInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// ============ 退出房间 ============
function leaveRoom() {
isManualClose = true;
disconnect();
document.getElementById('chatScreen').style.display = 'none';
document.getElementById('loginScreen').style.display = 'flex';
document.getElementById('reconnectBanner').style.display = 'none';
// 清空消息
document.getElementById('messages').innerHTML = '';
}
// 页面关闭时断开
window.addEventListener('beforeunload', () => {
isManualClose = true;
if (ws) ws.close(1000, '页面关闭');
});
// 允许回车进入房间输入框
document.getElementById('usernameInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') document.getElementById('roomInput').focus();
});
</script>
</body>
</html>
五、常见问题排查指南
当你遇到问题时,按这个顺序排查:
连接建立阶段
问题:连接直接被拒绝
检查这几项:
- 服务端是否正常启动?
curl http://localhost:8080/api/stats - 防火墙是否拦截了WebSocket端口?
- 反向代理是否正确配置了Upgrade头?
- Origin是否被服务端拒绝?
调试技巧:
// 客户端加更多日志
ws.onopen = () => console.log('连接成功');
ws.onclose = (e) => console.log('关闭:', e.code, e.reason);
ws.onerror = (e) => console.log('错误:', e);
连接维持阶段
问题:连接经常断开
- 检查服务端心跳是否正常:查看
isAlive标记 - 检查代理超时设置:Nginx的
proxy_read_timeout等 - 检查客户端网络是否稳定(特别是移动端)
问题:发消息没响应
- 检查连接状态:
ws.readyState === 1 - 检查消息格式是否正确(JSON.stringify)
- 检查服务端是否正确处理了该类型消息
性能问题
问题:用户多了之后卡顿
- 检查内存使用:
process.memoryUsage() - 检查是否有消息重复发送
- 考虑使用Redis做消息转发(集群模式)
- 限制消息历史数量,避免内存积累
六、一些实用的进阶技巧
1. 消息确认机制
对于重要消息(比如充值通知),需要确认客户端收到了:
// 服务端发送需要确认的消息
function sendWithReceipt(ws, message) {
const receiptId = generateId();
ws.send(JSON.stringify({
...message,
receiptId,
requireReceipt: true
}));
// 等待确认
return new Promise((resolve) => {
const timeout = setTimeout(() => resolve(false), 5000);
// 假设客户端返回receipt确认
ws.once('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'receipt' && msg.receiptId === receiptId) {
clearTimeout(timeout);
resolve(true);
}
});
});
}
2. 离线消息处理
用户重新上线时,获取离线期间的消息:
// 服务端存储离线消息
const offlineMessages = new Map(); // userId -> [messages]
function storeOfflineMessage(userId, message) {
if (!offlineMessages.has(userId)) {
offlineMessages.set(userId, []);
}
offlineMessages.get(userId).push(message);
// 限制缓存数量
if (offlineMessages.get(userId).length > 500) {
offlineMessages.get(userId).splice(0, 200);
}
}
// 客户端上线时请求离线消息
function requestOfflineMessages() {
ws.send(JSON.stringify({
type: 'offline_messages',
userId: currentUser
}));
}
3. 使用Compression Extensions减少带宽
const wss = new WebSocket.Server({
port: PORT,
perMessageDeflate: {
zlibDeflateOptions: { chunkSize: 1024, memLevel: 7, level: 3 },
zlibInflateOptions: { chunkSize: 10 * 1024 },
threshold: 1024 // 小于1KB的消息不压缩
}
});
4. 监控和告警
// 简单的监控指标
setInterval(() => {
const stats = roomManager.getStats();
console.log('[监控]', {
connections: wss.clients.size,
rooms: stats.totalRooms,
users: stats.totalUsers,
memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + 'MB'
});
}, 10000);
写在最后
WebSocket开发看起来简单,真正上手后你会发现坑不少。断线重连、心跳检测、兼容性问题、性能瓶颈,每一个都可能让你头秃。
我写这篇文章的目的,就是希望你在踩这些坑之前,能有个参考。代码给你了,虽然不能直接复制粘贴就用(毕竟每个人的项目结构不一样),但核心逻辑都在这里了。
记住几个关键原则:
- 永远不要信任客户端的连接状态,服务端要主动探测
- 心跳和重连是标配,不要省略
- 做好限流和连接数控制,防止恶意用户拖垮服务
- 日志要写清楚,出了问题才能快速定位
希望这篇指南能帮到你。如果有什么具体问题,欢迎在评论区讨论。
