找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1709

积分

1

好友

242

主题
发表于 5 小时前 | 查看: 1| 回复: 0

本文将详细介绍如何使用WebSocketNode.js构建一个支持多人实时同步的共享画板应用。该项目不仅包含了完整的前后端代码,还实现了画笔、橡皮擦、在线人数统计、自动重连等实用功能,是学习网络/系统中实时通信协议应用的优秀案例。

项目效果演示

在开始编码之前,我们先通过动图来直观感受一下最终效果:多个用户可以在同一画布上实时绘画,每个人的操作都会立刻同步到所有在线用户的屏幕上。

共享画板实时协作演示

环境准备与项目初始化

首先,创建一个新的项目目录并初始化package.json文件。这个文件定义了项目的基本信息、启动脚本以及依赖包。

在项目根目录下打开终端,执行 npm install 来安装所有依赖。

{
  "name": "websocket-shared-whiteboard",
  "version": "1.0.0",
  "description": "基于WebSocket的共享画板应用",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "keywords": ["websocket", "whiteboard", "shared", "drawing"],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "ws": "^8.14.2",
    "express": "^4.18.2"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}

WebSocket 服务端核心实现 (server.js)

服务端是整个应用的中枢,负责管理所有客户端连接、转发绘画数据以及广播在线人数。我们使用Node.jsws库和express框架来构建。

项目初始化完成后,执行 node server.jsnpm run dev 来启动服务端。

const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const path = require('path');

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

// 存储所有连接的客户端
const clients = new Set();

// 提供静态文件服务(用于托管前端HTML/CSS/JS文件)
app.use(express.static(path.join(__dirname, 'public')));

// 广播当前在线用户数量
function broadcastUserCount() {
    const userCount = clients.size;
    const message = JSON.stringify({
        type: 'userCount',
        count: userCount
    });
    clients.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) {
            client.send(message);
        }
    });
    console.log('广播用户数量:', userCount);
}

// 清理已断开或无效的连接
function cleanupClients() {
    clients.forEach((client) => {
        if (client.readyState !== WebSocket.OPEN) {
            clients.delete(client);
        }
    });
}

// 处理新的WebSocket连接
wss.on('connection', (ws) => {
    console.log('新客户端已连接');
    clients.add(ws);
    // 广播用户数量更新
    broadcastUserCount();

    // 处理接收到的消息
    ws.on('message', (message) => {
        try {
            const data = JSON.parse(message);
            console.log('接收到消息:', data.type);

            // 详细记录绘画数据
            if (data.type === 'draw') {
                console.log('绘画数据详情:', {
                    x1: data.x1, y1: data.y1,
                    x2: data.x2, y2: data.y2,
                    color: data.color, size: data.size,
                    isEraser: data.isEraser
                });
                console.log('准备广播给', clients.size - 1, '个其他客户端');
            }

            // 处理客户端发来的心跳消息
            if (data.type === 'ping') {
                ws.send(JSON.stringify({ type: 'pong' }));
                return;
            }

            // 广播消息给所有其他客户端(实现同步)
            let broadcastCount = 0;
            clients.forEach((client) => {
                if (client !== ws && client.readyState === WebSocket.OPEN) {
                    client.send(message);
                    broadcastCount++;
                }
            });
            if (data.type === 'draw') {
                console.log('实际广播给了', broadcastCount, '个客户端');
            }
        } catch (error) {
            console.error('消息解析错误:', error);
        }
    });

    // 处理连接关闭
    ws.on('close', () => {
        console.log('客户端已断开连接');
        clients.delete(ws);
        broadcastUserCount();
    });

    // 处理连接错误
    ws.on('error', (error) => {
        console.error('WebSocket错误:', error);
        clients.delete(ws);
        broadcastUserCount();
    });

    // 向新连接的客户端发送欢迎消息
    ws.send(JSON.stringify({
        type: 'welcome',
        message: '欢迎来到共享画板!'
    }));
});

// 定时清理无效连接,防止内存泄露
setInterval(() => {
    const oldSize = clients.size;
    cleanupClients();
    if (oldSize !== clients.size) {
        broadcastUserCount();
    }
}, 30000); // 每30秒检查一次

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

// 优雅关闭处理
process.on('SIGTERM', () => {
    console.log('正在关闭服务器...');
    server.close(() => {
        console.log('服务器已关闭');
    });
});

客户端前端实现 (public/index.html)

前端负责提供交互界面、处理用户绘图操作并通过WebSocket与服务端通信。我们使用原生的HTML/CSS/JS开发,并利用Canvas API实现绘图功能。

将以下文件保存为 public/index.html,服务端配置好后,访问 http://localhost:3000/ 即可看到画板界面。

<!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: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            height: 100vh;
            color: #333;
            overflow: hidden;
        }
        .container {
            max-width: 1400px;
            margin: 0 auto;
            padding: 15px;
            background: rgba(255, 255, 255, 0.95);
            height: 100vh;
            box-shadow: 0 0 30px rgba(0, 0, 0, 0.1);
            box-sizing: border-box;
            display: flex;
            flex-direction: column;
        }
        /* 头部样式 */
        .header {
            background: #fff;
            padding: 20px;
            border-radius: 15px;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
            margin-top: 20px;
            margin-bottom: 20px;
            flex-shrink: 0;
        }
        .header h1 {
            text-align: center;
            color: #4a5568;
            margin-bottom: 20px;
            font-size: 2.5rem;
            text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
        }
        /* 工具栏样式 */
        .toolbar {
            display: flex;
            justify-content: center;
            align-items: center;
            flex-wrap: wrap;
            gap: 20px;
            margin-bottom: 15px;
        }
        .tool-group {
            display: flex;
            align-items: center;
            gap: 8px;
            background: #f8f9fa;
            padding: 10px 15px;
            border-radius: 25px;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        }
        .tool-group label {
            font-weight: 600;
            color: #4a5568;
            font-size: 0.9rem;
        }
        input[type="color"] {
            width: 40px;
            height: 40px;
            border: none;
            border-radius: 50%;
            cursor: pointer;
            transition: transform 0.2s ease;
        }
        input[type="color"]:hover {
            transform: scale(1.1);
        }
        input[type="range"] {
            width: 100px;
            height: 5px;
            background: #ddd;
            border-radius: 5px;
            outline: none;
        }
        input[type="range"]::-webkit-slider-thumb {
            appearance: none;
            width: 20px;
            height: 20px;
            background: #667eea;
            border-radius: 50%;
            cursor: pointer;
        }
        .btn {
            padding: 10px 20px;
            border: none;
            border-radius: 25px;
            background: #667eea;
            color: white;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s ease;
            font-size: 0.9rem;
        }
        .btn:hover {
            background: #5a67d8;
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
        }
        .btn.active {
            background: #4c51bf;
            box-shadow: 0 3px 10px rgba(76, 81, 191, 0.5);
        }
        /* 状态栏样式 */
        .status {
            display: flex;
            justify-content: space-between;
            align-items: center;
            font-size: 0.9rem;
            font-weight: 500;
        }
        #connectionStatus {
            padding: 5px 15px;
            border-radius: 20px;
            font-weight: 600;
        }
        .status-connected {
            background: #48bb78;
            color: white;
        }
        .status-disconnected {
            background: #f56565;
            color: white;
        }
        .status-error {
            background: #ed8936;
            color: white;
        }
        #onlineUsers {
            color: #4a5568;
            background: #e2e8f0;
            padding: 5px 15px;
            border-radius: 20px;
        }
        /* 画布容器样式 */
        .canvas-container {
            background: #fff;
            border-radius: 15px;
            padding: 20px;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
            margin-bottom: 20px;
            text-align: center;
            width: 100%;
            height: calc(100vh - 280px);
            min-height: 400px;
            position: relative;
            overflow: hidden;
        }
        #whiteboard {
            border: 3px solid #e2e8f0;
            border-radius: 10px;
            cursor: crosshair;
            box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
            background: #ffffff;
            display: block;
            margin: 0 auto;
            width: 100%;
            height: 100%;
        }
        .canvas-info {
            margin-top: 15px;
            color: #718096;
            font-style: italic;
        }
        /* 响应式设计 */
        @media (max-width: 768px) {
            .container {
                padding: 10px;
            }
            .header h1 {
                font-size: 2rem;
            }
            .toolbar {
                flex-direction: column;
                gap: 10px;
            }
            .tool-group {
                flex-wrap: wrap;
                justify-content: center;
            }
            #whiteboard {
                width: 100%;
                height: 400px;
            }
            .status {
                flex-direction: column;
                gap: 10px;
                text-align: center;
            }
        }
        @media (max-width: 480px) {
            .header h1 {
                font-size: 1.5rem;
            }
            .tool-group {
                padding: 8px 12px;
            }
            .btn {
                padding: 8px 16px;
                font-size: 0.8rem;
            }
            #whiteboard {
                height: 300px;
            }
        }
        /* 动画效果 */
        @keyframes fadeIn {
            from {
                opacity: 0;
                transform: translateY(20px);
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }
        .container>* {
            animation: fadeIn 0.5s ease-out;
        }
        /* 加载状态 */
        .loading {
            display: inline-block;
            width: 20px;
            height: 20px;
            border: 3px solid #f3f3f3;
            border-top: 3px solid #667eea;
            border-radius: 50%;
            animation: spin 1s linear infinite;
        }
        @keyframes spin {
            0% {
                transform: rotate(0deg);
            }
            100% {
                transform: rotate(360deg);
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <header class="header">
            <h1>🎨 共享画板</h1>
            <div class="toolbar">
                <div class="tool-group">
                    <label for="brushColor">画笔颜色:</label>
                    <input type="color" id="brushColor" value="#000000">
                </div>
                <div class="tool-group">
                    <label for="brushSize">画笔大小:</label>
                    <input type="range" id="brushSize" min="1" max="50" value="3">
                    <span id="brushSizeValue">3</span>px
                </div>
                <div class="tool-group">
                    <button id="clearCanvas" class="btn">清空画板</button>
                    <button id="eraser" class="btn">橡皮擦</button>
                    <button id="pen" class="btn active">画笔</button>
                </div>
            </div>
            <div class="status">
                <div id="connectionStatus" class="status-disconnected">未连接</div>
                <div id="onlineUsers">在线用户: 0</div>
            </div>
        </header>
        <main class="canvas-container">
            <canvas id="whiteboard" width="1200" height="600"></canvas>
            <div class="canvas-info">
                <p>鼠标拖拽进行绘画,你的作品会实时同步给其他用户!</p>
            </div>
        </main>
    </div>
    <script>
        class SharedWhiteboard {
            constructor() {
                this.canvas = document.getElementById('whiteboard');
                this.ctx = this.canvas.getContext('2d');
                this.isDrawing = false;
                this.currentTool = 'pen';
                this.brushColor = '#000000';
                this.brushSize = 3;
                this.lastX = 0;
                this.lastY = 0;

                this.ctx.lineCap = 'round';
                this.ctx.lineJoin = 'round';
                this.ctx.strokeStyle = this.brushColor;
                this.ctx.lineWidth = this.brushSize;

                // WebSocket相关状态管理
                this.ws = null;
                this.isConnected = false;
                this.reconnectInterval = 3000;
                this.heartbeatInterval = null;
                this.heartbeatTimer = 30000;

                this.initCanvas();
                this.initEventListeners();
                this.initWebSocket();
            }

            initCanvas() {
                this.ctx.lineCap = 'round';
                this.ctx.lineJoin = 'round';
                this.ctx.strokeStyle = this.brushColor;
                this.ctx.lineWidth = this.brushSize;
                // 设置画布背景为白色
                this.ctx.fillStyle = '#ffffff';
                this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
            }

            initEventListeners() {
                // 画布鼠标事件
                this.canvas.addEventListener('mousedown', this.startDrawing.bind(this));
                this.canvas.addEventListener('mousemove', this.draw.bind(this));
                this.canvas.addEventListener('mouseup', this.stopDrawing.bind(this));
                this.canvas.addEventListener('mouseout', this.stopDrawing.bind(this));

                // 触摸事件支持移动端
                this.canvas.addEventListener('touchstart', this.handleTouch.bind(this));
                this.canvas.addEventListener('touchmove', this.handleTouch.bind(this));
                this.canvas.addEventListener('touchend', this.stopDrawing.bind(this));

                // 工具栏事件监听
                document.getElementById('brushColor').addEventListener('change', (e) => {
                    this.brushColor = e.target.value;
                    this.ctx.strokeStyle = this.brushColor;
                });
                document.getElementById('brushSize').addEventListener('input', (e) => {
                    this.brushSize = e.target.value;
                    this.ctx.lineWidth = this.brushSize;
                    document.getElementById('brushSizeValue').textContent = this.brushSize;
                });
                document.getElementById('clearCanvas').addEventListener('click', () => {
                    this.clearCanvas();
                });
                document.getElementById('eraser').addEventListener('click', () => {
                    this.setTool('eraser');
                });
                document.getElementById('pen').addEventListener('click', () => {
                    this.setTool('pen');
                });
            }

            initWebSocket() {
                const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
                const wsUrl = `${protocol}//${window.location.host}`;
                this.ws = new WebSocket(wsUrl);

                this.ws.onopen = () => {
                    console.log('WebSocket连接已建立');
                    this.isConnected = true;
                    this.updateConnectionStatus('已连接', 'connected');
                    this.startHeartbeat();
                };

                this.ws.onmessage = (event) => {
                    try {
                        let messageData;
                        if (event.data instanceof Blob) {
                            event.data.text().then(text => {
                                try {
                                    const data = JSON.parse(text);
                                    this.handleWebSocketMessage(data);
                                } catch (error) {
                                    console.error('Blob数据解析错误:', error, '原始数据:', text);
                                }
                            });
                            return;
                        } else if (typeof event.data === 'string') {
                            messageData = event.data;
                        } else {
                            console.error('未知的数据类型:', typeof event.data, event.data);
                            return;
                        }
                        const data = JSON.parse(messageData);
                        this.handleWebSocketMessage(data);
                    } catch (error) {
                        console.error('消息解析错误:', error, '原始数据:', event.data);
                    }
                };

                this.ws.onclose = () => {
                    console.log('WebSocket连接已关闭');
                    this.isConnected = false;
                    this.updateConnectionStatus('连接断开', 'disconnected');
                    this.stopHeartbeat();
                    // 自动重连机制
                    setTimeout(() => {
                        if (!this.isConnected) {
                            this.initWebSocket();
                        }
                    }, this.reconnectInterval);
                };

                this.ws.onerror = (error) => {
                    console.error('WebSocket错误:', error);
                    this.updateConnectionStatus('连接错误', 'error');
                };
            }

            handleWebSocketMessage(data) {
                console.log('收到WebSocket消息:', data.type, data);
                switch (data.type) {
                    case 'welcome':
                        break;
                    case 'draw':
                        console.log('处理远程绘画数据:', data);
                        this.drawRemoteLine(data);
                        break;
                    case 'clear':
                        this.clearCanvas(false);
                        break;
                    case 'userCount':
                        this.updateOnlineUsers(data.count);
                        break;
                    case 'pong':
                        console.log('收到心跳响应');
                        break;
                }
            }

            startHeartbeat() {
                this.stopHeartbeat();
                this.heartbeatInterval = setInterval(() => {
                    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
                        this.ws.send(JSON.stringify({ type: 'ping' }));
                    }
                }, this.heartbeatTimer);
            }

            stopHeartbeat() {
                if (this.heartbeatInterval) {
                    clearInterval(this.heartbeatInterval);
                    this.heartbeatInterval = null;
                }
            }

            // 获取鼠标在画布上的精确坐标
            getCoordinates(e) {
                const rect = this.canvas.getBoundingClientRect();
                const scaleX = this.canvas.width / rect.width;
                const scaleY = this.canvas.height / rect.height;
                return {
                    x: (e.clientX - rect.left) * scaleX,
                    y: (e.clientY - rect.top) * scaleY
                };
            }

            startDrawing(e) {
                this.isDrawing = true;
                const coords = this.getCoordinates(e);
                this.lastX = coords.x;
                this.lastY = coords.y;
            }

            draw(e) {
                if (!this.isDrawing) return;
                const coords = this.getCoordinates(e);
                const currentX = coords.x;
                const currentY = coords.y;

                // 在本地画布上绘制
                this.drawLine(this.lastX, this.lastY, currentX, currentY, this.currentTool === 'eraser');

                // 通过WebSocket将绘画数据发送到服务端进行广播
                if (this.isConnected) {
                    this.sendDrawData({
                        x1: this.lastX,
                        y1: this.lastY,
                        x2: currentX,
                        y2: currentY,
                        color: this.currentTool === 'eraser' ? '#ffffff' : this.brushColor,
                        size: this.brushSize,
                        isEraser: this.currentTool === 'eraser'
                    });
                }

                this.lastX = currentX;
                this.lastY = currentY;
            }

            stopDrawing() {
                this.isDrawing = false;
            }

            drawLine(x1, y1, x2, y2, isEraser = false) {
                this.ctx.beginPath();
                this.ctx.moveTo(x1, y1);
                this.ctx.lineTo(x2, y2);
                if (isEraser) {
                    this.ctx.globalCompositeOperation = 'destination-out';
                } else {
                    this.ctx.globalCompositeOperation = 'source-over';
                }
                this.ctx.stroke();
            }

            drawRemoteLine(data) {
                console.log('开始绘制远程线条:', data);
                // 保存当前画笔设置
                const originalColor = this.ctx.strokeStyle;
                const originalWidth = this.ctx.lineWidth;
                const originalCompositeOperation = this.ctx.globalCompositeOperation;

                // 根据接收到的数据设置画笔
                this.ctx.strokeStyle = data.color || '#000000';
                this.ctx.lineWidth = data.size || 3;
                if (data.isEraser) {
                    this.ctx.globalCompositeOperation = 'destination-out';
                } else {
                    this.ctx.globalCompositeOperation = 'source-over';
                }
                this.ctx.lineCap = 'round';
                this.ctx.lineJoin = 'round';

                // 绘制从其他用户同步过来的线条
                this.ctx.beginPath();
                this.ctx.moveTo(data.x1, data.y1);
                this.ctx.lineTo(data.x2, data.y2);
                this.ctx.stroke();

                // 恢复原来的画笔设置
                this.ctx.strokeStyle = originalColor;
                this.ctx.lineWidth = originalWidth;
                this.ctx.globalCompositeOperation = originalCompositeOperation;
            }

            sendDrawData(data) {
                if (this.ws && this.ws.readyState === WebSocket.OPEN) {
                    this.ws.send(JSON.stringify({
                        type: 'draw',
                        ...data
                    }));
                }
            }

            clearCanvas(sendToServer = true) {
                this.ctx.fillStyle = '#ffffff';
                this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
                if (sendToServer && this.isConnected) {
                    this.ws.send(JSON.stringify({ type: 'clear' }));
                }
            }

            setTool(tool) {
                this.currentTool = tool;
                document.querySelectorAll('.btn').forEach(btn => btn.classList.remove('active'));
                document.getElementById(tool).classList.add('active');
                if (tool === 'eraser') {
                    this.canvas.style.cursor = 'grab';
                } else {
                    this.canvas.style.cursor = 'crosshair';
                }
            }

            handleTouch(e) {
                e.preventDefault();
                const touch = e.touches[0] || e.changedTouches[0];
                let eventType;
                switch (e.type) {
                    case 'touchstart':
                        eventType = 'mousedown';
                        break;
                    case 'touchmove':
                        eventType = 'mousemove';
                        break;
                    case 'touchend':
                        eventType = 'mouseup';
                        break;
                    default:
                        return;
                }
                const mouseEvent = new MouseEvent(eventType, {
                    clientX: touch.clientX,
                    clientY: touch.clientY,
                    bubbles: true,
                    cancelable: true
                });
                this.canvas.dispatchEvent(mouseEvent);
            }

            updateConnectionStatus(status, className) {
                const statusElement = document.getElementById('connectionStatus');
                statusElement.textContent = status;
                statusElement.className = `status-${className}`;
            }

            updateOnlineUsers(count) {
                document.getElementById('onlineUsers').textContent = `在线用户: ${count}`;
            }
        }

        // 响应式调整画布尺寸
        function resizeCanvas() {
            const canvas = document.getElementById('whiteboard');
            if (!canvas) return;
            const container = canvas.parentElement;
            const containerWidth = container.clientWidth - 40;
            const containerHeight = container.clientHeight - 40;

            canvas.style.width = containerWidth + 'px';
            canvas.style.height = containerHeight + 'px';
            canvas.width = containerWidth;
            canvas.height = containerHeight;
        }

        // 页面加载完成后初始化应用
        document.addEventListener('DOMContentLoaded', () => {
            resizeCanvas();
            const whiteboard = new SharedWhiteboard();
            console.log('共享画板已初始化');
            window.addEventListener('resize', resizeCanvas);
        });
    </script>
</body>
</html>

总结与部署

通过以上步骤,我们完成了一个功能完整的实时共享画板应用。本项目的核心在于利用 WebSocket 协议实现了双向、低延迟的通信,服务端使用 Node.jsExpress 框架高效地管理连接与消息广播,前端则通过 Canvas API 提供流畅的绘图体验。这个项目非常适合作为学习Node.js和实时Web应用的入门实践,你可以在此基础上继续扩展,例如加入聊天室、形状工具、图片上传等功能。




上一篇:技术团队管理反思:是什么在吞噬开发者的工作热情与效率?
下一篇:现代C++项目工程化实战指南:CMake、依赖管理与CI/CD
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-24 19:00 , Processed in 0.241115 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表