WebSocket与Canvas实现实时多人协作白板系统完整开发指南

2026-03-07 0 641
免费资源下载
作者:全栈开发工程师
发布日期:2023年11月
难度等级:中级

一、项目概述与核心技术栈

1.1 项目目标

构建一个支持多人实时协作的在线白板系统,要求实现:

  • 实时同步所有用户的绘图操作
  • 支持多种绘图工具(画笔、矩形、圆形、文字)
  • 用户身份识别与颜色分配
  • 操作历史记录与回退
  • 画布缩放与平移
  • 离线恢复与数据同步

1.2 技术栈选择

组件 技术选型 理由
前端框架 原生JavaScript + Canvas API 轻量级,直接操作Canvas,性能最优
实时通信 WebSocket + Socket.IO 双向实时通信,自动重连,房间管理
后端服务 Node.js + Express 高并发处理,与WebSocket良好集成
数据存储 Redis + 内存存储 快速读写,会话管理,临时数据存储

二、系统架构设计

2.1 整体架构图

客户端 (浏览器)
    │
    ├── Canvas绘图层
    ├── 事件监听层
    └── WebSocket客户端
        │
        ▼
WebSocket服务器 (Node.js)
    │
    ├── 房间管理器
    ├── 消息广播器
    └── 状态同步器
        │
        ▼
数据存储层 (Redis)
    │
    ├── 房间状态
    ├── 用户信息
    └── 操作历史
                

2.2 数据协议设计

// 绘图操作数据结构
const drawOperation = {
    type: 'draw', // 操作类型:draw, erase, text, shape
    tool: 'pen', // 工具类型:pen, rectangle, circle, text
    userId: 'user_123',
    color: '#FF5733',
    lineWidth: 3,
    points: [ // 路径点数组
        {x: 100, y: 100, pressure: 0.5},
        {x: 150, y: 120, pressure: 0.7}
    ],
    timestamp: 1699876543210,
    roomId: 'room_abc'
};

// 系统消息数据结构
const systemMessage = {
    type: 'system',
    action: 'user_joined', // user_joined, user_left, clear_canvas
    userId: 'user_123',
    username: '张三',
    timestamp: 1699876543210,
    data: {} // 附加数据
};

三、前端Canvas绘图系统实现

3.1 Canvas初始化与分层设计

class WhiteboardCanvas {
    constructor(containerId) {
        this.container = document.getElementById(containerId);
        this.layers = {};
        this.initLayers();
        this.currentTool = 'pen';
        this.isDrawing = false;
        this.lastPoint = null;
        this.operations = [];
        this.operationIndex = -1;
    }
    
    initLayers() {
        // 创建三个Canvas层实现高性能渲染
        const layers = ['background', 'drawing', 'temp'];
        
        layers.forEach(layerName => {
            const canvas = document.createElement('canvas');
            canvas.width = this.container.clientWidth;
            canvas.height = this.container.clientHeight;
            canvas.style.position = 'absolute';
            canvas.style.left = '0';
            canvas.style.top = '0';
            canvas.style.cursor = 'crosshair';
            
            this.container.appendChild(canvas);
            this.layers[layerName] = {
                canvas,
                ctx: canvas.getContext('2d'),
                needsRedraw: false
            };
        });
        
        // 设置抗锯齿
        this.layers.drawing.ctx.imageSmoothingEnabled = true;
        this.layers.drawing.ctx.imageSmoothingQuality = 'high';
        
        // 绑定事件
        this.bindEvents();
    }
    
    bindEvents() {
        const drawingCanvas = this.layers.drawing.canvas;
        
        // 鼠标事件
        drawingCanvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
        drawingCanvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
        drawingCanvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
        drawingCanvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
        
        // 触摸事件支持
        drawingCanvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
        drawingCanvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
        drawingCanvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
        
        // 键盘事件
        document.addEventListener('keydown', this.handleKeyDown.bind(this));
    }
    
    handleMouseDown(e) {
        const point = this.getCanvasPoint(e);
        this.startDrawing(point);
        
        // 发送开始绘图事件到服务器
        this.socket.emit('draw_start', {
            point,
            tool: this.currentTool,
            color: this.currentColor,
            lineWidth: this.currentLineWidth
        });
    }
    
    getCanvasPoint(e) {
        const rect = this.layers.drawing.canvas.getBoundingClientRect();
        const scaleX = this.layers.drawing.canvas.width / rect.width;
        const scaleY = this.layers.drawing.canvas.height / rect.height;
        
        return {
            x: (e.clientX - rect.left) * scaleX,
            y: (e.clientY - rect.top) * scaleY,
            pressure: e.pressure || 0.5
        };
    }
}

3.2 绘图工具实现

class DrawingTools {
    constructor(canvasManager) {
        this.canvas = canvasManager;
        this.tools = {
            pen: this.drawPen.bind(this),
            rectangle: this.drawRectangle.bind(this),
            circle: this.drawCircle.bind(this),
            text: this.drawText.bind(this),
            eraser: this.erase.bind(this)
        };
    }
    
    drawPen(ctx, points, color, lineWidth) {
        if (points.length < 2) return;
        
        ctx.strokeStyle = color;
        ctx.lineWidth = lineWidth;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        
        ctx.beginPath();
        ctx.moveTo(points[0].x, points[0].y);
        
        // 使用贝塞尔曲线平滑路径
        for (let i = 1; i < points.length; i++) {
            const prev = points[i - 1];
            const curr = points[i];
            
            // 计算控制点
            const cp1 = {
                x: prev.x + (curr.x - prev.x) * 0.3,
                y: prev.y + (curr.y - prev.y) * 0.3
            };
            
            const cp2 = {
                x: prev.x + (curr.x - prev.x) * 0.7,
                y: prev.y + (curr.y - prev.y) * 0.7
            };
            
            ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, curr.x, curr.y);
        }
        
        ctx.stroke();
    }
    
    drawRectangle(ctx, startPoint, endPoint, color, lineWidth) {
        const width = endPoint.x - startPoint.x;
        const height = endPoint.y - startPoint.y;
        
        ctx.strokeStyle = color;
        ctx.lineWidth = lineWidth;
        ctx.setLineDash([]);
        
        ctx.beginPath();
        ctx.rect(startPoint.x, startPoint.y, width, height);
        ctx.stroke();
    }
    
    // 压力敏感绘制
    applyPressure(lineWidth, pressure) {
        const minWidth = lineWidth * 0.5;
        const maxWidth = lineWidth * 2;
        return minWidth + (maxWidth - minWidth) * pressure;
    }
}

3.3 WebSocket客户端管理

class WhiteboardSocket {
    constructor(roomId, userId) {
        this.roomId = roomId;
        this.userId = userId;
        this.socket = null;
        this.reconnectAttempts = 0;
        this.maxReconnectAttempts = 5;
        this.pendingOperations = [];
        this.isConnected = false;
    }
    
    connect() {
        this.socket = io('https://whiteboard-server.example.com', {
            query: {
                roomId: this.roomId,
                userId: this.userId
            },
            transports: ['websocket', 'polling'],
            reconnection: true,
            reconnectionAttempts: this.maxReconnectAttempts,
            reconnectionDelay: 1000
        });
        
        this.setupEventListeners();
    }
    
    setupEventListeners() {
        this.socket.on('connect', () => {
            console.log('WebSocket连接成功');
            this.isConnected = true;
            this.reconnectAttempts = 0;
            this.flushPendingOperations();
        });
        
        this.socket.on('draw_operation', (operation) => {
            this.handleRemoteOperation(operation);
        });
        
        this.socket.on('user_joined', (userData) => {
            this.notifyUserJoin(userData);
        });
        
        this.socket.on('user_left', (userId) => {
            this.notifyUserLeave(userId);
        });
        
        this.socket.on('canvas_state', (state) => {
            this.restoreCanvasState(state);
        });
        
        this.socket.on('disconnect', (reason) => {
            this.isConnected = false;
            this.handleDisconnection(reason);
        });
        
        this.socket.on('error', (error) => {
            console.error('WebSocket错误:', error);
        });
    }
    
    sendOperation(operation) {
        if (this.isConnected) {
            this.socket.emit('draw_operation', operation);
        } else {
            // 离线时暂存操作
            this.pendingOperations.push({
                operation,
                timestamp: Date.now()
            });
            
            // 限制队列大小
            if (this.pendingOperations.length > 100) {
                this.pendingOperations.shift();
            }
        }
    }
    
    flushPendingOperations() {
        while (this.pendingOperations.length > 0) {
            const pending = this.pendingOperations.shift();
            this.sendOperation(pending.operation);
        }
    }
}

四、后端WebSocket服务器实现

4.1 Node.js服务器核心代码

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const Redis = require('ioredis');

class WhiteboardServer {
    constructor() {
        this.app = express();
        this.server = http.createServer(this.app);
        this.io = new Server(this.server, {
            cors: {
                origin: process.env.CLIENT_URL,
                methods: ['GET', 'POST']
            },
            pingTimeout: 60000,
            pingInterval: 25000
        });
        
        this.redis = new Redis(process.env.REDIS_URL);
        this.rooms = new Map(); // 内存中房间状态
        this.userSessions = new Map();
        
        this.setupMiddleware();
        this.setupSocketHandlers();
    }
    
    setupSocketHandlers() {
        this.io.on('connection', (socket) => {
            const { roomId, userId } = socket.handshake.query;
            
            console.log(`用户连接: ${userId}, 房间: ${roomId}`);
            
            // 加入房间
            socket.join(roomId);
            
            // 初始化房间状态
            this.initializeRoom(roomId, userId, socket.id);
            
            // 通知其他用户有新用户加入
            socket.to(roomId).emit('user_joined', {
                userId,
                socketId: socket.id,
                timestamp: Date.now()
            });
            
            // 发送当前画布状态给新用户
            this.sendCanvasState(roomId, socket);
            
            // 绘图操作处理
            socket.on('draw_operation', (operation) => {
                this.handleDrawOperation(roomId, operation);
            });
            
            // 清除画布
            socket.on('clear_canvas', () => {
                this.clearCanvas(roomId, userId);
            });
            
            // 断开连接处理
            socket.on('disconnect', () => {
                this.handleDisconnect(roomId, userId, socket.id);
            });
            
            // 错误处理
            socket.on('error', (error) => {
                console.error(`Socket错误: ${error}`);
            });
        });
    }
    
    async initializeRoom(roomId, userId, socketId) {
        if (!this.rooms.has(roomId)) {
            this.rooms.set(roomId, {
                users: new Map(),
                operations: [],
                lastOperationId: 0,
                createdAt: Date.now()
            });
            
            // 从Redis恢复房间状态(如果存在)
            const savedState = await this.redis.get(`room:${roomId}`);
            if (savedState) {
                const state = JSON.parse(savedState);
                this.rooms.get(roomId).operations = state.operations;
            }
        }
        
        const room = this.rooms.get(roomId);
        room.users.set(userId, {
            socketId,
            joinedAt: Date.now(),
            lastActive: Date.now()
        });
        
        // 保存用户会话
        this.userSessions.set(socketId, { roomId, userId });
    }
    
    async handleDrawOperation(roomId, operation) {
        const room = this.rooms.get(roomId);
        if (!room) return;
        
        // 分配操作ID
        operation.id = ++room.lastOperationId;
        operation.receivedAt = Date.now();
        
        // 添加到操作历史
        room.operations.push(operation);
        
        // 限制历史记录大小
        if (room.operations.length > 1000) {
            room.operations = room.operations.slice(-500);
        }
        
        // 广播给房间内其他用户
        this.io.to(roomId).except(operation.userId).emit('draw_operation', operation);
        
        // 异步保存到Redis
        await this.saveRoomState(roomId);
    }
    
    async saveRoomState(roomId) {
        const room = this.rooms.get(roomId);
        if (!room) return;
        
        const state = {
            operations: room.operations.slice(-200), // 只保存最近200个操作
            lastOperationId: room.lastOperationId,
            updatedAt: Date.now()
        };
        
        await this.redis.setex(
            `room:${roomId}`,
            86400, // 24小时过期
            JSON.stringify(state)
        );
    }
    
    sendCanvasState(roomId, socket) {
        const room = this.rooms.get(roomId);
        if (!room) return;
        
        // 发送最近的操作历史
        const recentOperations = room.operations.slice(-100);
        socket.emit('canvas_state', {
            operations: recentOperations,
            lastOperationId: room.lastOperationId
        });
    }
    
    handleDisconnect(roomId, userId, socketId) {
        const room = this.rooms.get(roomId);
        if (!room) return;
        
        // 移除用户
        room.users.delete(userId);
        this.userSessions.delete(socketId);
        
        // 通知其他用户
        this.io.to(roomId).emit('user_left', {
            userId,
            timestamp: Date.now()
        });
        
        // 如果房间为空,清理资源
        if (room.users.size === 0) {
            setTimeout(() => {
                if (room.users.size === 0) {
                    this.rooms.delete(roomId);
                    this.redis.del(`room:${roomId}`);
                }
            }, 300000); // 5分钟后清理
        }
    }
    
    start(port = 3000) {
        this.server.listen(port, () => {
            console.log(`白板服务器运行在端口 ${port}`);
        });
    }
}

// 启动服务器
const server = new WhiteboardServer();
server.start(process.env.PORT || 3000);

五、性能优化与高级特性

5.1 数据压缩与批处理

class OperationOptimizer {
    constructor() {
        this.batchInterval = 50; // 50ms批处理间隔
        this.batchBuffer = [];
        this.batchTimer = null;
    }
    
    // 路径点压缩算法(道格拉斯-普克算法)
    compressPoints(points, tolerance = 1.0) {
        if (points.length < 3) return points;
        
        const firstPoint = points[0];
        const lastPoint = points[points.length - 1];
        
        let maxDistance = 0;
        let maxIndex = 0;
        
        for (let i = 1; i  maxDistance) {
                maxDistance = distance;
                maxIndex = i;
            }
        }
        
        if (maxDistance > tolerance) {
            const left = this.compressPoints(
                points.slice(0, maxIndex + 1),
                tolerance
            );
            const right = this.compressPoints(
                points.slice(maxIndex),
                tolerance
            );
            
            return left.slice(0, -1).concat(right);
        } else {
            return [firstPoint, lastPoint];
        }
    }
    
    // 批处理发送
    batchOperation(operation) {
        this.batchBuffer.push(operation);
        
        if (!this.batchTimer) {
            this.batchTimer = setTimeout(() => {
                this.sendBatch();
            }, this.batchInterval);
        }
    }
    
    sendBatch() {
        if (this.batchBuffer.length === 0) return;
        
        const batch = {
            type: 'batch',
            operations: this.batchBuffer,
            count: this.batchBuffer.length,
            timestamp: Date.now()
        };
        
        // 发送批处理数据
        this.socket.sendBatch(batch);
        
        // 清空缓冲区
        this.batchBuffer = [];
        this.batchTimer = null;
    }
}

5.2 离线同步与冲突解决

class OfflineManager {
    constructor() {
        this.localOperations = [];
        this.syncedOperations = new Set();
        this.isOnline = navigator.onLine;
        
        this.initEventListeners();
        this.loadFromStorage();
    }
    
    initEventListeners() {
        window.addEventListener('online', () => {
            this.isOnline = true;
            this.syncWithServer();
        });
        
        window.addEventListener('offline', () => {
            this.isOnline = false;
        });
    }
    
    saveOperation(operation) {
        // 添加本地时间戳和唯一ID
        operation.localId = this.generateLocalId();
        operation.localTimestamp = Date.now();
        operation.version = 1;
        
        this.localOperations.push(operation);
        this.saveToStorage();
        
        if (this.isOnline) {
            this.sendToServer(operation);
        }
    }
    
    async syncWithServer() {
        const unsynced = this.localOperations.filter(
            op => !this.syncedOperations.has(op.localId)
        );
        
        if (unsynced.length === 0) return;
        
        // 按时间戳排序
        unsynced.sort((a, b) => a.localTimestamp - b.localTimestamp);
        
        for (const operation of unsynced) {
            try {
                await this.sendToServer(operation);
                this.syncedOperations.add(operation.localId);
            } catch (error) {
                console.error('同步失败:', error);
                break;
            }
        }
        
        this.cleanupSyncedOperations();
    }
    
    // 操作转换(OT)算法处理冲突
    transformOperation(localOp, remoteOp) {
        if (localOp.type !== remoteOp.type) {
            return localOp; // 不同类型操作不冲突
        }
        
        // 根据操作类型进行转换
        switch (localOp.type) {
            case 'draw':
                return this.transformDrawOperation(localOp, remoteOp);
            case 'erase':
                return this.transformEraseOperation(localOp, remoteOp);
            default:
                return localOp;
        }
    }
}

5.3 性能监控与调优

class PerformanceMonitor {
    constructor() {
        this.metrics = {
            fps: 0,
            latency: 0,
            operationRate: 0,
            memoryUsage: 0
        };
        
        this.startMonitoring();
    }
    
    startMonitoring() {
        // FPS监控
        let frameCount = 0;
        let lastTime = performance.now();
        
        const measureFPS = () => {
            frameCount++;
            const currentTime = performance.now();
            
            if (currentTime - lastTime >= 1000) {
                this.metrics.fps = Math.round(
                    (frameCount * 1000) / (currentTime - lastTime)
                );
                frameCount = 0;
                lastTime = currentTime;
            }
            
            requestAnimationFrame(measureFPS);
        };
        
        requestAnimationFrame(measureFPS);
        
        // 内存监控
        if (performance.memory) {
            setInterval(() => {
                this.metrics.memoryUsage = 
                    performance.memory.usedJSHeapSize / 1024 / 1024; // MB
            }, 5000);
        }
        
        // 网络延迟监控
        this.monitorLatency();
    }
    
    monitorLatency() {
        setInterval(() => {
            const start = Date.now();
            
            // 发送ping消息
            this.socket.emit('ping', { timestamp: start });
            
            this.socket.once('pong', (data) => {
                const latency = Date.now() - start;
                this.metrics.latency = latency;
                
                // 根据延迟调整批处理间隔
                if (latency > 200) {
                    this.adjustBatchInterval(true);
                } else if (latency < 50) {
                    this.adjustBatchInterval(false);
                }
            });
        }, 10000);
    }
    
    getPerformanceReport() {
        return {
            ...this.metrics,
            timestamp: Date.now(),
            userAgent: navigator.userAgent,
            canvasSize: this.getCanvasSize()
        };
    }
}

// 页面交互增强
document.addEventListener(‘DOMContentLoaded’, function() {
// 代码块语法高亮
const codeBlocks = document.querySelectorAll(‘pre code’);
codeBlocks.forEach(block => {
// 添加行号
const lines = block.textContent.split(‘n’);
const lineNumbers = lines.map((_, i) => i + 1).join(‘n’);

const lineNumberDiv = document.createElement(‘div’);
lineNumberDiv.className = ‘line-numbers’;
lineNumberDiv.textContent = lineNumbers;

block.parentNode.insertBefore(lineNumberDiv, block);

// 复制功能
const copyButton = document.createElement(‘button’);
copyButton.textContent = ‘复制’;
copyButton.addEventListener(‘click’, async function() {
try {
await navigator.clipboard.writeText(block.textContent);
copyButton.textContent = ‘已复制’;
setTimeout(() => {
copyButton.textContent = ‘复制’;
}, 2000);
} catch (err) {
console.error(‘复制失败:’, err);
}
});

block.parentNode.style.position = ‘relative’;
copyButton.style.position = ‘absolute’;
copyButton.style.top = ‘5px’;
copyButton.style.right = ‘5px’;
copyButton.style.padding = ‘2px 8px’;
copyButton.style.fontSize = ’12px’;
copyButton.style.cursor = ‘pointer’;

block.parentNode.appendChild(copyButton);
});

// 表格样式增强
const tables = document.querySelectorAll(‘table’);
tables.forEach(table => {
table.addEventListener(‘mouseover’, function(e) {
if (e.target.tagName === ‘TD’) {
const row = e.target.parentElement;
row.style.backgroundColor = ‘#f8f9fa’;
}
});

table.addEventListener(‘mouseout’, function(e) {
if (e.target.tagName === ‘TD’) {
const row = e.target.parentElement;
row.style.backgroundColor = ”;
}
});
});

// 导航高亮
const sections = document.querySelectorAll(‘section’);
const navLinks = document.querySelectorAll(‘nav a’);

const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.getAttribute(‘id’);
navLinks.forEach(link => {
link.style.fontWeight =
link.getAttribute(‘href’) === `#${id}`
? ‘bold’
: ‘normal’;
});
}
});
},
{ threshold: 0.5 }
);

sections.forEach(section => observer.observe(section));
});

WebSocket与Canvas实现实时多人协作白板系统完整开发指南
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

淘吗网 html WebSocket与Canvas实现实时多人协作白板系统完整开发指南 https://www.taomawang.com/web/html/1658.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务