文章目录
基于Node.js与WebSocket的实时聊天室系统:从实现到优化
实时通信是现代Web应用的核心功能之一,聊天室作为实时交互的典型场景,能够很好地展示WebSocket技术的优势。本文将详细介绍如何使用Node.js构建功能完善的实时聊天室系统,包括核心功能实现、界面设计及多轮优化过程,为开发者提供一套可直接复用的解决方案。
系统概述与核心功能
本聊天室系统采用Node.js作为后端,纯HTML5+CSS3+JavaScript构建前端界面,无需依赖第三方前端框架。系统具备以下核心功能:
- 双向通信机制:基于WebSocket协议实现服务器与客户端的全双工通信,相比传统HTTP轮询方式,显著降低延迟并减少网络开销
- 多模式聊天:支持群聊与私聊两种模式,默认进入群聊,用户可通过左侧列表选择特定用户发起私人对话
- 用户管理:自动生成用户ID与头像,支持修改用户名,实时显示在线用户状态与数量
- 消息处理:完整的消息生命周期管理,包括发送、接收、存储与展示,支持消息时间戳与状态标识
- 响应式设计:适配桌面端与移动端不同屏幕尺寸,在移动设备上提供优化的交互体验
系统架构采用经典的客户端-服务器模型,后端使用Node.js的ws模块实现WebSocket服务器,负责连接管理、消息路由与用户状态维护;前端通过原生WebSocket API与服务器通信,处理UI渲染与用户交互。
核心技术实现解析
后端WebSocket服务器实现
服务器端的核心职责是维护连接状态、处理消息路由并管理用户信息。实现上采用了以下关键技术策略:
- 连接管理:使用
ws模块创建WebSocket服务器,为每个新连接生成唯一用户ID,通过数组维护所有在线用户信息 - 消息协议设计:定义了结构化的JSON消息格式,包含类型字段(
type)与数据字段(data),支持welcome、userJoined、newMessage等多种消息类型 - 消息路由机制:根据消息类型区分处理逻辑,群聊消息采用广播模式发送给所有在线用户,私聊消息则精准路由至指定接收者
- 用户状态同步:当用户加入、离开或更新信息时,实时向所有在线用户推送最新的用户列表,确保客户端状态一致性
前端界面与交互设计
前端实现聚焦于用户体验与界面美观,主要特点包括:
- 布局结构:采用左侧用户列表+右侧聊天区域的经典布局,用户列表展示当前在线用户与群聊选项,聊天区域包含消息展示与输入功能
- 消息展示优化:通过视觉设计区分不同类型消息——自己发送的消息居右显示(绿色背景),他人消息居左显示(白色背景),私聊消息添加锁图标标记
- 响应式适配:在移动设备上,用户列表可通过菜单按钮切换显示/隐藏,确保小屏设备上的操作便捷性
- 微交互设计:添加消息发送/接收动画、按钮状态反馈、连接状态提示等细节,提升用户体验
系统优化迭代过程
第一轮优化:消息布局与历史记录
初始版本存在消息展示不够直观、缺乏历史记录的问题,优化措施包括:
- 重构消息布局:采用左侧头像+右侧内容的结构,每条消息清晰显示发送者头像、名称、内容与时间戳
- 实现消息历史存储:设计
messageHistory数据结构,按聊天对象(群聊/私聊)分类存储消息,切换聊天时自动加载对应历史记录 - 添加空状态提示:当聊天记录为空时,显示友好的引导信息,提升新用户体验
第二轮优化:群聊功能完善
针对群聊无法正确选中和消息查看的问题,进行了以下改进:
- 修复群聊选择机制:为群聊项添加明确的点击事件处理,确保可以被正确选中并切换
- 完善群聊消息路由:优化服务器端群聊消息广播逻辑,确保所有用户能正确接收群聊消息
- 优化状态同步:当用户在私聊与群聊间切换时,确保界面状态(标题、消息区域)正确更新
第三轮优化:未读消息与通知系统
为解决用户可能错过重要消息的问题,新增未读消息计数与通知功能:
- 设计未读计数机制:通过
unreadCounts对象跟踪各聊天对象的未读消息数量,在用户列表中直观显示 - 实现计数重置逻辑:当用户点击进入对应聊天(包括群聊)时,自动将未读计数清零并从界面移除
- 添加多渠道通知:
- 应用内通知:新消息到达时在屏幕右下角显示通知卡片
- 浏览器通知:当页面不在焦点时,通过浏览器系统通知提醒用户,需用户授权
- 优化通知内容:包含发送者名称与消息预览,帮助用户快速了解消息重要性
最佳实践与扩展方向
最佳实践:
- 连接状态管理:实现自动重连机制,处理网络波动导致的连接中断,提升系统健壮性
- 消息安全处理:对用户输入进行HTML转义,防止XSS攻击,确保系统安全
- 性能优化:采用消息分片、批量处理等策略,减少频繁DOM操作带来的性能损耗
- 用户体验细节:添加加载状态、错误提示、操作反馈等细节,提升系统可用性
扩展方向:
- 消息持久化:将消息存储到数据库,支持历史消息查询与用户消息同步
- 功能增强:添加表情发送、文件传输、消息撤回等高级功能
- 身份认证:集成用户登录系统,支持账号密码或第三方登录
- 负载均衡:针对高并发场景,设计分布式WebSocket服务器架构
技术实现
- 后端:使用Node.js的ws库实现WebSocket服务器,处理连接管理、消息路由和用户状态
server.js代码
const WebSocket = require('ws');
const http = require('http');
const fs = require('fs');
const path = require('path');
// Create HTTP server
const server = http.createServer((req, res) => {
// Serve static files
const filePath = req.url === '/' ? '/index.html' : req.url;
const fullPath = path.join(__dirname, filePath);
fs.readFile(fullPath, (err, content) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('File not found');
} else {
// Set content type based on file extension
const ext = path.extname(fullPath);
let contentType = 'text/html';
switch (ext) {
case '.js':
contentType = 'text/javascript';
break;
case '.css':
contentType = 'text/css';
break;
case '.png':
contentType = 'image/png';
break;
case '.jpg':
case '.jpeg':
contentType = 'image/jpeg';
break;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
}
});
});
// Create WebSocket server
const wss = new WebSocket.Server({ server });
// Generate random avatar based on user ID
function generateAvatar(userId) {
// Use consistent seed based on user ID for same avatar
return `https://picsum.photos/seed/${userId}/200`;
}
// Generate unique user ID
function generateUserId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
// Store connected users
let users = [];
// Handle WebSocket connections
wss.on('connection', (ws) => {
console.log('New client connected');
// Create user object
const userId = generateUserId();
const user = {
id: userId,
name: `User${users.length + 1}`,
avatar: generateAvatar(userId),
ws: ws
};
// Add user to list
users.push(user);
// Send welcome message with user info and user list
ws.send(JSON.stringify({
type: 'welcome',
data: {
user: {
id: user.id,
name: user.name,
avatar: user.avatar
},
users: users.map(u => ({
id: u.id,
name: u.name,
avatar: u.avatar
}))
}
}));
// Notify others about new user
broadcastToOthers(user.id, JSON.stringify({
type: 'userJoined',
data: {
user: {
id: user.id,
name: user.name,
avatar: user.avatar
},
users: users.map(u => ({
id: u.id,
name: u.name,
avatar: u.avatar
}))
}
}));
// Handle incoming messages
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
switch (data.type) {
case 'chatMessage':
// Broadcast to all users
broadcast(JSON.stringify({
type: 'newMessage',
data: {
content: data.content,
sender: {
id: user.id,
name: user.name,
avatar: user.avatar
},
timestamp: new Date().toISOString()
}
}));
break;
case 'privateMessage':
// Send to specific user
const recipient = users.find(u => u.id === data.recipientId);
if (recipient) {
// Send to recipient
recipient.ws.send(JSON.stringify({
type: 'newPrivateMessage',
data: {
content: data.content,
sender: {
id: user.id,
name: user.name,
avatar: user.avatar
},
recipientId: data.recipientId,
timestamp: new Date().toISOString()
}
}));
// Send to sender (so they see their own message)
ws.send(JSON.stringify({
type: 'newPrivateMessage',
data: {
content: data.content,
sender: {
id: user.id,
name: user.name,
avatar: user.avatar
},
recipientId: data.recipientId,
timestamp: new Date().toISOString()
}
}));
}
break;
case 'updateName':
const oldName = user.name;
user.name = data.name;
// Notify all users about name change
broadcast(JSON.stringify({
type: 'userUpdated',
data: {
userId: user.id,
oldName: oldName,
newName: user.name,
users: users.map(u => ({
id: u.id,
name: u.name,
avatar: u.avatar
}))
}
}));
// Confirm name change to user
ws.send(JSON.stringify({
type: 'nameUpdated',
data: {
name: user.name
}
}));
break;
}
} catch (error) {
console.error('Error parsing message:', error);
}
});
// Handle disconnection
ws.on('close', () => {
console.log('Client disconnected');
// Remove user from list
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex !== -1) {
users.splice(userIndex, 1);
// Notify others about user leaving
broadcast(JSON.stringify({
type: 'userLeft',
data: {
userId: userId,
user: {
id: user.id,
name: user.name
},
users: users.map(u => ({
id: u.id,
name: u.name,
avatar: u.avatar
}))
}
}));
}
});
// Handle errors
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
// Broadcast message to all users
function broadcast(message) {
users.forEach(user => {
if (user.ws.readyState === WebSocket.OPEN) {
user.ws.send(message);
}
});
}
// Broadcast message to all users except the sender
function broadcastToOthers(senderId, message) {
users.forEach(user => {
if (user.id !== senderId && user.ws.readyState === WebSocket.OPEN) {
user.ws.send(message);
}
});
}
// Start server
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
- 前端:纯HTML5+CSS3+JavaScript实现,无需第三方框架
index.html代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket Chat Room</title>
<style>
:root {
--primary-color: #4a6fa5;
--secondary-color: #166bb3;
--light-bg: #f5f5f5;
--dark-bg: #2c3e50;
--user-list-bg: #ffffff;
--chat-bg: #f9f9f9;
--my-message-bg: #dcf8c6;
--other-message-bg: #ffffff;
--text-color: #333333;
--light-text: #7f8c8d;
--border-color: #e0e0e0;
--active-color: #e3f2fd;
--unread-color: #ff5722;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background-color: var(--light-bg);
color: var(--text-color);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-container {
display: flex;
flex: 1;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 8px;
margin: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
/* User List Styles */
.user-list-container {
width: 280px;
background-color: var(--user-list-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: width 0.3s ease;
}
.user-list-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
background-color: var(--primary-color);
color: white;
}
.user-list-header h2 {
font-size: 1.2rem;
font-weight: 600;
}
.user-info {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--primary-color);
}
.user-name-container {
flex: 1;
}
.user-name {
font-weight: 500;
margin-bottom: 4px;
}
.edit-name {
font-size: 0.8rem;
color: var(--secondary-color);
cursor: pointer;
}
.edit-name:hover {
text-decoration: underline;
}
.users-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.user-item {
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.user-item:hover {
background-color: var(--active-color);
}
.user-item.active {
background-color: var(--active-color);
font-weight: 500;
}
.user-item .user-status {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #4caf50;
margin-left: auto;
}
.user-item .unread-count {
position: absolute;
right: 24px;
background-color: var(--unread-color);
color: white;
font-size: 0.7rem;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.user-count {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}
/* Chat Area Styles */
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--chat-bg);
min-width: 0;
}
.chat-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
background-color: white;
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-title {
font-size: 1.2rem;
font-weight: 600;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background-size: cover;
background-position: center;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
max-width: 70%;
display: flex;
gap: 10px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message.other {
align-self: flex-start;
}
.message.me {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
}
.message-content {
padding: 10px 14px;
border-radius: 18px;
position: relative;
word-wrap: break-word;
max-width: calc(100% - 46px);
}
.message.other .message-content {
background-color: var(--other-message-bg);
border-bottom-left-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.message.me .message-content {
background-color: var(--my-message-bg);
border-bottom-right-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.message .sender {
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 4px;
color: var(--secondary-color);
}
.message.me .sender {
display: none; /* 不需要显示自己的名字 */
}
.message .time {
font-size: 0.7rem;
color: var(--light-text);
margin-top: 4px;
text-align: right;
}
.message.private .message-content::before {
content: '🔒';
position: absolute;
left: 8px;
top: 8px;
font-size: 0.8rem;
}
.message.private.other .message-content {
padding-left: 24px;
}
.message.private.me .message-content {
padding-right: 24px;
}
.message.private.me .message-content::before {
left: auto;
right: 8px;
}
.chat-input-container {
padding: 16px;
border-top: 1px solid var(--border-color);
background-color: white;
}
.chat-form {
display: flex;
gap: 10px;
}
.message-input {
flex: 1;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 24px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.message-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(74, 111, 165, 0.2);
}
.send-button {
padding: 12px 20px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 24px;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
}
.send-button:hover {
background-color: var(--secondary-color);
}
.send-button:active {
transform: scale(0.98);
}
.chat-status {
font-size: 0.9rem;
color: var(--light-text);
text-align: center;
padding: 8px;
background-color: rgba(255, 255, 255, 0.8);
}
/* System messages */
.system-message {
align-self: center;
background-color: rgba(255, 255, 255, 0.7);
padding: 6px 12px;
border-radius: 12px;
font-size: 0.9rem;
color: var(--light-text);
font-style: italic;
max-width: 80%;
text-align: center;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
visibility: hidden;
opacity: 0;
transition: visibility 0s linear 0.25s, opacity 0.25s;
}
.modal-overlay.active {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
.modal {
background-color: white;
padding: 24px;
border-radius: 8px;
width: 90%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
transform: translateY(-20px);
transition: transform 0.25s;
}
.modal-overlay.active .modal {
transform: translateY(0);
}
.modal h3 {
margin-bottom: 16px;
font-size: 1.2rem;
color: var(--dark-bg);
}
.modal-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
margin-bottom: 16px;
font-size: 1rem;
}
.modal-input:focus {
border-color: var(--primary-color);
outline: none;
}
.modal-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-button {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.modal-button.cancel {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.modal-button.cancel:hover {
background-color: var(--light-bg);
}
.modal-button.confirm {
background-color: var(--primary-color);
border: none;
color: white;
}
.modal-button.confirm:hover {
background-color: var(--secondary-color);
}
.mobile-menu-toggle {
display: none;
background: none;
border: none;
color: var(--text-color);
font-size: 1.5rem;
cursor: pointer;
padding: 8px;
}
/* Empty state */
.empty-state {
align-self: center;
text-align: center;
padding: 40px 20px;
color: var(--light-text);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
/* Notification badge */
.notification-badge {
position: fixed;
top: 20px;
right: 20px;
background-color: var(--unread-color);
color: white;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
transform: translateX(120%);
transition: transform 0.3s ease;
max-width: 300px;
}
.notification-badge.show {
transform: translateX(0);
}
.notification-badge .notification-sender {
font-weight: bold;
margin-bottom: 4px;
}
/* Responsive Styles */
@media (max-width: 768px) {
.app-container {
margin: 0;
border: none;
border-radius: 0;
}
.user-list-container {
position: absolute;
height: 100%;
z-index: 100;
transform: translateX(-100%);
transition: transform 0.3s ease;
width: 85%;
max-width: 300px;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.user-list-container.active {
transform: translateX(0);
}
.mobile-menu-toggle {
display: block;
}
.message {
max-width: 85%;
}
}
</style>
</head>
<body>
<div class="app-container">
<!-- User List -->
<div class="user-list-container">
<div class="user-list-header">
<h2>Chat Room</h2>
<div class="user-count" id="user-count">0 users online</div>
</div>
<div class="user-info">
<img src="" alt="Your avatar" class="avatar" id="my-avatar">
<div class="user-name-container">
<div class="user-name" id="my-name">Loading...</div>
<div class="edit-name" id="edit-name">Edit name</div>
</div>
</div>
<div class="users-list" id="users-list">
<!-- Users will be added here dynamically -->
<div class="user-item active" data-user-id="0">
<img src="https://picsum.photos/seed/group/200" alt="Group chat" class="avatar">
<div>
<div>Group Chat</div>
</div>
</div>
</div>
</div>
<!-- Chat Area -->
<div class="chat-container">
<div class="chat-header">
<button class="mobile-menu-toggle" id="mobile-menu-toggle">☰</button>
<div class="chat-title" id="chat-title">Group Chat</div>
</div>
<div class="chat-status" id="chat-status">Connecting...</div>
<div class="chat-messages" id="chat-messages">
<!-- Messages will be added here dynamically -->
</div>
<div class="chat-input-container">
<form class="chat-form" id="chat-form">
<input type="text" class="message-input" id="message-input" placeholder="Type your message..." autocomplete="off">
<button type="submit" class="send-button">
<i>→</i> Send
</button>
</form>
</div>
</div>
</div>
<!-- Edit Name Modal -->
<div class="modal-overlay" id="name-modal">
<div class="modal">
<h3>Change Your Name</h3>
<input type="text" class="modal-input" id="new-name-input" placeholder="Enter your name">
<div class="modal-buttons">
<button class="modal-button cancel" id="cancel-name">Cancel</button>
<button class="modal-button confirm" id="confirm-name">Save</button>
</div>
</div>
</div>
<!-- Notification Badge -->
<div class="notification-badge" id="notification-badge">
<div class="notification-sender" id="notification-sender"></div>
<div class="notification-message" id="notification-message"></div>
</div>
<script>
// DOM Elements
const chatForm = document.getElementById('chat-form');
const messageInput = document.getElementById('message-input');
const chatMessages = document.getElementById('chat-messages');
const usersList = document.getElementById('users-list');
const userCount = document.getElementById('user-count');
const chatStatus = document.getElementById('chat-status');
const chatTitle = document.getElementById('chat-title');
const myNameElement = document.getElementById('my-name');
const myAvatarElement = document.getElementById('my-avatar');
const editNameElement = document.getElementById('edit-name');
const nameModal = document.getElementById('name-modal');
const newNameInput = document.getElementById('new-name-input');
const confirmNameButton = document.getElementById('confirm-name');
const cancelNameButton = document.getElementById('cancel-name');
const userListContainer = document.getElementById('user-list-container');
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const notificationBadge = document.getElementById('notification-badge');
const notificationSender = document.getElementById('notification-sender');
const notificationMessage = document.getElementById('notification-message');
// State
let socket;
let currentUser = null;
let selectedUserId = 0; // 0 for group chat
let users = [];
let messageHistory = {
0: [] // 0 is group chat
};
let unreadCounts = {}; // Track unread messages for each user
// Request notification permission on page load
if (Notification && Notification.permission !== 'granted') {
Notification.requestPermission();
}
// Initialize WebSocket connection
function initWebSocket() {
// Connect to WebSocket server
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUri = `${wsProtocol}//${window.location.host}`;
socket = new WebSocket(wsUri);
// Connection opened
socket.addEventListener('open', (event) => {
chatStatus.textContent = 'Connected';
chatStatus.style.color = '#4caf50';
});
// Listen for messages
socket.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data);
handleMessage(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
// Connection closed
socket.addEventListener('close', (event) => {
chatStatus.textContent = 'Disconnected. Reconnecting...';
chatStatus.style.color = '#f44336';
// Try to reconnect after 3 seconds
setTimeout(initWebSocket, 3000);
});
// Connection error
socket.addEventListener('error', (error) => {
console.error('WebSocket error:', error);
chatStatus.textContent = 'Connection error';
chatStatus.style.color = '#f44336';
});
}
// Handle incoming messages
function handleMessage(message) {
switch (message.type) {
case 'welcome':
handleWelcome(message.data);
break;
case 'userJoined':
handleUserJoined(message.data);
break;
case 'userLeft':
handleUserLeft(message.data);
break;
case 'userUpdated':
handleUserUpdated(message.data);
break;
case 'newMessage':
addMessageToHistory(message.data, false);
// Only show if we're in group chat
if (selectedUserId === 0) {
addMessageToDOM(message.data, false);
} else {
// Update unread count for group chat
updateUnreadCount(0, 1);
showNotification(
'Group Chat',
message.data.content
);
}
break;
case 'newPrivateMessage':
addMessageToHistory(message.data, true);
// Only show if we're in the relevant chat
if (selectedUserId === message.data.sender.id ||
selectedUserId === message.data.recipientId) {
addMessageToDOM(message.data, true);
} else {
// Update unread count for this user
const userId = message.data.sender.id === currentUser.id
? message.data.recipientId
: message.data.sender.id;
updateUnreadCount(userId, 1);
// Show notification
showNotification(
message.data.sender.name,
message.data.content
);
}
break;
case 'nameUpdated':
myNameElement.textContent = message.data.name;
break;
default:
console.log('Unknown message type:', message.type);
}
}
// Add message to history storage
function addMessageToHistory(message, isPrivate) {
const chatId = isPrivate
? message.sender.id === currentUser.id ? message.recipientId : message.sender.id
: 0;
// Initialize history array if it doesn't exist
if (!messageHistory[chatId]) {
messageHistory[chatId] = [];
}
// Add message to history with metadata
messageHistory[chatId].push({
...message,
isPrivate,
timestamp: new Date().toISOString()
});
}
// Handle welcome message
function handleWelcome(data) {
currentUser = data.user;
users = data.users;
myNameElement.textContent = currentUser.name;
myAvatarElement.src = currentUser.avatar;
// Initialize unread counts for all users and group chat
unreadCounts[0] = 0; // Group chat
users.forEach(user => {
if (user.id !== currentUser.id) {
unreadCounts[user.id] = 0;
}
});
updateUserList();
addSystemMessage('Welcome to the chat room!');
}
// Handle user joined
function handleUserJoined(data) {
users = data.users;
// Initialize unread count for new user
if (!unreadCounts[data.user.id]) {
unreadCounts[data.user.id] = 0;
}
updateUserList();
addSystemMessage(`${data.user.name} has joined the chat`);
}
// Handle user left
function handleUserLeft(data) {
users = data.users;
updateUserList();
// If the user we were chatting with left, switch to group chat
if (selectedUserId !== 0 && !users.some(u => u.id === selectedUserId)) {
selectUser(0, 'Group Chat');
}
// Find the user who left
const leftUser = data.user || { name: 'A user' };
addSystemMessage(`${leftUser.name} has left the chat`);
}
// Handle user updated
function handleUserUpdated(data) {
users = data.users;
updateUserList();
// If we're chatting with the user who changed their name, update the chat title
if (selectedUserId === data.userId) {
chatTitle.textContent = `Chat with ${data.newName}`;
}
addSystemMessage(`${data.oldName} changed name to ${data.newName}`);
}
// Update user list
function updateUserList() {
// Clear existing users except group chat
const groupChatItem = usersList.querySelector('[data-user-id="0"]');
usersList.innerHTML = '';
usersList.appendChild(groupChatItem);
// Add users
users.forEach(user => {
if (user.id !== currentUser.id) {
const userItem = document.createElement('div');
userItem.className = `user-item ${selectedUserId === user.id ? 'active' : ''}`;
userItem.dataset.userId = user.id;
// Check if there are unread messages
const unreadHtml = unreadCounts[user.id] > 0
? `<div class="unread-count">${unreadCounts[user.id]}</div>`
: '';
userItem.innerHTML = `
<img src="${user.avatar}" alt="${user.name}" class="avatar">
<div>
<div>${user.name}</div>
</div>
<div class="user-status"></div>
${unreadHtml}
`;
userItem.addEventListener('click', () => selectUser(user.id, user.name));
usersList.appendChild(userItem);
}
});
// Update group chat unread count if any
if (unreadCounts[0] > 0 && groupChatItem) {
// Remove existing unread count if present
const existingUnread = groupChatItem.querySelector('.unread-count');
if (existingUnread) {
existingUnread.remove();
}
// Add new unread count
const unreadElement = document.createElement('div');
unreadElement.className = 'unread-count';
unreadElement.textContent = unreadCounts[0];
groupChatItem.appendChild(unreadElement);
} else if (unreadCounts[0] === 0 && groupChatItem) {
// Remove unread count if it exists and count is zero
const existingUnread = groupChatItem.querySelector('.unread-count');
if (existingUnread) {
existingUnread.remove();
}
}
// Ensure group chat item has click handler
if (groupChatItem && !groupChatItem.hasClickHandler) {
groupChatItem.addEventListener('click', () => selectUser(0, 'Group Chat'));
groupChatItem.hasClickHandler = true;
}
// Update user count
userCount.textContent = `${users.length} ${users.length === 1 ? 'user' : 'users'} online`;
}
// Update unread count for a user
function updateUnreadCount(userId, change) {
if (!unreadCounts[userId]) {
unreadCounts[userId] = 0;
}
unreadCounts[userId] += change;
if (unreadCounts[userId] < 0) {
unreadCounts[userId] = 0;
}
updateUserList();
}
// Select a user to chat with
function selectUser(userId, userName) {
selectedUserId = userId;
chatTitle.textContent = userId === 0 ? 'Group Chat' : `Chat with ${userName}`;
// Reset unread count for this chat
updateUnreadCount(userId, -unreadCounts[userId]);
// Update active user item
document.querySelectorAll('.user-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`.user-item[data-user-id="${userId}"]`).classList.add('active');
// Load message history for selected chat
loadMessageHistory(userId);
// Close mobile menu
if (window.innerWidth <= 768) {
userListContainer.classList.remove('active');
}
// Focus on message input
messageInput.focus();
}
// Load message history for selected chat
function loadMessageHistory(chatId) {
// Clear current messages
chatMessages.innerHTML = '';
// Get history for this chat
const history = messageHistory[chatId] || [];
// Show empty state if no messages
if (history.length === 0) {
const emptyState = document.createElement('div');
emptyState.className = 'empty-state';
emptyState.innerHTML = `
<i class="fa fa-comments-o"></i>
<h3>No messages yet</h3>
<p>${chatId === 0 ? 'Start the conversation in group chat' : 'Send a message to start chatting'}</p>
`;
chatMessages.appendChild(emptyState);
return;
}
// Add all messages from history
history.forEach(msg => {
addMessageToDOM(msg, msg.isPrivate);
});
scrollToBottom();
}
// Add a message to the DOM
function addMessageToDOM(message, isPrivate) {
const isMyMessage = message.sender.id === currentUser.id;
const messageElement = document.createElement('div');
messageElement.className = `message ${isMyMessage ? 'me' : 'other'} ${isPrivate ? 'private' : ''}`;
const date = new Date(message.timestamp);
const timeString = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
messageElement.innerHTML = `
<img src="${message.sender.avatar}" alt="${message.sender.name}" class="message-avatar">
<div class="message-content">
${!isMyMessage ? `<div class="sender">${message.sender.name}</div>` : ''}
<div class="content">${escapeHtml(message.content)}</div>
<div class="time">${timeString}</div>
</div>
`;
chatMessages.appendChild(messageElement);
scrollToBottom();
}
// Add system message
function addSystemMessage(text) {
const messageElement = document.createElement('div');
messageElement.className = 'system-message';
messageElement.textContent = text;
// Add to group chat history
addMessageToHistory({
content: text,
sender: { id: 'system', name: 'System', avatar: 'https://picsum.photos/seed/system/200' },
timestamp: new Date().toISOString()
}, false);
// Only show if in group chat
if (selectedUserId === 0) {
chatMessages.appendChild(messageElement);
scrollToBottom();
} else {
// Update unread count for group chat
updateUnreadCount(0, 1);
}
}
// Scroll to bottom of chat messages
function scrollToBottom() {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Send a message
function sendMessage() {
const content = messageInput.value.trim();
if (!content || !socket || socket.readyState !== WebSocket.OPEN) {
return;
}
const message = {
content: content,
type: selectedUserId === 0 ? 'chatMessage' : 'privateMessage'
};
if (selectedUserId !== 0) {
message.recipientId = selectedUserId;
}
socket.send(JSON.stringify(message));
messageInput.value = '';
}
// Show notification
function showNotification(sender, message) {
// Show in-app notification badge
notificationSender.textContent = sender;
notificationMessage.textContent = message;
notificationBadge.classList.add('show');
// Hide after 5 seconds
setTimeout(() => {
notificationBadge.classList.remove('show');
}, 5000);
// Show browser notification if permission granted and tab is not active
if (Notification && Notification.permission === 'granted' && !document.hasFocus()) {
new Notification(`${sender} says:`, {
body: message,
icon: 'https://picsum.photos/seed/chatnotification/48'
});
}
}
// Event Listeners
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
sendMessage();
});
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
editNameElement.addEventListener('click', () => {
newNameInput.value = currentUser.name;
nameModal.classList.add('active');
newNameInput.focus();
});
cancelNameButton.addEventListener('click', () => {
nameModal.classList.remove('active');
});
confirmNameButton.addEventListener('click', () => {
const newName = newNameInput.value.trim();
if (newName && newName !== currentUser.name && newName.length <= 20) {
socket.send(JSON.stringify({
type: 'updateName',
name: newName
}));
nameModal.classList.remove('active');
}
});
newNameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
confirmNameButton.click();
}
});
mobileMenuToggle.addEventListener('click', () => {
userListContainer.classList.toggle('active');
});
// Close mobile menu when clicking outside
document.addEventListener('click', (e) => {
if (window.innerWidth <= 768 &&
!userListContainer.contains(e.target) &&
!mobileMenuToggle.contains(e.target) &&
userListContainer.classList.contains('active')) {
userListContainer.classList.remove('active');
}
});
// Handle window resize
window.addEventListener('resize', () => {
if (window.innerWidth > 768 && userListContainer) {
userListContainer.classList.remove('active');
}
});
// Initialize the app
initWebSocket();
</script>
</body>
</html>
- 通信协议:自定义JSON消息格式,支持不同类型的消息(群聊、私聊、系统通知等)
使用方法
- 安装依赖:
npm install ws - 启动服务器:
node server.js - 在浏览器中访问
http://localhost:3000 - 可以打开多个浏览器窗口模拟不同用户进行测试
总结
本文详细介绍了基于Node.js与WebSocket的实时聊天室系统的实现与优化过程。通过采用现代化的Web技术,我们构建了一个功能完善、体验优良的实时通信应用,支持群聊与私聊、用户管理、消息历史等核心功能,并通过多轮优化解决了未读消息计数、群聊选择等关键问题。
该系统的实现不仅展示了WebSocket在实时通信场景中的优势,也提供了一套可复用的技术方案与最佳实践,可为各类实时Web应用(如在线客服、协作工具、社交应用等)的开发提供参考。随着Web技术的不断发展,实时通信将在更多场景中发挥重要作用,掌握相关技术实现对于前端与后端开发者都具有重要意义。
Node.js WebSocket聊天室实现与优化

241

被折叠的 条评论
为什么被折叠?



