首页
/ Socket.IO 协作白板实战:从广播事件到实时多人绘图的完整实现剖析

Socket.IO 协作白板实战:从广播事件到实时多人绘图的完整实现剖析

2026-09-04 14:47:27作者:郁楠烈Hubert

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));

它做了三件事:

  1. 静态托管前端express.static(__dirname + '/public')public/ 下的 index.htmlmain.jsstyle.css 直接交给 Express 托管,因此无需额外的打包工具。
  2. 建立 Socket.IO 服务require('socket.io')(http) 将 Socket.IO 挂载到原生 http.Server 上,所有客户端连接都走这一入口。
  3. 核心转发逻辑:每当有客户端连接时,注册 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 默认为 truethis.serveClient(false !== opts.serveClient)),服务启动时通过 attachServeindex.ts)把 client-dist/ 目录下的预编译文件(对应 packages/socket.io/client-dist/socket.io.js 等)响应到该路径。因此示例客户端零安装成本,浏览器加载 socket.io.js 后即可获得全局 io 构造函数。

真正的绘图逻辑集中在 public/main.js,可以拆成四个要点:

1. 鼠标与触摸事件统一处理

客户端同时监听 mousedown/mousemove/mouseup/mouseouttouchstart/touchmove/touchend/touchcancelmain.js),并在坐标提取时用 e.clientX || e.touches[0].clientX 兼容两种事件源。触摸分支就是 README 未展开但示例实际支持的移动端能力。

2. 10ms 节流限制事件频率

mousemovetouchmove 的回调都经过了 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 颜色名,如 blackred,取自调色板元素的 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 布尔标志位控制笔的按下/抬起状态,mouseupmouseout 都会收笔,避免指针移出画布后仍继续连线。

端到端链路:一次笔迹的完整旅程

把两端串起来,一次协作绘图的完整链路是:

  1. 用户 A 在 canvas 上按住鼠标拖动,onMouseMove 节流后每段线段调用 drawLine(..., true)
  2. 本地 2D 上下文立刻绘制该线段(自己无感知延迟);
  3. 同时 socket.emit('drawing', {...归一化坐标, color}),Socket.IO 经 Engine.IO 传输层(优先 WebSocket)送达服务端;
  4. 服务端 index.js 的监听器触发,socket.broadcast.emit('drawing', data)BroadcastOperator → adapter 分发给房间内其他 socket;
  5. 用户 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.onbroadcast.emitserveClient)的一次端到端演练,是学习 Socket.IO 事件模型时值得第一个跑通的示例。

登录后查看全文
热门项目推荐
相关项目推荐