前言
因为课程需要以及自己想找点事做的原因,在咨询GPT-5后,我决定开发一个协同画布进行练手。做的很粗糙,本蒟蒻再次球球大佬轻喷Orz。
Note:我同时使用Pad和PC书写这篇blog,所以会导致标点全角和半角混用,请见谅。
后端
前端比较好做,于是我们先来考虑后端。首先考虑的是架构问题。我最开始想使用传统的php,但是php更适合做轮询,而在我们这个项目中轮询的话首先对于服务器的压力较大,其次对于我们延迟的要求也比较高。于是我们转为websocket。只有当websocket不可用或者断开时, 我们再采取轮询重新建立连接或者作为降级方案.
WebSocket简介
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。浏览器或脚本先通过一次 HTTP 请求「升级」到 WebSocket,之后双方可以随时互相发数据,无需再发新的 HTTP 请求。
在常见的即时场景, 如聊天等地方用WebSocket能大大减少带宽使用, 节约服务器资源.
有以下常见WebSocket库:
- Node.js
ws:轻量、性能好,常见选择Socket.IO:在 WebSocket 之上封装(含自动重连、房间、回退到轮询等),但不等同于纯 WebSocket 协议
- Java
- Jakarta WebSocket(
javax.websocket/jakarta.websocket)标准 API(Tomcat/Jetty/Undertow 等) - Spring WebSocket / STOMP:常用于与 Spring 生态整合、支持消息代理
- Netty:更底层、更高性能与可定制
- Jakarta WebSocket(
- Go
gorilla/websocket:经典库nhooyr/websocket:更现代的实现
- Python
websockets、aiohttp(异步)- Django Channels(带频道层、适合与 Django 集成)
- .NET
- ASP.NET Core WebSockets / SignalR(SignalR 会做更高层封装与回退)
- PHP
- Ratchet、Swoole(Swoole 偏高性能常驻内存服务)
我们选择Node.js进行开发, 因为其与我们所熟悉的JavaScript极为相似, 有助于我们的初次开发.
Node.js初步起步
什么是Node.js
Node.js 是一个基于 Chrome V8 JavaScript 引擎构建的 JavaScript 运行时环境。简单来说,Node.js 让 JavaScript 可以在服务器端运行,而不仅仅局限于浏览器中。
1. 单线程事件循环
- Node.js 使用单线程的事件循环模型
- 通过事件驱动和回调函数处理并发请求
- 避免了传统多线程编程中的线程切换开销
2. 非阻塞 I/O
- 所有 I/O 操作(文件读写、网络请求等)都是异步的
- 当等待 I/O 操作完成时,程序不会被阻塞,可以继续处理其他任务
- 大大提高了应用程序的吞吐量
3. 跨平台
- 支持 Windows、macOS、Linux 等多种操作系统
- 一次编写,到处运行
4. 丰富的生态系统
- npm(Node Package Manager)拥有数百万个开源包
- 活跃的开发者社区
Node.js使用单线程异步处理吗所以避免了多线程可能引发的一切问题, 适合I/O密集型任务.
如何安装Node.js-docker
如果想让Node.js在docker环境下运行, 请确保你的服务器有docker环境并配置镜像源.
由于我是腾讯云服务器, 可以使用腾讯云镜像(仅限内网访问):
编辑配置文件:vim /etc/docker/daemon.json:
{
"registry-mirrors": [
"https://mirror.ccs.tencentyun.com"
],
"insecure-registries": [
"mirror.ccs.tencentyun.com"
]
}
重启docker:
systemctl daemon-reload
systemctl restart docker
接下来下载Node.js:
进入Node.js — Download Node.js®我们可以查询到官方下载命令:
Get Node.js®v24.14.0 (LTS) for Linux using Docker with npm
# Docker has specific installation instructions for each operating system.
# Please refer to the official documentation at https://docker.com/get-started/
# Pull the Node.js Docker image:
docker pull node:24-alpine
# Create a Node.js container and start a Shell session:
docker run -it --rm --entrypoint sh node:24-alpine
# Verify the Node.js version:
node -v # Should print "v24.14.0".
# Verify npm version:
npm -v # Should print "11.9.0".
如何安装node.js - apt-get install
由于我使用vscode-server进行开发, 它运行是直接执行node指令, 而不会进入docker环境, 所以我采取直接安装. 直接使用apt-get安装只会安装18版本的, 有点过于老了. 于是我们可以进Nodesource Node.js DEB来安装node.js 22.x LTS.
当我执行apt-get update时, 爆了公钥无法识别的问题: The following signatures couldn't be verified because the public key is not available: NO_PUBKEY 3B4FE6ACC0B21F32
这种情况下我们只需要导入公钥即可. 如果你和我一样是Ubuntu可以按照以下操作:
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32
这里的公钥3B4FE6ACC0B21F32替换为你上面报错的公钥.
接下来, 正常安装node.js即可. 如果安装成功, 理应可以通过node -v和npm -v查询版本号.
接下来, 创建Hello World.js:
console.log("Hello, node.js!")
运行:

大功告成!
基本框架
我们使用Socket.IO来作为创建WebSocket的框架服务. 它给了一个很方便的简易聊天室的demo教程, 我们稍作修改即可. 首先, 导入socket.io的modules:
import { Server } from "socket.io";
项目初始化
首先,在项目目录下新建package.json作为我们的描述文件。
键入以下文件:
{
"name": "auto-sync-drawboard",
"version": "0.0.1",
"description": "an app that allow users to draw anything in one drawboard and can automatically sync.",
"type": "module",
"dependencies": {}
}
接下来,安装我们所需的依赖:
npm install express@4
然后在index.js键入以下内容:
import express from 'express';
import { createServer } from 'node:http';
const app = express();
const server = createServer(app);
app.get('/', (req, res) => {
res.send('<h1>Hello world</h1>');
});
server.listen(4268, () => {
console.log('server running at http://localhost:4268');
});
运行。由于我已经将端口4268转发到draw.endermite.cn,于是可以直接打开draw.endermite.cn:

如果能够正常显示,那么已经有一个很好的开头了!
转发HTML
我们不难发现,如果我们将HTML全部写到我们的js里面将会导致臃肿以及难以维护,于是我们还是按照传统方式开发一个index.html将静态部分放入html文件中。(app.get部分是修改)
在index.js中加入以下内容:
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
app.get('/', (req, res) => {
res.sendFile(join(__dirname, 'index.html'));
});
然后我们再来简单写个前端demo。我使用gpt-5.3-codex(ai简直太好用了你知道吗)进行初步开发, 先做了份本地可以绘制的基本版本:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>协同画板 Demo(前端)</title>
<style>
:root{
--bg:#f5f7fb;
--panel:#ffffffd9;
--border:#e8ebf3;
--text:#1f2937;
--sub:#6b7280;
--primary:#3b82f6;
--shadow:0 10px 30px rgba(25,35,70,.08);
--r:16px;
--app-gap:10px;
--top-h:64px;
--footer-h:42px;
--side-w:280px;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{
width:100%;height:100%;margin:0;overflow:hidden;
font-family:"PingFang SC","Microsoft YaHei",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
color:var(--text);
background:
radial-gradient(1200px 600px at 10% -20%, #e8f0ff, transparent 60%),
radial-gradient(1000px 500px at 110% -10%, #e7fff7, transparent 55%),
var(--bg);
}
.app{
height:100dvh;
padding:12px;
display:grid;
grid-template-rows:var(--top-h) 1fr var(--footer-h);
gap:var(--app-gap);
}
.topbar,.footer,.tools,.board-wrap{
background:var(--panel);
border:1px solid var(--border);
box-shadow:var(--shadow);
backdrop-filter:blur(10px);
}
.topbar{
border-radius:var(--r);
display:flex;align-items:center;justify-content:space-between;
padding:0 14px;
z-index:3;
}
.brand{display:flex;align-items:center;gap:10px;min-width:0}
.logo{
width:38px;height:38px;border-radius:12px;display:grid;place-items:center;color:#fff;
background:linear-gradient(135deg,#3b82f6,#8b5cf6);
box-shadow:0 8px 20px rgba(59,130,246,.35);
font-size:18px;flex:0 0 auto;
}
.title{font-size:16px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.subtitle{font-size:12px;color:var(--sub);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.status{
display:flex;align-items:center;gap:8px;
color:#10b981;font-size:13px;background:#ecfdf5;border:1px solid #d1fae5;
padding:6px 10px;border-radius:999px;flex:0 0 auto;
}
.dot{width:8px;height:8px;border-radius:50%;background:#10b981;animation:pulse 1.8s infinite}
.main{
min-height:0;
display:grid;
grid-template-columns:var(--side-w) 1fr;
gap:var(--app-gap);
/* 关键:让中间区严格扣除上下高度,避免挡 footer */
height:calc(100dvh - 24px - var(--top-h) - var(--footer-h) - var(--app-gap) * 2);
}
.tools{
border-radius:var(--r);
padding:10px;
display:flex;flex-direction:column;gap:10px;
min-height:0;
overflow:auto; /* 防溢出 */
}
.group{
border:1px solid #eef1f7;border-radius:12px;padding:8px;background:#fff;
display:grid;gap:8px;
}
.tool-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.btn{
height:38px;border:1px solid var(--border);background:#fff;border-radius:10px;cursor:pointer;
display:grid;place-items:center;font-size:16px;transition:.16s;
}
.btn:hover{transform:translateY(-1px);box-shadow:0 8px 16px rgba(59,130,246,.12);border-color:#dbe5ff}
.btn.active{color:var(--primary);border-color:#bfdbfe;background:#eff6ff}
.label{font-size:12px;color:var(--sub);padding-left:2px}
input[type=range]{width:100%;accent-color:var(--primary);cursor:pointer}
/* Adobe 风格色盘 */
.picker-wrap{display:grid;gap:8px}
.sv-panel{
width:100%;
aspect-ratio:1.2/1;
border-radius:10px;
position:relative;
cursor:crosshair;
border:1px solid #dfe5f2;
overflow:hidden;
background:red;
touch-action:none;
}
.sv-white{
position:absolute;inset:0;
background:linear-gradient(to right,#fff,rgba(255,255,255,0));
}
.sv-black{
position:absolute;inset:0;
background:linear-gradient(to top,#000,rgba(0,0,0,0));
}
.sv-pointer{
position:absolute;width:14px;height:14px;border:2px solid #fff;border-radius:50%;
box-shadow:0 0 0 1px rgba(0,0,0,.35);
transform:translate(-7px,-7px);
pointer-events:none;
}
.hue{
-webkit-appearance:none;appearance:none;
height:12px;border-radius:999px;outline:none;border:1px solid #dfe5f2;
background:linear-gradient(90deg,
#ff0000 0%,
#ffff00 17%,
#00ff00 33%,
#00ffff 50%,
#0000ff 67%,
#ff00ff 83%,
#ff0000 100%);
}
.hue::-webkit-slider-thumb{
-webkit-appearance:none;appearance:none;
width:16px;height:16px;border-radius:50%;background:#fff;border:2px solid #111827;
box-shadow:0 1px 4px rgba(0,0,0,.25);
}
.color-preview{
height:28px;border-radius:8px;border:1px solid #dfe5f2;display:flex;align-items:center;justify-content:center;
font-size:12px;color:#111827;background:#fff;font-weight:600;
}
.board-wrap{
border-radius:var(--r);padding:10px;display:flex;flex-direction:column;gap:8px;min-height:0;
overflow:hidden;
}
.board-head{
display:flex;justify-content:space-between;align-items:center;gap:8px;
color:var(--sub);font-size:12px;padding:0 4px;
}
.canvas-box{
flex:1;min-height:0;border-radius:12px;border:1px dashed #dbe2f0;
background:
linear-gradient(90deg,#f9fbff 0 24px,transparent 24px) 0 0/25px 25px,
linear-gradient(#f9fbff 0 24px,transparent 24px) 0 0/25px 25px,
#fff;
overflow:hidden;position:relative;touch-action:none;
}
canvas{width:100%;height:100%;display:block;cursor:crosshair}
.footer{
border-radius:12px;display:flex;align-items:center;justify-content:space-between;
padding:0 12px;font-size:12px;color:var(--sub);
position:relative;z-index:5; /* 防止被画布视觉覆盖 */
}
.kbd{
border:1px solid #d7deee;border-bottom-width:2px;border-radius:6px;
padding:1px 6px;background:#fff;color:#4b5563;font-size:11px;margin-left:4px;
}
@media (max-width: 900px){
:root{
--top-h:56px;
--footer-h:40px;
}
.main{
grid-template-columns:1fr;
grid-template-rows:auto 1fr;
}
.tools{
flex-direction:row;
overflow-x:auto;
overflow-y:hidden;
padding:8px;
gap:8px;
align-items:stretch;
}
.group{
min-width:170px;
gap:6px;
padding:7px;
}
.subtitle{display:none}
.btn{height:34px}
.label{font-size:11px}
.status{font-size:12px;padding:5px 8px}
.picker-wrap .color-preview{height:24px;font-size:11px}
}
@keyframes pulse{
0%{box-shadow:0 0 0 0 rgba(16,185,129,.6)}
70%{box-shadow:0 0 0 9px rgba(16,185,129,0)}
100%{box-shadow:0 0 0 0 rgba(16,185,129,0)}
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo">🖊️</div>
<div>
<div class="title">协同画板 Demo</div>
<div class="subtitle">前端单机版 | Canvas | 预留协同接入</div>
</div>
</div>
<div class="status"><span class="dot"></span><span>在线(本地模式)</span></div>
</header>
<main class="main">
<aside class="tools">
<section class="group">
<div class="tool-row">
<button id="penBtn" class="btn active" title="画笔">✏️</button>
<button id="eraserBtn" class="btn" title="橡皮擦">🧽</button>
</div>
</section>
<section class="group">
<div class="label">颜色(Adobe 风格)🎨</div>
<div class="picker-wrap">
<div id="svPanel" class="sv-panel">
<div class="sv-white"></div>
<div class="sv-black"></div>
<div id="svPointer" class="sv-pointer"></div>
</div>
<input id="hueRange" class="hue" type="range" min="0" max="360" value="220" />
<div id="colorPreview" class="color-preview">#3B82F6</div>
</div>
</section>
<section class="group">
<div class="label">粗细 🧵</div>
<input id="sizeRange" type="range" min="1" max="32" value="4" />
<div class="tool-row">
<button id="undoBtn" class="btn" title="撤销">↩️</button>
<button id="redoBtn" class="btn" title="重做">↪️</button>
<button id="clearBtn" class="btn" title="清空">🗑️</button>
<button id="saveBtn" class="btn" title="保存">💾</button>
</div>
</section>
</aside>
<section class="board-wrap">
<div class="board-head">
<span>画布区域</span>
<span id="meta">颜色 #3B82F6 · 粗细 4px</span>
</div>
<div id="canvasBox" class="canvas-box">
<canvas id="board"></canvas>
</div>
</section>
</main>
<footer class="footer">
<div>提示:移动端支持手指绘制</div>
<div>快捷键 <span class="kbd">Ctrl/Cmd + Z</span><span class="kbd">Ctrl/Cmd + Y</span></div>
</footer>
</div>
<script>
const canvas = document.getElementById('board');
const box = document.getElementById('canvasBox');
const ctx = canvas.getContext('2d');
const penBtn = document.getElementById('penBtn');
const eraserBtn = document.getElementById('eraserBtn');
const sizeRange = document.getElementById('sizeRange');
const meta = document.getElementById('meta');
const undoBtn = document.getElementById('undoBtn');
const redoBtn = document.getElementById('redoBtn');
const clearBtn = document.getElementById('clearBtn');
const saveBtn = document.getElementById('saveBtn');
// 颜色选择器元素(Adobe 风格)
const svPanel = document.getElementById('svPanel');
const svPointer = document.getElementById('svPointer');
const hueRange = document.getElementById('hueRange');
const colorPreview = document.getElementById('colorPreview');
let isDrawing = false;
let mode = 'pen';
let size = Number(sizeRange.value);
let color = '#3B82F6';
// HSV
let h = 220, s = 0.75, v = 0.96;
// ====== 笔画事件模型 ======
let strokes = []; // 已确认笔画
let undoneStrokes = []; // 撤销缓存(重做用)
let currentStroke = null;
function genId() {
return 'stroke_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
}
// ---------- 颜色工具 ----------
function hsvToRgb(h, s, v) {
const c = v * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = v - c;
let r=0,g=0,b=0;
if (h < 60) [r,g,b] = [c,x,0];
else if (h < 120) [r,g,b] = [x,c,0];
else if (h < 180) [r,g,b] = [0,c,x];
else if (h < 240) [r,g,b] = [0,x,c];
else if (h < 300) [r,g,b] = [x,0,c];
else [r,g,b] = [c,0,x];
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
};
}
function rgbToHex(r,g,b) {
return '#' + [r,g,b].map(n => n.toString(16).padStart(2,'0')).join('').toUpperCase();
}
function updateColorUI() {
svPanel.style.background = `hsl(${h} 100% 50%)`;
const rgb = hsvToRgb(h, s, v);
color = rgbToHex(rgb.r, rgb.g, rgb.b);
colorPreview.style.background = color;
colorPreview.textContent = color;
colorPreview.style.color = v < .55 ? '#fff' : '#111827';
meta.textContent = `颜色 ${color} · 粗细 ${size}px`;
}
function setSVByClient(clientX, clientY) {
const rect = svPanel.getBoundingClientRect();
const x = Math.min(Math.max(0, clientX - rect.left), rect.width);
const y = Math.min(Math.max(0, clientY - rect.top), rect.height);
s = x / rect.width;
v = 1 - y / rect.height;
svPointer.style.left = `${x}px`;
svPointer.style.top = `${y}px`;
mode = 'pen';
setActiveTool(penBtn);
updateColorUI();
}
let pickSV = false;
svPanel.addEventListener('mousedown', e => { pickSV = true; setSVByClient(e.clientX, e.clientY); });
window.addEventListener('mousemove', e => { if (pickSV) setSVByClient(e.clientX, e.clientY); });
window.addEventListener('mouseup', () => pickSV = false);
svPanel.addEventListener('touchstart', e => {
pickSV = true;
const t = e.touches[0];
setSVByClient(t.clientX, t.clientY);
}, { passive: false });
window.addEventListener('touchmove', e => {
if (!pickSV) return;
const t = e.touches[0];
setSVByClient(t.clientX, t.clientY);
}, { passive: false });
window.addEventListener('touchend', () => pickSV = false);
hueRange.addEventListener('input', e => {
h = Number(e.target.value);
updateColorUI();
});
// ---------- 工具 ----------
function setActiveTool(btn) {
[penBtn, eraserBtn].forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
penBtn.onclick = () => { mode = 'pen'; setActiveTool(penBtn); };
eraserBtn.onclick = () => { mode = 'eraser'; setActiveTool(eraserBtn); };
sizeRange.addEventListener('input', e => {
size = Number(e.target.value);
meta.textContent = `颜色 ${color} · 粗细 ${size}px`;
});
// ---------- Canvas 尺寸 ----------
function resizeCanvas() {
const rect = box.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
redrawAll();
// 同步色盘指针位置
svPointer.style.left = `${s * svPanel.clientWidth}px`;
svPointer.style.top = `${(1 - v) * svPanel.clientHeight}px`;
}
// ---------- 绘制核心 ----------
function getPoint(e) {
const rect = canvas.getBoundingClientRect();
const p = e.touches ? e.touches[0] : e;
return {
x: p.clientX - rect.left,
y: p.clientY - rect.top,
t: Date.now(),
};
}
function drawStroke(stroke) {
const pts = stroke.points;
if (!pts || pts.length === 0) return;
ctx.save();
ctx.lineWidth = stroke.size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (stroke.mode === 'eraser') {
ctx.globalCompositeOperation = 'destination-out';
} else {
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = stroke.color;
}
// 只有一个点时画圆点
if (pts.length === 1) {
ctx.beginPath();
ctx.arc(pts[0].x, pts[0].y, stroke.size / 2, 0, Math.PI * 2);
if (stroke.mode === 'eraser') {
ctx.fillStyle = 'rgba(0,0,0,1)';
} else {
ctx.fillStyle = stroke.color;
}
ctx.fill();
ctx.restore();
return;
}
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.stroke();
ctx.restore();
}
function redrawAll() {
const rect = box.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
for (const s of strokes) drawStroke(s);
if (currentStroke) drawStroke(currentStroke); // 正在绘制时实时预览
}
// ---------- 输入事件 ----------
function startDraw(e) {
e.preventDefault();
isDrawing = true;
const p = getPoint(e);
currentStroke = {
id: genId(),
mode,
color,
size,
points: [p],
};
// 新笔画出现,重做栈清空(标准行为)
undoneStrokes = [];
redrawAll();
}
function moveDraw(e) {
if (!isDrawing || !currentStroke) return;
e.preventDefault();
const p = getPoint(e);
const pts = currentStroke.points;
const last = pts[pts.length - 1];
// 简单节流:距离太小就不加点,减少数据量
const dx = p.x - last.x;
const dy = p.y - last.y;
if (dx * dx + dy * dy < 1.5) return;
pts.push(p);
redrawAll();
}
function endDraw() {
if (!isDrawing) return;
isDrawing = false;
if (currentStroke && currentStroke.points.length) {
strokes.push(currentStroke);
}
currentStroke = null;
redrawAll();
}
canvas.addEventListener('mousedown', startDraw);
window.addEventListener('mousemove', moveDraw);
window.addEventListener('mouseup', endDraw);
canvas.addEventListener('touchstart', startDraw, { passive: false });
window.addEventListener('touchmove', moveDraw, { passive: false });
window.addEventListener('touchend', endDraw);
// ---------- 撤销 / 重做 / 清空 ----------
undoBtn.onclick = () => {
if (!strokes.length) return;
const s = strokes.pop();
undoneStrokes.push(s);
redrawAll();
};
redoBtn.onclick = () => {
if (!undoneStrokes.length) return;
const s = undoneStrokes.pop();
strokes.push(s);
redrawAll();
};
clearBtn.onclick = () => {
if (!strokes.length) return;
// 这里把全部移到 undone,保留可重做能力
undoneStrokes.push(...strokes);
strokes = [];
currentStroke = null;
redrawAll();
};
// ---------- 导出 ----------
saveBtn.onclick = () => {
// 注意:导出时以当前像素内容导出
// redrawAll 已经保证 canvas 是最新
const a = document.createElement('a');
a.download = `whiteboard-${Date.now()}.png`;
a.href = canvas.toDataURL('image/png');
a.click();
};
// ---------- 快捷键 ----------
window.addEventListener('keydown', e => {
const isMac = navigator.platform.toUpperCase().includes('MAC');
const cmd = isMac ? e.metaKey : e.ctrlKey;
if (!cmd) return;
if (e.key.toLowerCase() === 'z') {
e.preventDefault();
undoBtn.click();
} else if (e.key.toLowerCase() === 'y') {
e.preventDefault();
redoBtn.click();
}
});
window.addEventListener('resize', resizeCanvas);
// ---------- 初始化 ----------
updateColorUI();
requestAnimationFrame(() => {
svPointer.style.left = `${s * svPanel.clientWidth}px`;
svPointer.style.top = `${(1 - v) * svPanel.clientHeight}px`;
});
resizeCanvas();
</script>
</body>
</html>
ok, 接下来就是难点了,如何创建socket,接下来就没法用ai了
导入socket.io
socket.io由两个部分组成,一个是后端,一个是前端。
后端来说,我们只需要来执行一句npm install:
npm install socket.io
这一步会自动安装socket.io并把依赖添加至package.json。
在安装时,自动下载了前端所需文件。在node_modules/socket.io/client-dist/文件夹中, 但是我们需要将其加入public目录以方便暴露:
mkdir public
mkdir js
cp -r node_modules/socket.io/client-dist/* public/js/
接下来,我们在index.js中加入以下代码:
import { createServer} from 'node:http';//这一行代码为更改
import { Server } from 'socket.io';
app.use('/public', express.static(join(__dirname, 'public')));//这一行是为了暴露public目录
const io = new Server(server);
io.on('connection', (socket) => {
console.log('a user connected');
socket.on("disconnect", (reason) => {
console.log("disconnect:", socket.id, reason);
});
});
然后进入index.html,追加以下代码:
<script src="/public/js/socket.io.js"></script>
<script>
const socket = io("https://draw.endermite.cn", {
path: "/socket.io/",
transports: ["websocket", "polling"],
});
socket.on("connect", () => console.log("connect", socket.id));
socket.on("disconnect", (reason) => console.log("disconnect", reason));
socket.on("connect_error", (err) => console.log("connect_error", err.message));
</script>
这里的网址请自行替换
请注意官方的版本是有问题的, io里面还是要加网站, path, transports, 不然会弹transport error.
发送WebSocket信息
接下来,我们需要在用户抬起鼠标的时候将信息传递给服务器。由于我们之前是使用codex书写的html文件,我们先审查一下之前的代码。当然,这个部分在我们后续的步骤中同步进行。如果不是什么重要的我就不在blog中写了。
首先,我现在不打算在笔画结构体中加入身份信息,所以我们先去掉clear,undo等功能(毕竟只是一个demo)。
然后,我们应该在笔抬起来的时候向服务器发送笔画数据,于是在endDraw函数末尾加上:
socket.emit('stroke:add', currentStroke)
并在index.js中加入,以便我们查看信息:
io.on('connection', (socket) => {
socket.on('stroke:add', (msg) => {
console.log('stroke:add: ' + msg);
});
});
如果顺利,你应该能看到以下类似输出:

当然,把加号换成逗号就可以输出具体objects,但是这样太长了。
我们接下来就可以存储了:
let strokes = [];
io.on('connection', (socket) => {
socket.on('stroke:add', (stroke) => {
console.log('stroke:add: ' + stroke);
strokes.push(stroke);
socket.broadcast.emit(stroke);
});
});//这一部分自行和之前合并
我们通过push将笔画存入后端内存(如果需要可以自行改为长时存储),并通过broadcast将笔画发给除了本socket的其余socket。
然后我们需要加上初始化功能。只需要加上一种操作:init即可。
socket.emit('stroke:init', strokes);//在connection里面书写
接下来就只是一些简单的css等小地方的优化。也就是说,我们的协同画板第一版已经完美成功!

Comments NOTHING