十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

diagram-design:手绘风格图形编辑器的核心设计与实现

diagram-design:手绘风格图形编辑器的核心设计与实现 diagram-design 这个名字看上去不起眼但它是那种真正能解决实际问题的工具。去年我用它重做了团队内部的技术架构图顺手把一套在线图表编辑器的核心逻辑完整过了一遍——从画布交互、手绘风格渲染到自动布局和本地持久化几乎踩遍了图形编辑器开发中所有经典坑。今天写这篇文章就是想把这套项目的设计思路、核心实现路径和部署细节摊开来讲明白。如果你正在做前端可视化、图形编辑器、低代码平台或者单纯想搞一个免费好用的架构图/流程图工具这篇文章应该能帮你省下不少弯路。1. 项目整体设计与思路拆解1.1 为什么还要做一个新的绘图工具市面上的图表工具其实不少draw.io、ProcessOn、Figma、Excalidraw 各有各的用户群。但这些工具放在实际工作流里总有那么几处让人别扭的地方。draw.io 功能全但界面偏旧默认样式丑得像上个世纪的产物连接线吸附逻辑有时还会莫名其妙跑偏。ProcessOn 不少模板和高级功能要会员协作分享在国内网络环境里也谈不上顺畅。Figma 确实强大可它本质是 UI 设计工具用来画架构图思维导图有点杀鸡用牛刀而且实时协作功能对单兵作战的用户来说完全是负担。Excalidraw 手绘风格算是标杆但它的定位更偏向白板速写真正要拿它画严谨的架构图会发现元素类型、布局能力都比较有限。diagram-design 的想法很直接把 Excalidraw 那种轻松的手绘视觉语言和 draw.io 那种严谨的流程建模能力结合起来。它不追求变成一个全能设计平台而是做一件小事——让人在 30 秒内打开页面把脑子里的结构画出来并且画出来的图能用于正式文档不显得幼稚。这个定位在工程实践里有非常具体的意义。手绘风格有一个被低估的价值它能显著降低评审场景里的对抗感。当你在代码评审会上递给同事一张歪歪扭扭的手绘图对方的第一反应是讨论一下而不是这方案是不是还没做完。这在团队协作里是很实在的心理学应用。1.2 技术选型背后的取舍逻辑图形编辑器最核心的技术决策是渲染方案Canvas 还是 SVG。这块我认真做了对比测试结果如下对比维度SVGCanvas渲染性能元素数量超过 500 后开始明显卡顿上万元素依然流畅事件绑定天然支持 DOM 事件每条路径都是元素需要手动做命中检测数据绑定元素的属性直接映射到数据对象需要维护一层渲染对象矢量清晰度任意缩放无模糊高分屏需要额外处理开发效率高适合中小型图形应用低需要自研渲染循环样式控制支持 CSS 全部特性只能通过 JS API结论很清晰对于图表设计这种节点数量通常不超过两三百个的工具型应用SVG 是明显更聪明的选择。它的开发效率高出一个量级手绘路径的每个节点天然对应一个 DOM 元素双击改名、拖拽移动、样式切换这些交互实现起来都极其自然。这里多说一句坐标系统的坑。SVG 里每个图形元素都有 transform 属性很多初学者想都不想就把位移写进 transform结果一旦涉及嵌套分组和缩放坐标换算能让人怀疑人生。我后来总结的规律是位移尽量直接修改几何属性x、y、width、heighttransform 只用于旋转和缩放。这样可以在绝大多数情况下避免坐标系的混乱。1.3 功能边界知道不做什么比做什么更重要diagram-design 在功能规划上有个很刻意的取舍——不做多人实时协作。原因很简单实时协作的工程复杂度非常高需要用 WebSocket 维护操作序列、处理冲突合并、做在线状态同步而图表编辑器的核心场景其实是单人在一段时间内完成一张图然后分享给别人看。引入实时协作不但让整个项目的技术栈变得复杂还会引入基础设施成本。最终确定的功能边界是这样的本地优先数据默认保存在浏览器 IndexedDB 里不上传任何服务器离线可用纯前端应用断网状态下所有功能正常通用格式互通支持导入导出 JSON 和常见图片格式轻量级 AI 辅助自动排版、自动对齐、网格吸附模板系统内置一批架构图、流程图、思维导图模板这套边界的核心逻辑是降低使用门槛。使用者不需要注册账户、不需要邀请同事、不需要学习协作权限体系打开就能干活。对于个人开发者和中小团队来说这种消失的复杂度恰恰是工具最大的竞争力。2. 核心功能与实操要点2.1 画布交互拖拽、缩放和平移的底层逻辑画布交互是图表编辑器最先要做的部分也是后续一切功能的地基。diagram-design 里实现了一个比较标准的交互模型拖拽空白区域平移画布滚轮缩放画布按下 Space 键临时切换到手型工具。自己实现时最容易被坑的是缩放中心点。如果直接对 SVG 的 viewBox 做等比缩放缩放的中心永远在画布左上角用户会发现想看的区域越滚越远去。正确做法是让鼠标指针所在的位置成为缩放中心这需要做一次坐标变换// 缩放时保持鼠标位置不变 function zoomAtPoint(svg, point, scaleFactor) { const ctm svg.getScreenCTM(); const mousePoint point.matrixTransform(ctm.inverse()); // 更新 viewBox 的宽高 const newWidth currentWidth * scaleFactor; const newHeight currentHeight * scaleFactor; // 计算新的 viewBox 偏移使 mousePoint 在新视图中的位置不变 const newX mousePoint.x - (point.x - ctm.e) * scaleFactor; const newY mousePoint.y - (point.y - ctm.f) * scaleFactor; svg.setAttribute(viewBox, ${newX} ${newY} ${newWidth} ${newHeight}); }这里关键的一步是用getScreenCTM().inverse()把屏幕坐标转成画布坐标再做偏移计算。刚开始可能觉得绕但一旦写出来就能理解所谓的画布缩放本质上只是改变 viewBox 的四个数值而 SVG 引擎会自动重绘所有元素。平移的实现要相对简单一些在 pointerdown 时记录初始坐标pointermove 时实时调整 viewBox 的 x 和 y 值。不过有个细节一定要在 pointerdown 时调用setPointerCapture否则鼠标快速移出画布区域后拖动事件会丢失画布就会卡住不动。2.2 图形与文本从零绘制一个可编辑节点一个图表的节点表面上是一个矩形加一段文字实际做起来要考虑的细节很多。节点的数据结构设计成扁平化模型interface NodeData { id: string; type: rect | ellipse | diamond | text | image; x: number; // 左上角 x 坐标 y: number; // 左上角 y 坐标 width: number; height: number; rx?: number; // 矩形圆角半径 fillColor: string; strokeColor: string; strokeWidth: number; strokeStyle: solid | dashed | dotted; text: string; fontSize: number; fontFamily: string; textAlign: left | center | right; opacity: number; locked?: boolean; }用扁平化结构而非嵌套结构是借鉴了 Redux 的状态扁平化优先思想。后续做选中、移动、撤销重做时直接按 id 更新节点不需要深拷贝整棵对象树性能和代码可读性都好很多。文本编辑是另一个容易翻车的点。SVG 里没有 HTML 那种天然的 contenteditable 编辑体验比较常见的方案是双击节点时在节点位置覆盖一个透明的 textarea用户在其中编辑失焦或按 Enter 后同步回节点数据。这里有个中文输入法的坑。中文用户输入拼音时会触发 compositionstart 和 compositionend 事件。如果监听的是 keydown 的 Enter 事件输入法选词时按的 Enter 会被误判为确认编辑导致用户拼音还没选完字就被保存了。要解决这个问题必须在 keydown 处理器里检查event.isComposing属性或者用 compositionend 事件辅助判断。2.3 连线与节点吸附让图表自动对齐连接线是图表工具里最体现专业感的部分。diagram-design 实现了三种连线样式直线、折线、弧线。默认使用折线因为架构图里大量使用自上而下的连接折线看起来更清晰。连线生成的核心是锚点算法。每个节点计算出上下左右四个锚点坐标连线从起点节点的某个锚点出发到终点节点的某个锚点结束function getAnchorPoint(node, direction) { const cx node.x node.width / 2; const cy node.y node.height / 2; switch (direction) { case top: return { x: cx, y: node.y }; case bottom: return { x: cx, y: node.y node.height }; case left: return { x: node.x, y: cy }; case right: return { x: node.x node.width, y: cy }; } }连接线拖动时需要实时计算鼠标位置距离哪个锚点最近距离小于阈值我测试后觉得 8px 比较合适太小难吸附太大容易误吸就自动吸附上去。这个万有引力效果看起来简单却是决定工具是否灵动的关键细节。折线路径生成要用到简单的引线算法。从起点出发先垂直或水平走一段距离再水平或垂直转向最终到达终点。为简单起见diagram-design 采用中线转折策略先垂直走到两个节点垂直中点再水平走到终点锚点正上方最后垂直到达。实际效果在大多数场景下都算自然。2.4 样式系统与手绘笔触的实现手绘风格是 diagram-design 最抓人眼球的部分也是技术上最有意思的一块。原理并不复杂对 SVG path 的每个点坐标加上一个伪随机的抖动偏移量。手绘感的灵魂在于随机的有分寸。完全随机会让线条看起来像乱画随机幅度控制在一个很小的范围内比如 0.8~1.5px再用固定随机种子生成抖动序列确保每次绘制同一元素时抖动保持一致不会因为重新渲染而线条变形。function roughLineTo(x1, y1, x2, y2, seed) { const steps 8; const points []; const rand mulberry32(seed); for (let i 0; i steps; i) { const t i / steps; const baseX x1 (x2 - x1) * t; const baseY y1 (y2 - y1) * t; const offset (rand() - 0.5) * 1.5; points.push([baseX offset, baseY offset]); } return points.map((p, i) { return ${i 0 ? M : L} ${p[0].toFixed(2)} ${p[1].toFixed(2)}; }).join( ); }这只是一个粗糙的版本。实际项目里为了让手绘效果更自然会使用二次贝塞尔曲线连接点并给线条绘制两层一层半透明粗线作为墨迹渗边一层不透明细线作为主线。两层叠出来就像用墨水笔在纸上画的效果。调色板也是经过斟酌的。手绘风格不适合高纯度艳色diagram-design 内置的默认调色板采用柔和的中低饱和度色系比如淡蓝、嫩绿、鹅黄、藕粉。这些颜色在白色背景上既不刺眼又有足够的区分度和手绘线条搭配起来非常舒服。3. 核心实现与代码级解析3.1 用 SVG 构建可编辑图形的基本盘项目整体技术栈是 Vite React TypeScript渲染层直接用原生 SVG 标签。之所以不用 Snap.svg 这类封装库是因为现代 React 已经能很好地将数据驱动到 SVG 属性引入额外库反而增加心智负担。一个矩形节点在 React 里的渲染方式类似这样function ShapeNode({ data, selected, onSelect }) { return ( g transform{data.rotation ? rotate(${data.rotation} ${data.x data.width / 2} ${data.y data.height / 2}) : undefined} onClick{(e) { e.stopPropagation(); onSelect(data.id); }} style{{ cursor: move }} rect x{data.x} y{data.y} width{data.width} height{data.height} rx{data.rx} fill{selected ? lighten(data.fillColor, 0.2) : data.fillColor} stroke{data.strokeColor} strokeWidth{data.strokeWidth} strokeDasharray{data.strokeStyle dashed ? 6 4 : data.strokeStyle dotted ? 2 4 : undefined} / text x{data.x data.width / 2} y{data.y data.height / 2} textAnchormiddle dominantBaselinecentral fontSize{data.fontSize} fontFamily{data.fontFamily} {data.text} /text /g ); }这里有几个细节值得说一下。第一dominantBaselinecentral相比手动计算文字基线要高效得多可以让文字始终垂直居中不需要根据字体高度做偏移调整。第二所有交互事件都挂在g分组元素上而不是直接挂到rect。这样矩形、椭圆、多边形可以共用相同的交互逻辑点击、拖拽、旋转的事件处理代码只写一份。第三被选中时的状态通过lighten函数提亮填充色来表达而非使用额外的选中边框。这样视觉反馈更柔和不会抢走图表本身的注意力。3.2 坐标转换从屏幕鼠标位置换算到画布坐标在图表编辑器里几乎所有的交互都要经历一次坐标转换鼠标在浏览器窗口里的屏幕坐标需要换算成画布坐标系里的逻辑坐标。一个常见的错误是手动去读 SVG 的 viewBox 值然后做四则运算但一旦涉及缩放和旋转手写公式很容易出错。正确做法是利用 SVG 原生 APIfunction getCanvasPoint(svg, event) { const ctm svg.getScreenCTM(); if (!ctm) return null; const rect svg.getBoundingClientRect(); const point svg.createSVGPoint(); point.x event.clientX - rect.left; point.y event.clientY - rect.top; return point.matrixTransform(ctm.inverse()); }getScreenCTM()返回的是设备坐标到屏幕坐标的变换矩阵调用inverse()反向变换之后就能把任意屏幕坐标精确映射到 SVG 的逻辑坐标。这个方案能正确处理缩放、平移、旋转而且不需要你手动维护任何坐标状态。如果项目后续要扩展支持多画布或嵌入到复杂布局中建议封装一个CanvasContext提供坐标转换、缩放级别、可视区域查询等能力而不是在各组件里直接调用 SVG API否则后期维护会非常痛苦。3.3 对齐与自动布局算法对齐功能看着简单实现起来要小心。多选多个节点后点击左对齐正确的做法是计算所有选中节点中最左侧的 x 坐标然后把其他节点的 x 都调整到这个值y 坐标保持不变。function alignNodes(nodes, direction) { if (nodes.length 2) return; // 找到基准坐标 let baseValue; switch (direction) { case left: baseValue Math.min(...nodes.map(n n.x)); break; case right: baseValue Math.max(...nodes.map(n n.x n.width)); break; case top: baseValue Math.min(...nodes.map(n n.y)); break; case bottom: baseValue Math.max(...nodes.map(n n.y n.height)); break; } // 应用对齐 nodes.forEach(node { switch (direction) { case left: node.x baseValue; break; case right: node.x baseValue - node.width; break; case top: node.y baseValue; break; case bottom: node.y baseValue - node.height; break; } }); }自动布局则是更聪明的功能。diagram-design 里的自动布局采用了一个朴素的方案对节点做拓扑排序按层级分配 y 坐标同一层级的节点在 x 方向均匀分布。这个方案有个前提条件图中的连线不能成环。一旦有环路拓扑排序就失效了。处理办法也简单先检测环把环上的某条连线临时忽略掉排序完成后再补回来。工程实现上用一个 Map 记录入度然后反复从队列里取出入度为零的节点这是经典的 Kahn 算法代码量不到 30 行效果却出奇地好。3.4 数据模型与 JSON 导入导出图表文件本质上一坨 JSON。diagram-design 的数据结构包含三个顶层字段{ version: 1.0, canvas: { width: 1600, height: 1200, background: #ffffff, gridSize: 20 }, nodes: [], edges: [] }节点和连线的数据结构前文已经给过。这个模型的关键在于version字段。只要导出的 JSON 带上版本号后续即使数据结构升级也能在老版本文件导入时做兼容转换。这个习惯很多人不做结果项目迭代三个月后老用户手里的图纸全部打不开非常尴尬。导出功能使用浏览器原生的 Blob 和 URL.createObjectURL 实现function exportJSON(data, filename) { const blob new Blob([JSON.stringify(data, null, 2)], { type: application/json }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download filename || diagram.json; a.click(); URL.revokeObjectURL(url); }这里有一个容易被忽略的体验细节导出文件名默认带上日期比如architecture-2024-06-15.json。这样用户不会连续导出几个文件后分不清哪个是最新版本。细节虽小但对用户体验的提升很实在。4. 本地部署与工程化实践4.1 初始化项目与构建工具diagram-design 用 Vite 搭建没有用 CRA 或 Webpack。理由很现实Vite 的开发服务器启动速度是毫秒级的保存文件后热更新几乎感知不到延迟。对于图表编辑器这种需要频繁调样式、试坐标的场景开发反馈速度直接影响写代码的心态。初始化命令非常简单npm create vitelatest diagram-design -- --template react-ts cd diagram-design npm install npm run devTypeScript 是必选项而不是可选项。图表编辑器的数据结构复杂节点、连线、锚点、变换矩阵到处都是嵌套对象没有类型系统改一个字段要全局搜索修完不知道哪里会崩。有类型检查兜底重构会安心很多。4.2 持久化存储与自动保存图表数据保存在浏览器端这个需求用 localStorage 就能实现但 localStorage 有两个硬伤同步操作会阻塞主线程、容量上限只有 5MB 左右。对于图表数据这种动辄包含几十个节点和上百条连线的 JSON 对象动不动几 MB 很常见。IndexedDB 是更合适的选择。它是一个异步的浏览器内数据库容量通常在几十 MB 以上配合一个简单的封装库比如 Dexie.js使用体验和 localStorage 差不了太多。自动保存需要做防抖。图表编辑过程中用户不停地拖拽节点如果每次移动都触发保存IndexedDB 的写入队列会被打爆。我设置的策略是节点坐标变化之类的低频操作立即保存高频操作拖拽过程中用 500ms 防抖合并保存。const saveQueue []; function scheduleSave() { clearTimeout(saveTimer); saveTimer setTimeout(() { performSave(); }, 500); }还有一个细节保存时要给数据追加一个savedAt时间戳并且保留最近 3 份历史版本。用户误操作导致数据损坏时可以回滚到几分钟前的版本。这个设计救过我很多次强烈建议做进去。4.3 常见问题排查表问题可能原因解决方案导出 PNG 背景全黑未设置 SVG 白色背景 rect导出前在 SVG 内部插入一个覆盖全画布的白色矩形双击编辑文字时输入框位置跑偏缩放比例未换算到屏幕坐标用 getCTM() 计算缩放比率应用到输入框的 left/top高分屏导出模糊导出分辨率未乘 devicePixelRatio导出时初始化 Canvas 尺寸乘以 DPR再调用 ctx.scaleSVG 元素拖拽时卡顿每次 mousemove 都触发了 React 全量重渲染对节点组件做 memo 包裹只更新真正变化的节点 props导入老版本 JSON 报错字段命名或结构发生变化写版本迁移函数 convertLegacyData(version, data)Safari 下旋转元素边缘锯齿Safari 对非整数坐标渲染精度不足给路径追加一个随机微小的坐标抖动或者用 transform 属性代替坐标修改排查心得当 SVG 相关的问题怎么都定位不到时先检查是不是有 hidden 的宽高为 0 的容器。这类问题比较隐蔽因为它们不影响正常功能只在下一次渲染时产生奇怪的布局偏差。4.4 性能优化SVG 渲染性能的瓶颈通常不在 SVG 引擎本身而在宿主层——React 的组件重渲染。节点一多每次移动其中一个节点如果父组件的 state 整体更新所有ShapeNode都会重新执行渲染函数。要解决这个问题核心手段是 memo 加按需更新const ShapeNode memo(function ShapeNode({ data, selected, onSelect }) { // 只有 data 引用变化或 selected 变化时这里才会重新执行 return g.../g; });在父组件的 reducer 里更新节点时只更新被修改的那个节点对象保持其他节点引用不变。这样 memo 对比 props 时发现引用没变React 就会跳过渲染。实测下来200 个节点的画布做拖拽操作帧率可以稳定在 60 帧。如果画布元素量非常大超过 500一个更激进的优化是对可视区域做视口裁剪只渲染 viewBox 范围内的节点范围外的跳过。但这会引入滚动时节点动态挂载卸载的复杂度diagram-design 目前没有做后续如果要支持超大画布这是一个方向。5. 进阶玩法与可扩展方向5.1 做一组高效的键盘快捷键图表工具的键盘快捷键直接影响使用效率。diagram-design 内置了这些Delete / Backspace删除选中元素Ctrl D快速复制选中元素偏移 20px 放置Ctrl G / Ctrl Shift G编组 / 解组方向键微调选中元素位置每按一次移动 1pxShift 方向键快速移动每按一次移动 10pxCtrl 滚轮缩放画布Ctrl 0重置视图缩放为 100%Ctrl 1适应画布显示全部元素Ctrl D 这个设计来自 Axure用习惯了之后效率很高比复制粘贴再手动拖到旁边快得多。快捷键系统写成一个 hook 挂在全局监听上注意在 keydown 里排除用户正在编辑文本的状态。可以用一个全局标志位或者检查document.activeElement的 tagName 来判断。5.2 让自动整理看起来智能的粗线条方案自动布局可以做得更聪明一点。在拓扑排序的基础上再叠加两个小优化第一同一层级的节点按宽度从大到小排列这样布局出来整体更均衡。第二连线尽量用弧线而不是折线弧线会让整张图看起来不那么僵硬。function generateArcPath(x1, y1, x2, y2) { const dx x2 - x1; const dy y2 - y1; const cx1 x1 dx * 0.5; const cy1 y1; const cx2 x2 - dx * 0.5; const cy2 y2; return M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}; }这种简单的水平二次贝塞尔弧线用在架构图的分层连接上视觉上比直角折线更柔和比随意弯曲的自由曲线更有序是很多商业化架构图工具的默认选择。5.3 开源后的生态玩法如果 diagram-design 要往开源项目方向发展有几件性价比很高的事可以做。第一是模板分享社区。用户画好一张架构图后可以导出为模板 JSON别人导入模板后直接基于它修改。这能形成一个低门槛的手绘模板库。第二是插件机制。开放自定义图形类型的注册接口让用户能自定义类似于云资源图标、网络设备图形、组织架构头像等元素。插件机制做出来项目的扩展性会有质的提升。第三是导出到更多格式。除了 JSON 和 PNG如果能导出为 SVG、PDF甚至 -- 某些情况下 -- 直接导出为 Markdown 的 Mermaid 语法文本会让这个工具在很多自动化工作流里变得极其好用。最后再说两句实在话做 diagram-design 这个项目踩过最大的坑其实是什么都想做的诱惑。早期我加了多人注释、意见批注、协作游标、版本比对结果每个功能都做得半吊子代码库越来越重核心体验却越来越差。后来痛定思痛砍到只剩画图、连线、导出、自动排版这几个核心功能产品才真正顺眼起来。如果你也要开发类似的工具型项目我的建议是先做一个能完整走通一个用户流程的版本哪怕看起来很简陋但要稳定。之后每加一个功能前先问一句这个功能真的有助于用户更快地把图画完吗如果回答犹豫就不做。这个原则救了我很多次也希望对你有点启发。
返回列表