尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3项目5分钟接入Excalidraw白板:数据保存与多人同步实战

Vue3项目5分钟接入Excalidraw白板:数据保存与多人同步实战 上个月产品丢给我一个需求说要在后台详情页里加一块“能写能画的板”语气特别轻松不就是一块可以写字的画布嘛5分钟能搞定吧。我当时心里清楚这块“能写字的画布”真要自己从零写从坐标系到笔迹压感再到橡皮擦没个一周根本拿不出来。最后我选择了直接接入现成的在线白板插件从选型到跑通Demo加上封装成Vue组件、做数据保存总共花了一个下午如果只看“接入成功出白板”这一步真就是5分钟的事。这篇文章把完整思路和可复制的代码贴出来给同样有“临时塞一块白板”需求的Vue开发者参考。内容包括为什么选现成插件而不是自己画轮子、Excalidraw在Vue项目里的正确接法、白板数据的保存和恢复、多人同屏的轻量方案以及我实际踩过、能复现的坑。代码基于Vue3 Vite纯前端示例即可跑通后端我用接口占位替换成你自己的服务就行。1. 为什么说这种白板需求最好别自己从头写1.1 自己写白板要过多少关“不就是一个canvas监听鼠标事件画线吗”——这种想法我也有过。确实最基础的白板20行代码就能画线但一旦往真实使用场景里想事情就完全变样了图形选择与缩放矩形、圆形、箭头这些基础图形要支持框选、拖拽、缩放、旋转这背后是完整的图形数据模型和命中检测算法。撤销与重做每一笔操作都要入栈场景快照怎么存、内存怎么控制都是坑。橡皮擦的两种模式按元素擦除容易做按像素擦除需要自己处理canvas合成。无限画布与缩放平移白板的坐标系不是屏幕坐标viewport变换逻辑绕得很。导出图片 / 导出JSON要支持PNG、SVG导出意味着你保存的图形元数据要和渲染引擎对齐。触屏设备适配还要处理多点触控、画笔轨迹和手指拖动画布的手势冲突。把这些全部自己实现一遍工作量不低于做一个“简化版FigJam”。而绝大多数项目的真实需求只是“页面上能有一块好用的白板”并不是“我要做一个白板产品”。与其在业务项目里养一个自研白板长期维护不如直接选一个组织维护的开源方案。1.2 市面上能直接塞进Vue的成熟白板方案这里的“能直接塞进Vue”我指的是有npm包、有维护、社区有人用的方案。我整理了一张对比表方案底层技术上手难度多人协作包体积适合场景ExcalidrawReact低有官方协作服务可自托管较大可异步加载通用手绘白板、批注tldrawReact中自带store可接Yjs较大重构频繁想做重度编辑器Draw.io( mxgraph )原生JS中高无大流程图、架构图fabric.js 自研原生JS高无需自己写中等高度定制的画板真正适合紧急接入的是Excalidraw。它默认就自带一套好看的手绘风格UI画笔、图形、箭头、文本、图片、橡皮擦、缩放平移、导出图片、暗色主题全都有而且它的数据模型elements数组非常干净存到后端、再恢复渲染都方便。1.3 最终选Excalidraw的几个理由Excalidraw虽然是用React写的但Vue项目完全可以用官方React渲染器把组件挂载到一个div里两者互不干扰。它的完整JS包虽然不小但可以通过动态import按需加载不影响首屏性能。我选择它的核心理由是三点开箱即用的完整度最高、数据JSON化程度高且结构公开、在线协作有官方实现可以抄袭思路。第一点解决“5分钟出界面”第二点解决“保存恢复不费劲”第三点解决后面“多人看同一块板”的扩展需求。如果你后面的目标是把白板做成一个完整产品那tldraw更值得研究它的架构意识更强。但绝大多数后台项目、协同办公项目、在线教学项目里Excalidraw是最快出活的那一个。2. 把Excalidraw装进Vue项目的完整步骤含可直接复制代码2.1 安装依赖时最容易被忽略的React版本问题既然Excalidraw是React组件库它要求宿主环境里有React且版本有下限最常见的坑就是这里你项目里原本没有React直接npm i excalidraw/excalidraw然后运行发现报错React is not defined或者找不到react-dom/client。正确做法是手动补齐React 18以上的依赖。我用的安装命令是npm i react18 react-dom18 excalidraw/excalidraw注意这里必须保证react和react-dom两个包的版本在18以上并且版本要匹配。我曾经见过项目里react是17、react-dom是18结果Excalidraw内部调createRoot直接白屏控制台报_client is undefined排查半天才发现是这两个包版本不一致。Excalidraw这个库内部还会用到浏览器的某些API所以构建工具上没什么特殊处理Vite和Webpack都没问题。TS项目也正常它自带类型声明。2.2 封装一个可复用的WhiteBoard.vue组件我在项目里习惯把这类第三方库统一包一层Vue组件业务页面不直接接触React的东西将来要换底层实现在组件内部处理即可。下面这个组件是完整可用的版本template div refcontainerRef classwhiteboard-box/div /template script setup import { ref, onMounted, onBeforeUnmount } from vue const props defineProps({ initialData: { type: Object, default: () ({ elements: [], appState: null, files: {} }) }, theme: { type: String, default: light }, viewOnly: { type: Boolean, default: false }, language: { type: String, default: zh-CN } }) const emit defineEmits([change, ready]) const containerRef ref(null) let root null let whiteboardAPI null onMounted(async () { const React await import(react) const { createRoot } await import(react-dom/client) const { Excalidraw } await import(excalidraw/excalidraw) root createRoot(containerRef.value) root.render( React.createElement(Excalidraw, { initialData: props.initialData, theme: props.theme, viewModeEnabled: props.viewOnly, langCode: props.language, onChange: (elements, appState, files) { emit(change, { elements, appState, files }) }, ref: (api) { whiteboardAPI api if (api) emit(ready, api) } }) ) }) onBeforeUnmount(() { if (root) { root.unmount() root null } }) defineExpose({ // 清空整个画板 async clear() { if (!whiteboardAPI) return whiteboardAPI.resetScene() }, // 获取当前画板的数据便于保存 getScene() { if (!whiteboardAPI) return null return { elements: whiteboardAPI.getSceneElements(), appState: whiteboardAPI.getAppState(), files: whiteboardAPI.getFiles() } }, // 用历史数据恢复画板 restore(data) { if (!whiteboardAPI || !data) return whiteboardAPI.updateScene({ elements: data.elements, appState: data.appState, files: data.files || {} }) } }) /script style scoped .whiteboard-box { width: 100%; height: 100%; min-height: 400px; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; } /style这段代码里有几个细节值得说道。一个是onMounted里全部用了动态import目的是让白板这个大包和React相关代码不进主bundle只在实际渲染白板时才加载项目首屏速度基本不受影响。另一个是ref回调里把Excalidraw实例存了下来同时触发ready事件这样父组件可以在实例就绪后再调用API避免出现对象还没初始化就操作导致的空指针问题。defineExpose暴露了三个方法clear清空画板、getScene取数据、restore恢复数据。业务页面只管调用这三个方法不需要理解Excalidraw内部的resetScene、updateScene是什么。2.3 在业务页面里接上这个白板组件封装完组件页面里用起来就是普通的Vue组件使用方式。下面这个示例自带三个按钮保存、清空、加载上一次内容刚好把组件的三个暴露方法全部用上template div classwhiteboard-page div classtoolbar button clicksaveToBackend保存到服务端/button button clickclearBoard清空画板/button button clickloadHistory加载上一次内容/button /div div classboard-wrap WhiteBoard refboardRef :initial-databoardData changeonBoardChange readyonReady /WhiteBoard /div /div /template script setup import { ref } from vue import WhiteBoard from ./components/WhiteBoard.vue const boardRef ref(null) const boardData ref({ elements: [], appState: { viewBackgroundColor: #f8fafc }, files: {} }) // 防抖保存白板的onChange触发非常频繁 let debounceTimer null function debouncedSave(payload, wait 500) { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer setTimeout(() { localStorage.setItem(wb_saved_scene, JSON.stringify(payload)) }, wait) } function onBoardChange(payload) { debouncedSave(payload) } function onReady(api) { // 白板实例就绪此时可以安全调用 ref 上的方法 console.log(白板已就绪已加载元素数, api.getSceneElements().length) } async function saveToBackend() { if (!boardRef.value) return const scene boardRef.value.getScene() // 示例里用fetch占位换成你项目封装的axios即可 const resp await fetch(/api/whiteboard/save, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(scene) }) if (resp.ok) { alert(保存成功) } } function clearBoard() { if (!boardRef.value) return boardRef.value.clear() } function loadHistory() { const raw localStorage.getItem(wb_saved_scene) if (!raw) return boardRef.value.restore(JSON.parse(raw)) } /script style scoped .whiteboard-page { height: 100vh; display: flex; flex-direction: column; } .toolbar { padding: 12px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; display: flex; gap: 8px; } .board-wrap { flex: 1; padding: 12px; } /style到这一步你已经能在页面上看到一块可以自由画画的在线白板了。从安装依赖到页面渲染确实在5分钟以内这也是标题里说的“5分钟接入”的主要含义。但如果只是为了看到画面这篇的意义其实不大下面我更想聊的是真正决定白板能不能用的数据层问题。3. 白板数据怎么存、怎么恢复以及性价比最高的多人同步思路3.1 搞懂Excalidraw的三种数据elements、appState、files白板核心数据不是一张图片而是一份可编辑的JSON状态。Excalidraw每次变化都会产出一组数据主要包括三块数据内容例elements画布上的所有元素矩形、圆形、自由画笔迹、文本、图片等[{ type: rectangle, x: 100, y: 100, ... }]appStateUI状态主题、当前工具、视图坐标、缩放倍率等{ theme: light, zoom: 1.2 }files图片资源以二进制dataURL或对象URL形式存在{ abc123: { mimeType: image/png, dataURL: ... } }这里最关键的认知是白板不是一张图片而是一堆描述“有什么图形、它在哪、什么颜色”的数据。图片导出只是渲染结果而数据才是能继续编辑的根源。所以你保存的时候保存elements数组就够了appState可以保存也可以不保存取默认files必须要保存否则白板里的图片会丢。保存的时候还要考虑到体积。elements数组里每个元素都有坐标、宽高、旋转、颜色、透明度等字段一个稍复杂的画面几百个元素很正常序列化成JSON可能上百KB。files里的图片以dataURL形式存在时体积会更大。所以直接存localStorage只适合简单的快速体验场景生产环境应该走后端接口甚至把files拆出来存对象存储。3.2 场景保存的三种落地方案我梳理过三种保存方案复杂度从低到高你可以按项目需求选方案一localStorage自动保存适合原型、临时工具、纯本地使用。每次onChange防抖500毫秒后把整个scene JSON写入localStorage。优点是不用后端刷新页面也不会丢缺点是localStorage上限5MB左右存不了太多图片。方案二后端接口全量保存适合业务系统里的白板批注。POST一个JSON对象到自己的服务端包含elements、appState、files三个字段。每次操作后防抖保存一次或者只在用户点击“保存”时提交。优点是可靠能多端同步支持一个工单关联一份白板数据。方案三操作日志增量保存适合需要多人协同、操作回放的高级场景。每次变化记录diff既有全量快照也有增量操作流服务端可以把操作分发给其他在线成员。这是Excalidraw官方协作套件的思路复杂度较高一般中小项目用不到。我自己的习惯是原型阶段用方案一业务系统用方案二。真实项目里有一个关键点——不要把onChange里的每帧数据都往后端发。Excalidraw的onChange在手画一笔的过程中会触发几十次每帧都发后端一次画笔拖拽就能打出几百KB流量后端接口直接被打爆。解决方案就是防抖加操作结束判定至少加500毫秒防抖甚至可以只在appState的某种操作完成状态下触发保存。3.3 用WebSocket做轻量多人白板同步如果产品要求“其他人能实时看到我的笔迹”Excalidraw官方有一个协作服务可以自己部署但大多数中小项目不想为了白板再养一个服务。这时候我有一个性价比很高的方案白板数据当作广播消息WebSocket推给房间里的其他人。思路非常简单不涉及CRDT这类复杂算法用户A在onChange里防抖拿到最新的elements数组。 通过WebSocket把整个elements数组连同房间号发给后端。 后端把消息广播给同房间的其他客户端。 其他客户端收到后调用updateScene替换整个场景。 这个方案在人数少最多几十人、画布不极端复杂的情况下完全够用缺点是画得快的场景可能会丢失中间状态以及全量替换场景会让收到消息的人正在操作的框选被重置。但如果你的需求只是“讲师画、学生看”或者“两个人轮流标注同一张图”这个办法的落地效率最高。如果未来要真正做好多人实时协作那就得换用Yjs这一类CRDT方案所有在线客户端共享同一个自动合并的数据结构Excalidraw也有对应的y-excalidraw适配层。只是实现成本和调试难度都会上一个台阶建议等项目明确要商业化之后再动手。4. 接入白板插件之后我实际踩过的坑每一个都能复现4.1 白板容器高度为0画板直接不显示这是接入Excalidraw最先碰到的坑。组件渲染的时候如果父容器没有显式高度白板就会“画不出来”页面上只显示一条细细的边框甚至什么都没有。原因在于Excalidraw内部使用了一个无限画布的滚动容器它依赖父级的高度来计算可见区域。height: 100%遇到祖先元素没有确定高度时实际计算结果是0。解决方式就是我在组件里加的min-height: 400px同时业务页面里的.board-wrap要给flex: 1让白板父容器有实打实的高。如果是放在弹窗里弹窗body也要有明确的高度否则打开弹窗后白板依然是一块空白。这块当初排查了将近半小时一度以为是自己react-dom没配对导致渲染失败后来打开开发者工具看元素尺寸才找到原因。4.2 中文手写体乱糟糟的问题与字体设置Excalidraw默认的字体是手写风格的Virgil英文环境下观感很好但一打中文笔画间距怪怪的。尤其是做在线教学、会议白板场景中文输入量大这个手写体会严重影响阅读体验。解决方案是在初始化数据或updateScene时把默认字体改成普通字体。Excalidraw里字体用currentItemFontFamily字段控制1代表普通字体2代表手写体。你可以这样设置whiteboardAPI.updateScene({ appState: { currentItemFontFamily: 1 } })或者在initialData的appState里直接带上这个字段。这样新创建的文本元素都会用正常字体渲染而历史数据里已经用手写体创建的文本则需要逐个选中后手动改字体。我的建议是在初始化时就固定默认字体别让用户第一期就留下乱糟糟的中文笔记。4.3 onChange事件太频繁保存接口被打爆我在第3节提过一次这个坑但值得单独展开。Excalidraw的onChange会在画布上任何状态变化时触发包括鼠标悬停、画笔拖拽、视图缩放甚至仅仅是“铅笔图标被选中”都算状态变化。如果直接把onChange里的数据同步到后端一个用户画一条线后端能收到几十次请求。我最后用的策略是“防抖限定事件源”。防抖就是500毫秒内的最新数据才真正提交限定事件源是指只在画笔拖拽结束pointerUp、元素删除完成、场景切换这些相对稳定的节点保存一次。简化实现时防抖已经能解决95%的问题再配合你业务相信的打包方式基本不会给后端造成压力。这个点如果接入的时候不处理上线之后一定会成为事故。4.4 弹层与z-index冲突白板被遮挡好多项目都会把白板放在弹窗或抽屉里比如“工单转派时让操作人留下批注”。Excalidraw内部有一些自带的浮层元素比如颜色选择器、右键菜单、图片裁切工具这些浮层自带一个比较高的z-index。如果你外层弹窗容器创建了新的层叠上下文或者z-index设得不够就会出现在白板里点颜色选择器却被外层弹窗盖住的现象整个交互直接烂掉。处理办法是给白板容器所在的弹窗设置足够高的z-index或者把白板所在内容改成isolation: isolate隔离层叠上下文。如果弹窗组件支持append-to-body这类API把它开到白板容器上也能避开被裁剪和遮挡的问题。遇到这种界面问题时先打开开发者工具检查白板浮层和外层弹窗的z-index和position关系通常马上就能定位。5. 从“能画”到“好用”常用API与界面定制5.1 常用的Excalidraw实例API刚才的WhiteBoard.vue组件已经把Excalidraw实例存到了内部变量里这里展开几个常用的API方便你在业务里直接调用API作用使用场景getSceneElements()返回当前所有元素保存到后端时的核心数据来源getAppState()返回当前UI状态配合记录当前工具、主题、视图位置getFiles()返回图片资源映射保存图片数据避免恢复后图片丢失resetScene()清空所有元素“清空画板”按钮updateScene({ elements, appState })用外部数据整体覆盖画布加载历史数据或WebSocket同步别人发来的数据setActiveTool({ type: freedraw })切换当前工具程序控制用户选中画笔、矩形、选择等setActiveTool这个API很有意思它可以做“演示模式”里的自动切换工具也可以做讲师圈重点。比如点击页面上的“红色画笔”按钮动态切到画笔模式同时设置笔画颜色whiteboardAPI.setActiveTool({ type: freedraw, customType: freedraw }) whiteboardAPI.updateScene({ appState: { currentItemStrokeColor: #ef4444, currentItemStrokeWidth: 6 } })需要注意setActiveTool只会切工具图标颜色和粗细要通过updateScene里的appState设置。这类细节官方文档写得比较隐晦我当初试了好几种写法才确定这两个API必须搭配使用。5.2 定制UI只读模式、隐藏工具栏、主题切换接入后往往会遇到一个问题Excalidraw自带完整的工具栏但不是所有场景都需要用户能看到全部功能。比如只读展示场景、查看别人批注的场景就不能让人随便画。官方UI隐藏方式通过UIOptions和viewModeEnabled两个props控制在封装的WhiteBoard.vue里已经有viewOnly这个prop了。Root.render( React.createElement(Excalidraw, { viewModeEnabled: true, // 只读模式隐藏所有编辑入口 UIOptions: { canvasActions: { export: true, // 保留导出 clearCanvas: false, // 隐藏清空按钮 loadScene: false, // 隐藏加载文件按钮 saveToDisk: false // 隐藏保存到本地 } } }) )主题切换更简单theme字段直接控制light和dark都支持。实际项目里一般会把主题和当前网站主题联动由业务层决定。我在封装组件时已经让theme成为prop父组件直接:themeisDark ? dark : light就能联动全局主题。5.3 一个综合示例课程批注白板把上面的东西串起来我给你一个真实的组合示例。场景是在线课程后台里讲师需要对一张课件图片做批注然后生成一张带批注的图片发给学生端。实现思路背景放一张课件PNG作为Excalidraw image元素讲师在图片上层用画笔和箭头做批注。保存时把elements、files发给后端学生端加载时通过updateScene恢复同时用viewModeEnabled锁定为只读学生只能看不能改。核心代码和参数已经在前面给出整个链路里的关键体验点有两个。一个是初始数据的files字段要能正确包含背景图片否则恢复时图片丢失批注浮在空白上。另一个是保存后的图片导出Excalidraw UI里自带“导出图片/复制图片”按钮在只读模式里保留导出功能即可不需要自己写canvas导出逻辑。对我个人来说Excalidraw最舒服的一点是它的数据模型足够透明不用去“猜”某个操作背后是怎么存的所有状态都是公开JSON。只要把elements、appState、files这三个概念整明白后面无论是做保存、做同步、做自定义UI都不需要去读源码逐行调试省下的时间拿去喝茶都绰绰有余。如果你手头也在接白板插件建议先按这个思路把组件层和数据结构定好别急着把第三方组件直接糊进业务页面否则后面切换方案或调整UI时会非常酸爽。
返回列表