Socket.IO 协作白板实战:从广播事件到实时多人绘图的完整实现剖析
Socket.IO 官方仓库内置了一个最精简的多人协作示例——whiteboard 协作白板。本文以 examples/whiteboard/README.md 为主线,完整讲解该示例的启动方式、目录结构与运行配置,并逐行剖析服务端转发逻辑、客户端 Canvas 绘图与坐标归一化协议,结合 packages/socket.io 的源码说明 broadcast.emit 与 /socket.io/socket.io.js 自动托管的底层机制,读完即可掌握一个可复制、可扩展的实时协同应用开发套路。
项目结构与快速上手
whiteboard 示例位于 examples/whiteboard 目录,结构非常扁平:
examples/whiteboard/
├── index.js # Express + Socket.IO 服务端
├── package.json # 依赖与启动脚本
├── README.md
└── public/
├── index.html # 页面:canvas 画布 + 颜色选择器
├── main.js # 客户端绘图与事件收发逻辑
└── style.css # 画布铺满全屏的样式
按 README 的说明,启动方式为:
$ npm ci && npm start
然后浏览器访问 http://localhost:3000 即可。如果需要指定端口,通过环境变量 PORT 传入即可——这与服务端 index.js 中的一行代码一一对应:
const port = process.env.PORT || 3000;
// ...
http.listen(port, () => console.log('listening on port ' + port));
package.json 中声明了唯一的两个运行时依赖与启动脚本,版本约束值得注意:
{
"dependencies": {
"express": "~4.17.1",
"socket.io": "^4.0.0"
},
"scripts": {
"start": "node index"
}
}
也就是说,该示例面向的是 Socket.IO v4 及以上版本,客户端 SDK 同样来自 v4 的 socket.io.js 构建产物。
服务端:十行代码完成事件广播
整个服务端实现只有 index.js 这一个文件:
const express = require('express');
const app = express();
const http = require('http').Server(app);
const io = require('socket.io')(http);
const port = process.env.PORT || 3000;
app.use(express.static(__dirname + '/public'));
function onConnection(socket){
socket.on('drawing', (data) => socket.broadcast.emit('drawing', data));
}
io.on('connection', onConnection);
http.listen(port, () => console.log('listening on port ' + port));
它做了三件事:
- 静态托管前端:
express.static(__dirname + '/public')把public/下的index.html、main.js、style.css直接交给 Express 托管,因此无需额外的打包工具。 - 建立 Socket.IO 服务:
require('socket.io')(http)将 Socket.IO 挂载到原生http.Server上,所有客户端连接都走这一入口。 - 核心转发逻辑:每当有客户端连接时,注册
drawing事件监听器,收到绘图数据后通过socket.broadcast.emit('drawing', data)把同一个事件转发给除发送者以外的所有连接。
这里的关键是 broadcast。从 packages/socket.io/lib/socket.ts 的源码可以看到,socket.broadcast 是一个 getter,返回一个 BroadcastOperator 实例:
public get broadcast() {
return this.newBroadcastOperator();
}
newBroadcastOperator()(socket.ts)构造的 BroadcastOperator 底层会把当前 socket 标记为排除对象(except(socket.id)),再经 socket.io-adapter 分发给房间内的其他成员。也就是说,示例中一行 socket.broadcast.emit 背后是"构造广播操作符 → 排除发送者 → 适配器分发"这一整条链路。默认情况下所有 socket 都在同一个 ""(默认)房间,因此广播会到达所有在线连接——这正是 README 中描述的"所有其他用户会实时看到你的笔迹"的实现基础。
注意示例采用的是纯转发策略:服务端不存储任何画布状态。新加入的用户只能看到之后的笔迹,看不到已画好的内容。这是刻意保持最简的实现,若要补齐历史,可引入离线事件(
offline/connect回放)或将完整画布快照下发给新连接者。
客户端:Canvas 绘图 + 事件节流
页面结构 index.html 非常简洁:一个全屏 canvas、一个 5 色(黑/红/绿/蓝/黄)调色板,以及两个关键脚本:
<script src="/socket.io/socket.io.js"></script>
<script src="/main.js"></script>
注意客户端 SDK 不是通过 npm 引入的,而是直接由服务端托管:/socket.io/socket.io.js 是 Socket.IO 服务端自动挂载的构建产物。这一点在 packages/socket.io/lib/index.ts 中可以看到默认行为——serveClient 默认为 true(this.serveClient(false !== opts.serveClient)),服务启动时通过 attachServe(index.ts)把 client-dist/ 目录下的预编译文件(对应 packages/socket.io/client-dist/socket.io.js 等)响应到该路径。因此示例客户端零安装成本,浏览器加载 socket.io.js 后即可获得全局 io 构造函数。
真正的绘图逻辑集中在 public/main.js,可以拆成四个要点:
1. 鼠标与触摸事件统一处理
客户端同时监听 mousedown/mousemove/mouseup/mouseout 与 touchstart/touchmove/touchend/touchcancel(main.js),并在坐标提取时用 e.clientX || e.touches[0].clientX 兼容两种事件源。触摸分支就是 README 未展开但示例实际支持的移动端能力。
2. 10ms 节流限制事件频率
mousemove 与 touchmove 的回调都经过了 throttle(onMouseMove, 10) 包装(main.js)。throttle 是一个简易的时间阈值实现(main.js):距离上次执行不足 delay 毫秒就直接丢弃本次调用。绘图场景下鼠标移动事件可达每秒上百次,若不节流,高频 socket.emit 会迅速放大网络负载并挤占心跳/重连等内部消息。这里取 10ms(约每秒最多 100 次)是在流畅度与带宽之间的折中。
3. 坐标归一化:协议的核心设计
每次画出一段线段时,drawLine 既负责本地落笔,也负责把坐标除以画布宽高归一化为 0~1 的小数后发出(main.js):
function drawLine(x0, y0, x1, y1, color, emit){
context.beginPath();
context.moveTo(x0, y0);
context.lineTo(x1, y1);
context.strokeStyle = color;
context.lineWidth = 2;
context.stroke();
context.closePath();
if (!emit) { return; }
var w = canvas.width;
var h = canvas.height;
socket.emit('drawing', {
x0: x0 / w,
y0: y0 / h,
x1: x1 / w,
y1: y1 / h,
color: color
});
}
而收到他人笔迹时再做反向还原(main.js):
function onDrawingEvent(data){
var w = canvas.width;
var h = canvas.height;
drawLine(data.x0 * w, data.y0 * h, data.x1 * w, data.y1 * h, data.color);
}
因此 drawing 事件的报文协议固定为:
| 字段 | 类型 | 说明 |
|---|---|---|
x0 / y0 |
number | 线段起点,归一化坐标(0~1) |
x1 / y1 |
number | 线段终点,归一化坐标(0~1) |
color |
string | 颜色名,如 black、red,取自调色板元素的 CSS 类名 |
归一化的意义在于解耦坐标空间:不同设备的窗口尺寸不同(onResize 会把 canvas 设为 window.innerWidth/innerHeight,见 main.js),用相对坐标传输可以保证任何分辨率下笔迹都落在同一相对位置,而传输的数值又很小,报文更紧凑。
4. 调色板与状态管理
current.color 记录当前颜色,初始为 black;点击调色板时通过 e.target.className.split(' ')[1] 取出元素类名中的颜色词(main.js),与 style.css 中 .color.black/.color.red/... 一一对应。drawing 布尔标志位控制笔的按下/抬起状态,mouseup 和 mouseout 都会收笔,避免指针移出画布后仍继续连线。
端到端链路:一次笔迹的完整旅程
把两端串起来,一次协作绘图的完整链路是:
- 用户 A 在 canvas 上按住鼠标拖动,
onMouseMove节流后每段线段调用drawLine(..., true); - 本地 2D 上下文立刻绘制该线段(自己无感知延迟);
- 同时
socket.emit('drawing', {...归一化坐标, color}),Socket.IO 经 Engine.IO 传输层(优先 WebSocket)送达服务端; - 服务端 index.js 的监听器触发,
socket.broadcast.emit('drawing', data)经BroadcastOperator→ adapter 分发给房间内其他 socket; - 用户 B 的
socket.on('drawing', onDrawingEvent)回调把归一化坐标还原为本地像素坐标,在 B 的 canvas 上以同样方式画出线段。
整条链路中服务端是无状态的转发节点——不解析、不缓存、不校验,这也是该示例能把服务端压到十行代码的原因。
使用边界与可扩展方向
基于仓库中实际的代码,示例存在几个明确的能力边界,可作为二次开发的起点:
- 无历史记录:新连接的用户看不到之前的内容(见上文服务端分析)。可扩展为连接时向新 socket 单独下发画布快照,或用 adapter 的离线事件机制补齐断线期间漏掉的笔迹。
- 无房间概念:所有连接共享默认房间,即全局一块白板。利用
socket.join(room)/broadcast.to(room)即可把白板隔离为多人房间。 - 无持久化:笔迹只存在于内存画布中,刷新即丢失;可结合 examples/basic-crud-application 的思路把事件落库。
- 节流参数写死:10ms 节流在 main.js 中硬编码,若目标网络延迟更高,可适当放大以进一步降低带宽。
- 单实例假设:本示例的广播走进程内 adapter;若部署多实例,需要换成 examples/cluster-redis 等集群方案中的 Redis adapter,否则不同节点间的用户互相看不到笔迹。
从 packages/socket.io/test 目录可以看到,仓库用 Mocha 风格测试持续覆盖 namespace 路由、middleware、连接恢复等核心行为,whiteboard 这类官方示例实际上是在真实场景中对这些核心 API(io.on('connection')、socket.on、broadcast.emit、serveClient)的一次端到端演练,是学习 Socket.IO 事件模型时值得第一个跑通的示例。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00