基于Node.js与WebSocket的实时聊天室系统:从实现到优化

Node.js WebSocket聊天室实现与优化

文章目录

基于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),支持welcomeuserJoinednewMessage等多种消息类型
  • 消息路由机制:根据消息类型区分处理逻辑,群聊消息采用广播模式发送给所有在线用户,私聊消息则精准路由至指定接收者
  • 用户状态同步:当用户加入、离开或更新信息时,实时向所有在线用户推送最新的用户列表,确保客户端状态一致性

前端界面与交互设计

前端实现聚焦于用户体验与界面美观,主要特点包括:

  • 布局结构:采用左侧用户列表+右侧聊天区域的经典布局,用户列表展示当前在线用户与群聊选项,聊天区域包含消息展示与输入功能
  • 消息展示优化:通过视觉设计区分不同类型消息——自己发送的消息居右显示(绿色背景),他人消息居左显示(白色背景),私聊消息添加锁图标标记
  • 响应式适配:在移动设备上,用户列表可通过菜单按钮切换显示/隐藏,确保小屏设备上的操作便捷性
  • 微交互设计:添加消息发送/接收动画、按钮状态反馈、连接状态提示等细节,提升用户体验

系统优化迭代过程

第一轮优化:消息布局与历史记录

初始版本存在消息展示不够直观、缺乏历史记录的问题,优化措施包括:

  • 重构消息布局:采用左侧头像+右侧内容的结构,每条消息清晰显示发送者头像、名称、内容与时间戳
  • 实现消息历史存储:设计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消息格式,支持不同类型的消息(群聊、私聊、系统通知等)

使用方法

  1. 安装依赖:npm install ws
  2. 启动服务器:node server.js
  3. 在浏览器中访问 http://localhost:3000
  4. 可以打开多个浏览器窗口模拟不同用户进行测试

总结

本文详细介绍了基于Node.js与WebSocket的实时聊天室系统的实现与优化过程。通过采用现代化的Web技术,我们构建了一个功能完善、体验优良的实时通信应用,支持群聊与私聊、用户管理、消息历史等核心功能,并通过多轮优化解决了未读消息计数、群聊选择等关键问题。

该系统的实现不仅展示了WebSocket在实时通信场景中的优势,也提供了一套可复用的技术方案与最佳实践,可为各类实时Web应用(如在线客服、协作工具、社交应用等)的开发提供参考。随着Web技术的不断发展,实时通信将在更多场景中发挥重要作用,掌握相关技术实现对于前端与后端开发者都具有重要意义。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值