发布日期: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));
});

