1. 项目概述:一个开源的在线白板协作工具
最近在调研团队协作工具时,发现了一个挺有意思的开源项目——liruifengv/we-drawing。简单来说,这是一个基于 Web 技术实现的在线白板绘图工具。你可以把它理解成一个简化版的、开源的“Miro”或“Figma Jam”,核心功能就是让多个用户能在一个共享的画布上,实时地绘制图形、添加文字、进行标注和讨论。
这个项目吸引我的点在于它的“纯粹性”。它没有那些大型商业协作平台里复杂的项目管理、看板、文档集成等功能,就是聚焦在“画”这件事上。对于需要快速进行头脑风暴、技术架构图评审、UI线框图讨论,或者远程教学时进行板书演示的场景,这种轻量、即开即用的工具往往比功能庞杂的平台更有效率。你不用去考虑创建团队、设置权限、关联项目等一系列前置操作,分享一个链接,大家就能一起画。
从技术栈来看,它主要基于前端技术实现,这意味着部署成本极低,甚至可以作为一个组件嵌入到你自己的系统中。对于开发者而言,一个成熟的开源项目不仅是工具,更是一个优秀的学习案例,我们可以从中学习到如何实现实时协作、图形绘制、状态同步等前端领域的核心难点。
2. 核心功能与架构设计思路拆解
2.1 功能模块解析
we-drawing的核心功能可以拆解为以下几个模块:
图形绘制工具集:这是基础。项目提供了常见的绘图工具,如自由画笔(用于涂鸦)、直线、箭头、矩形、圆形、三角形等基本几何图形。每种工具都有对应的属性可调,比如线条颜色、粗细、填充色、虚线样式等。这部分功能依赖于 Canvas 或 SVG 的绘图 API 实现。
文本与标注系统:除了图形,在协作中插入文字说明至关重要。项目需要实现一个文本工具,允许用户在画布任意位置点击并输入文字,并能对文字的字体、大小、颜色进行设置。更进阶一点,可能还包括便签(Sticky Note)功能,即一个带有背景色的文字区域。
选择与变换操作:用户绘制完图形或文字后,需要能够选中它们进行后续操作。这包括移动位置、调整大小(缩放)、旋转,以及复制、粘贴、删除、调整图层顺序(置顶/置底)等。这部分涉及到图形学中的“点选检测”(Hit Testing)和变换矩阵计算。
实时协作引擎:这是项目的灵魂。允许多个用户光标同时出现在画布上,并实时看到彼此的绘制操作。这通常通过 WebSocket 实现双向通信。当一个用户画了一笔,这个操作(如:从坐标A到坐标B,用红色画笔)会被序列化为一个操作指令(Operation),通过 WebSocket 广播给房间内的所有其他用户。其他用户的客户端收到指令后,在本地重现这一笔,从而实现“你画我见”的效果。
画布与状态管理:整个白板的内容(所有图形对象、它们的位置、属性)构成了应用的状态。如何高效地管理这个状态,并使其与视图(Canvas)同步,是关键。通常会采用一种中心化的状态管理方案,例如基于 Redux 或 Mobx 的模式,或者使用专门的图形库的状态管理机制。
房间与会话管理:支持创建不同的“房间”(Room)或“画板”(Board)。每个房间有唯一的ID或链接,用户通过链接加入特定房间进行协作。这涉及到后端的房间管理、用户连接管理和权限控制(虽然开源版可能很简单)。
2.2 技术架构选型考量
为什么选择这样的技术路径?我们分析一下背后的逻辑:
纯前端主导:项目仓库名为
we-drawing,通常意味着其主体是一个前端应用。选择纯前端或前后端分离架构,最大的好处是部署简单、易于扩展。前端可以静态托管在 GitHub Pages、Vercel、Netlify 等任何静态网站托管服务上,后端协作服务可以单独部署。这降低了使用和贡献的门槛。实时通信方案:实时协作必然用到 WebSocket。但直接裸用 WebSocket 处理复杂的操作同步、冲突解决(当两个用户同时修改同一个图形时)会非常复杂。因此,更常见的做法是使用成熟的实时通信库或服务,例如 Socket.IO(它提供了更友好的 API 和自动重连等特性),或者直接使用专为协作场景设计的协议如 Yjs(一个用于构建协作应用的 CRDT 库)。如果
we-drawing采用了 Yjs,那它的协作实现会非常优雅和健壮,因为 Yjs 底层使用了 CRDT(无冲突复制数据类型)算法,能天然地解决操作冲突问题,无需中心化的锁机制。图形渲染层:绘制可以选择 SVG 或 Canvas。两者各有优劣:
- SVG:矢量图形,DOM 结构,每个图形元素都是独立的 DOM 节点,易于绑定事件(如点击选中)、通过 CSS 控制样式,并且缩放不失真。但当图形数量极多时(成千上万),DOM 性能会成为瓶颈。
- Canvas:位图绘制,性能更高,适合大量图形和频繁绘制的场景(如自由画笔)。但实现交互(选中、拖拽)需要手动计算,复杂度高,且缩放会模糊。 一个折中且强大的方案是使用Fabric.js或Konva.js这类 2D 图形库。它们基于 Canvas,但封装了完整的对象模型,每个图形都是一个对象,自带事件系统和变换能力,极大地简化了开发。我猜测
we-drawing很可能会基于这类库构建。
状态同步策略:这是协作的核心。除了上述提到的 CRDT(如 Yjs)方案,另一种常见方案是操作转换(OT, Operational Transformation)。OT 需要有一个中心服务器来接收、转换和排序所有操作,确保所有客户端最终状态一致。像 Google Docs 早期就使用 OT。对于开源项目,如果采用 OT,后端逻辑会相对复杂;如果采用 CRDT,则前端逻辑更重,但后端可以很简单,甚至是一个简单的 WebSocket 转发服务器。从项目名和定位看,采用基于 Yjs 的 CRDT 方案可能性很大,这样更容易实现去中心化的 P2P 协作(通过 WebRTC),或者搭配一个极简的后端。
3. 核心实现细节与关键技术点剖析
3.1 图形系统的对象模型设计
无论底层用 Fabric.js 还是自研,都需要抽象出一套图形对象模型。这是整个应用的基石。
一个基本的图形对象(GraphicObject)可能包含以下属性:
interface GraphicObject { id: string; // 唯一标识符,用于同步和查找 type: 'line' | 'rect' | 'circle' | 'text' | 'path'; // 图形类型 x: number; // 位置 y: number; width: number; height: number; rotation: number; // 旋转角度 fill: string; // 填充色 stroke: string; // 描边色 strokeWidth: number; // 描边粗细 // 其他类型特定属性,如: // - path: 对于自由画笔,是一系列点坐标 // - text: 文字内容、字体 // - points: 对于多边形,是顶点数组 }应用的状态(AppState)可能就是这样一个图形对象的数组:
interface AppState { objects: GraphicObject[]; selectedObjectId: string | null; // 当前选中的对象ID currentTool: 'select' | 'pen' | 'rect' | 'text'; // 当前使用的工具 // ... 其他全局状态,如颜色、线宽等 }所有用户的操作,本质上都是在修改这个AppState。例如,“画一个矩形”操作,就是向objects数组push一个新的GraphicObject。
3.2 实时协作的数据同步机制
假设项目采用了Yjs + WebSocket的方案,其数据流大致如下:
共享数据模型:Yjs 的核心是
Y.Doc,它是一个共享的 CRDT 文档。在前端,我们将AppState(特别是objects数组)映射到Y.Doc中的一个共享数据类型上,例如Y.Array。import * as Y from 'yjs'; const ydoc = new Y.Doc(); const yobjects = ydoc.getArray('objects'); // 共享的图形对象数组操作同步:当本地用户执行一个操作(如添加图形)时,我们并不直接修改本地的
AppState,而是修改共享的yobjects。// 用户画了一个矩形,生成矩形对象 rectObj yobjects.push([rectObj]); // 向共享数组插入新对象Yjs 会自动将这个本地更新计算成一个增量更新(
update)。网络同步:通过一个“Provider”(提供者)将
ydoc的更新通过 WebSocket 发送给服务器,并接收其他用户的更新。Yjs 有官方的y-websocket提供者。import { WebsocketProvider } from 'y-websocket'; const wsProvider = new WebsocketProvider( 'ws://your-websocket-server', 'room-name', // 房间ID ydoc );服务器(
ws://your-websocket-server)的作用主要是广播:它接收一个客户端发来的 Yjs 更新,然后转发给同一房间内的所有其他客户端。状态一致性:当其他客户端通过 WebSocket 收到这个更新后,Yjs 会将其应用到本地的
ydoc中。由于yobjects是共享的,它会自动更新。我们的 UI 层(如 React/Vue)需要监听yobjects的变化,并同步更新视图。yobjects.observe(event => { // yobjects 发生了变化,重新渲染画布 updateCanvas(yobjects.toArray()); // 将 Y.Array 转为普通数组用于渲染 });这样,任何一个用户的操作,都会通过修改共享数据模型,经由 Yjs 和 WebSocket,自动、无冲突地同步到所有用户的界面上。
注意:这里的关键是“状态同步”而非“操作同步”。我们同步的是最终的数据状态(图形对象列表),而不是具体的绘制动作指令。Yjs 的 CRDT 特性保证了即使网络有延迟、操作顺序不一致,所有客户端最终看到的数据状态都是一致的。这是比单纯广播操作指令更健壮的方案。
3.3 绘制性能与用户体验优化
当画布上图形很多,或者自由画笔路径非常复杂(包含成千上万个点)时,性能会成为挑战。
渲染优化:
- 脏矩形渲染:并非每次变化都重绘整个画布。可以计算发生变化的图形区域(“脏矩形”),只重绘这个区域。Fabric.js 等库内部已做了优化。
- 离屏 Canvas:对于复杂的、不常变化的背景或静态图形组,可以预先绘制到一个离屏的 Canvas 上,主 Canvas 渲染时直接复制(
drawImage)过来,减少重复绘制开销。 - 图形分组与缓存:将多个静态图形合并渲染到一个缓存 Canvas 中,同样用复制方式渲染。
数据优化:
- 自由画笔路径简化:监听鼠标移动会生成密集的点。直接存储和同步所有点数据量巨大。需要使用算法(如 Douglas-Peucker 算法)对路径进行简化,在保持形状大致不变的情况下,大幅减少点的数量。
- 增量同步:Yjs 本身传输的就是增量更新,已经是最优。但对于非 CRDT 方案,也需要设计只同步变化部分(delta)的协议。
交互优化:
- 防抖与节流:对于频繁触发的事件,如画笔移动、画布缩放,其对应的状态更新和网络同步必须使用防抖(debounce)或节流(throttle)技术,避免阻塞主线程和洪水般的网络请求。
- 本地先行(Optimistic UI):当用户执行一个操作(如移动图形)时,不要等待服务器确认后再更新本地UI。应该立即在本地更新UI,让操作感觉瞬时。如果后续服务器同步失败(极少数情况),再回滚。这能极大提升用户体验。
4. 从零开始搭建一个简易协作白板
为了更深入理解,我们抛开现有项目,构思如何用现代技术栈从头构建一个核心功能相似的协作白板。这里我们选择React + Fabric.js + Yjs + Socket.io的组合,因为它平衡了开发效率、功能强大和协作可靠性。
4.1 环境准备与项目初始化
首先,创建一个新的 React 应用,并安装核心依赖:
npx create-react-app my-whiteboard --template typescript cd my-whiteboard npm install fabric yjs y-websocket socket.io-client # 或者使用 yarn我们选择 TypeScript 以获得更好的类型安全和开发体验。fabric是图形库,yjs是 CRDT 库,y-websocket是 Yjs 的 WebSocket 提供者,它内部封装了 Socket.io 客户端。
4.2 构建基础画布与图形工具
初始化 Fabric 画布: 创建一个
Whiteboard组件,在useEffect中初始化 Fabric 画布。import React, { useEffect, useRef } from 'react'; import { Canvas } from 'fabric'; const Whiteboard: React.FC = () => { const canvasRef = useRef<HTMLCanvasElement>(null); const fabricCanvasRef = useRef<Canvas | null>(null); useEffect(() => { if (!canvasRef.current) return; // 初始化 Fabric 画布 fabricCanvasRef.current = new Canvas(canvasRef.current, { width: window.innerWidth * 0.8, height: window.innerHeight * 0.8, backgroundColor: '#f8f9fa', }); // 禁止 Fabric 自带的右键菜单 fabricCanvasRef.current.allowTouchScrolling = false; return () => { fabricCanvasRef.current?.dispose(); }; }, []); return <canvas ref={canvasRef} />; };实现工具切换与图形绘制: 我们维护一个当前工具的状态。根据不同的工具,为 Fabric 画布设置不同的绘制模式。
type Tool = 'select' | 'pen' | 'rect' | 'circle' | 'text'; const [currentTool, setCurrentTool] = useState<Tool>('select'); useEffect(() => { const canvas = fabricCanvasRef.current; if (!canvas) return; // 先清除所有事件监听,避免冲突 canvas.off('mouse:down'); canvas.off('mouse:move'); canvas.off('mouse:up'); switch (currentTool) { case 'pen': canvas.isDrawingMode = true; canvas.freeDrawingBrush = new fabric.PencilBrush(canvas); canvas.freeDrawingBrush.width = 5; canvas.freeDrawingBrush.color = '#ff0000'; break; case 'rect': canvas.isDrawingMode = false; canvas.on('mouse:down', (opt) => startDrawing(opt, 'rect')); // ... mouse:move, mouse:up 逻辑 break; case 'select': canvas.isDrawingMode = false; canvas.selection = true; // 允许 Fabric 默认的选择行为 break; // ... 其他工具 } }, [currentTool]); const startDrawing = (opt: fabric.IEvent, shape: string) => { const canvas = fabricCanvasRef.current; const pointer = canvas?.getPointer(opt.e); if (!pointer || !canvas) return; let drawingObject: fabric.Object; switch (shape) { case 'rect': drawingObject = new fabric.Rect({ left: pointer.x, top: pointer.y, width: 0, height: 0, fill: 'transparent', stroke: '#000', strokeWidth: 2, }); break; // ... 其他图形 } canvas.add(drawingObject); canvas.setActiveObject(drawingObject); // 记录起始点,并在 mouse:move 中更新图形尺寸 };这样,我们就有了一个本地可用的基础画板。
4.3 集成 Yjs 实现状态共享
这是将本地应用升级为协作应用的关键一步。
创建共享数据文档: 我们需要将 Fabric 画布上的所有对象同步到 Yjs 的共享数据中。一个直接的想法是用一个 Y.Array 来存储所有对象的序列化数据。
import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; // 1. 创建 Y.Doc const ydoc = new Y.Doc(); // 2. 获取共享的图形数组 const yobjects = ydoc.getArray<fabric.Object>('objects'); // 3. 连接到 WebSocket 服务器(假设服务器运行在本地3001端口) const wsProvider = new WebsocketProvider( 'ws://localhost:3001', // WebSocket 服务器地址 'my-room-1', // 房间名 ydoc );双向绑定:本地操作同步到 Yjs: 每当在本地画布上添加、修改或删除一个对象时,我们需要将这个变化同步到
yobjects。// 监听 Fabric 画布的对象添加事件 canvas.on('object:added', (e: fabric.IEvent) => { const obj = e.target; if (obj && !obj.__isFromRemote) { // 避免远程同步来的对象再次触发同步 const serialized = obj.toObject(); // 序列化 Fabric 对象 yobjects.push([serialized]); // 推送到共享数组 } }); // 监听对象修改事件(移动、缩放、旋转、样式更改) canvas.on('object:modified', (e: fabric.IEvent) => { const obj = e.target; if (obj && !obj.__isFromRemote) { const index = findObjectIndexInYArray(obj.id); // 需要为对象赋予唯一ID,并建立索引 if (index !== -1) { const serialized = obj.toObject(); yobjects.delete(index, 1); // 删除旧数据 yobjects.insert(index, [serialized]); // 插入新数据 } } });这里的关键是标记
__isFromRemote,用来区分是本地用户的操作还是远程同步来的操作,防止循环同步。双向绑定:Yjs 变化同步到本地画布: 监听
yobjects的变化,当远程更新到来时,更新本地画布。yobjects.observe(event => { event.changes.delta.forEach(change => { if (change.insert) { // 有新的对象插入 change.insert.forEach(serializedObj => { fabric.util.enlivenObjects([serializedObj], (enlivenedObjs) => { enlivenedObjs.forEach(obj => { obj.__isFromRemote = true; // 标记为远程对象 canvas.add(obj); }); }); }); } if (change.delete) { // 有对象被删除,需要在画布上找到并删除对应对象 // 实现略,需根据ID匹配 } if (change.retain) { // 对象被修改,需要更新画布上对应的对象 // 实现略,需根据ID匹配并更新属性 } }); });fabric.util.enlivenObjects是 Fabric 提供的 API,可以将序列化的对象数据重新实例化为可交互的 Fabric 对象。
4.4 搭建简易信令服务器
Yjs 的y-websocket需要一个 WebSocket 服务器来广播更新。我们可以用 Node.js 和ws库快速搭建一个,但y-websocket推荐使用其配套的服务器端库y-websocket/bin/server.js,或者自己实现一个简单的 Socket.io 服务器。
这里展示一个极简的 Socket.io 服务器,它只做房间内的消息广播:
// server.js const http = require('http'); const { Server } = require('socket.io'); const server = http.createServer(); const io = new Server(server, { cors: { origin: "*" } // 生产环境需限制来源 }); io.on('connection', (socket) => { // 客户端加入房间 socket.on('join-room', (roomId) => { socket.join(roomId); console.log(`Socket ${socket.id} joined room ${roomId}`); }); // 广播 Yjs 更新 socket.on('yjs-update', (roomId, update) => { // 广播给房间内除发送者外的所有人 socket.to(roomId).emit('yjs-update', update); }); socket.on('disconnect', () => { console.log(`Socket ${socket.id} disconnected`); }); }); server.listen(3001, () => { console.log('Signaling server running on port 3001'); });前端WebsocketProvider需要稍作配置以连接这个服务器并发送yjs-update事件。实际上,y-websocket有现成的后端实现,直接使用会更方便。
5. 部署、优化与扩展方向
5.1 项目部署实践
一个典型的we-drawing类项目部署包含两部分:
前端静态资源:将构建好的 React/Vue 等前端代码,部署到任何静态托管服务。
- Vercel/Netlify:最方便,关联 Git 仓库即可自动部署。适合演示和个人项目。
- GitHub Pages:免费,适合开源项目展示。需要在项目中配置
gh-pages等工具。 - 自有服务器/Nginx:将
build或dist目录下的文件放到 Nginx 的静态文件目录下即可。
后端信令服务器:部署上述的 Node.js 服务器。
- 云服务器:购买一台最低配置的云服务器(如 1核1G),使用
pm2进程管理工具来运行和守护你的server.js。 - Serverless/Function:如果服务器逻辑非常简单(仅转发消息),可以尝试将其部署为 Serverless 函数(如 Vercel Serverless Functions、AWS Lambda)。但需要注意 WebSocket 连接在 Serverless 环境下的保活和状态管理可能更复杂。
- Docker 容器化:编写
Dockerfile,将应用容器化,便于在任何支持 Docker 的环境(如 Kubernetes, 各大云平台的容器服务)上部署和扩展。
- 云服务器:购买一台最低配置的云服务器(如 1核1G),使用
实操心得:域名与 HTTPS:如果你使用自有域名,务必配置 HTTPS。现代浏览器对 WebSocket 的安全上下文有要求,非 HTTPS 的页面可能无法建立 WebSocket 连接(
ws://)。可以使用 Let‘s Encrypt 免费证书。在 Nginx 中配置反向代理,将wss://yourdomain.com/socket.io的请求代理到本地的 Node.js 服务器(如http://localhost:3001)。
5.2 性能与体验深度优化
压缩与序列化优化:Yjs 的更新数据是二进制的,本身比较高效。但如果你传输的是完整的 Fabric 对象序列化数据(JSON),当图形复杂时,数据量会很大。可以考虑:
- 自定义序列化:只传输变化的部分(delta)和必要的属性,而不是整个对象。
- 使用二进制协议:如 MessagePack 或 Protobuf,替代 JSON 进行网络传输,能减少数据包大小。
- 数据压缩:在 WebSocket 层面启用压缩(如
permessage-deflate扩展)。
离线与冲突处理:Yjs 的 CRDT 特性天然支持离线编辑。用户离线时的操作会保存在本地,重新上线后会自动与服务器状态合并,解决冲突。这是 CRDT 相比 OT 的一大优势。你需要确保前端正确初始化 Yjs 并可能搭配 IndexedDB 进行数据持久化。
光标与用户状态同步:除了图形,通常还需要同步用户的“存在感”,比如实时显示其他用户的鼠标光标或选区。这可以通过在 Yjs 中维护一个共享的
Y.Map来实现,存储每个连接用户的{ userId: { x, y, color, name } }信息,并高频更新(需要节流)。前端监听这个 Map 的变化,在画布上绘制其他用户的光标。
5.3 功能扩展思路
基础白板之上,可以添加很多提升生产力的功能:
- 模板与素材库:预置常用的图形模板(如 UML 符号、网络拓扑图标、家具图例),用户可以直接拖拽使用。
- 导入/导出:支持导入图片、PDF 作为背景;导出画布为 PNG、JPEG、SVG 或自定义的 JSON 格式(用于保存和恢复)。
- 历史版本与回滚:利用 Yjs 的能力,可以相对容易地实现操作历史记录,支持撤销/重做,甚至快照功能。
- 演示模式:将白板转换为幻灯片演示,引导观众视线。
- API 与嵌入:将白板封装成 Web Component 或 iframe 可嵌入的组件,方便集成到其他系统(如在线教育平台、协作软件)。
6. 常见问题与排查实录
在实际开发和运行这类协作应用时,你肯定会遇到一些坑。以下是我总结的一些典型问题及解决思路:
6.1 同步延迟或卡顿
- 现象:一个用户操作后,其他用户看到更新有明显延迟,或画布反应迟钝。
- 排查:
- 网络检查:打开浏览器开发者工具的 Network 面板,查看 WebSocket 消息的发送和接收时间。延迟是否发生在网络传输环节?
- 前端性能:在 Performance 面板录制一段时间内的操作,查看是否有长任务(Long Task)阻塞了渲染。可能是某个图形操作(如路径简化算法)或状态更新逻辑太耗时。
- 数据量:检查单次同步的数据包大小。自由画笔是否记录了过多未简化的点?序列化的对象是否包含了大量不必要的属性?
- 解决:
- 对自由画笔路径进行实时简化(采样)。
- 优化序列化,只传输必要的、变化的属性。
- 对高频操作(如移动图形)进行节流同步,例如每100ms同步一次位置,而不是每帧都同步。
- 检查 Yjs 和前端框架(如 React)的集成,确保没有不必要的重渲染。使用
React.memo或不可变数据优化。
6.2 图形状态不同步或闪烁
- 现象:不同客户端看到的图形位置、样式不一致,或者在同步时图形短暂消失又出现(闪烁)。
- 排查:
- 循环同步:这是最常见的原因。确保你的同步逻辑有明确的标志位(如前面提到的
__isFromRemote),防止本地因响应远程更新而触发的画布变化,再次被当作本地操作同步出去。 - ID 冲突:确保每个图形对象有一个全局唯一的、稳定的 ID。Fabric 对象默认的
uid在序列化/反序列化过程中可能变化。最好在创建对象时手动赋予一个唯一 ID(如uuid),并在序列化时保留它。 - 合并冲突:虽然 Yjs 解决了数据层面的冲突,但应用到视图层时,如果更新逻辑有问题(比如先删除旧对象再添加新对象,中间有短暂间隔),可能导致闪烁。应尽量使用“更新属性”的方式,而非“替换对象”。
- 循环同步:这是最常见的原因。确保你的同步逻辑有明确的标志位(如前面提到的
- 解决:
- 仔细检查“本地->Yjs”和“Yjs->本地”这两个方向的事件监听和数据处理逻辑,确保没有死循环。
- 统一 ID 生成和持久化方案。
- 在将远程更新应用到画布时,使用 Fabric 的
object.set({ ...properties })来更新现有对象,而不是先remove再add。
6.3 WebSocket 连接不稳定
- 现象:用户频繁断开重连,或新用户加入后看不到历史数据。
- 排查:
- 防火墙/代理:检查服务器防火墙是否开放了 WebSocket 端口(如 3001)。如果前端通过 HTTPS 访问,WebSocket 必须使用
wss://。 - 心跳与超时:网络不稳定或中间设备(如代理、负载均衡)可能断开空闲连接。需要实现心跳机制(ping/pong)。
- 状态持久化:Yjs 文档在服务器端是存在于内存中的。服务器重启后,所有房间状态丢失。新用户加入空房间,自然没有数据。
- 防火墙/代理:检查服务器防火墙是否开放了 WebSocket 端口(如 3001)。如果前端通过 HTTPS 访问,WebSocket 必须使用
- 解决:
- 服务器端配置正确的心跳和超时时间。Socket.io 和
ws库都有相关配置。 - 为 Yjs 文档添加持久化存储。
y-websocket服务器支持与数据库(如 LevelDB)或文件系统集成,将每个房间的 Yjs 文档状态持久化。服务器重启后可以从存储中恢复文档。 - 考虑使用更稳定的托管服务来处理 WebSocket 连接,例如 Pusher、Ably 或 Socket.io 的云服务。
- 服务器端配置正确的心跳和超时时间。Socket.io 和
6.4 移动端兼容性问题
- 现象:在手机或平板上,绘图不跟手,手势操作(缩放、平移画布)与图形绘制冲突。
- 排查:移动端的触摸事件与桌面端的鼠标事件不同,是多点触控。Fabric.js 虽然支持触摸,但默认的交互模式可能需要调整。
- 解决:
- 确保 Fabric 画布初始化时设置了
isTouch相关属性为true。 - 为画布添加专门的手势监听来处理双指缩放和平移。可能需要禁用 Fabric 默认的某些触摸行为,然后通过
hammer.js或pinch-zoom等库自定义手势,并同步更新 Fabric 画布的视口变换(viewportTransform)。 - 针对触摸屏优化 UI,如增大按钮点击区域。
- 确保 Fabric 画布初始化时设置了
开发这样一个看似简单的协作白板,实际上是对前端实时数据同步、图形处理、状态管理和性能优化的一次综合演练。liruifengv/we-drawing这样的项目为我们提供了一个绝佳的学习范本。无论是直接使用它,还是借鉴其思想自研,关键在于理解其背后“状态共享”的核心逻辑,以及如何在复杂的交互中保持数据的一致性和应用的流畅性。在实际动手时,从最简单的本地画板开始,逐步加入 Yjs 同步,再完善UI和功能,是一个稳妥且学习曲线平滑的路径。遇到同步问题,多利用 Yjs 的调试工具观察文档状态的变化,能帮你快速定位问题根源。