本文将详细介绍如何使用WebSocket和Node.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.js的ws库和express框架来构建。
项目初始化完成后,执行 node server.js 或 npm 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.js 和 Express 框架高效地管理连接与消息广播,前端则通过 Canvas API 提供流畅的绘图体验。这个项目非常适合作为学习Node.js和实时Web应用的入门实践,你可以在此基础上继续扩展,例如加入聊天室、形状工具、图片上传等功能。