1. 项目定位先弄清楚 diagram-design 到底要做成什么1.1 为什么叫 diagram-design而不是 chart-library很多人第一次看到 layout 类项目的名字第一反应是“这不就是个画图工具吗”。实际把 diagram-design 这类项目从零跑通之后你会发现真正的难点几乎全在“画图”这两个字的背面节点怎么拖拽不卡顿、连线怎么不穿模、撤销重做怎么不出错、导出的文件怎么还能原样打开。我把它定位成两个层面的东西这点很重要。第一层是用户能直接看到的“图表设计器”支持拖拽节点、画线、编辑文字、缩放平移画布、框选多选最终导出成图片或工程文件第二层才是更有价值的“图表设计规范”也就是在底层沉淀一套统一的数据结构、坐标体系、渲染内核和交互规则让团队里不同成员画出来的图保持一致的视觉与操作逻辑。传统图表组件库解决的是“把数据变成图”而 diagram-design 解决的是“让使用者按自己的思路去编排图”。前者是展示后者是创作。两者的核心差异决定了整个项目架构走向组件库可以写死 props 和 slot设计器必须要有一套可序列化、可回放的文档模型把画布上看到的每一个元素都变成数据。1.2 核心用户和典型使用场景我从实际场景反推需求大概有四类人会直接用到 diagram-design前端开发者把设计器嵌入业务系统让运营、产品同学自己拖流程图和架构图不用每次改图都发需求单。产品与交互同学快速画出功能流程图、页面跳转图而且图改完可以直接导出放进文档。项目管理或运维人员画网络拓扑、部署架构、任务依赖图数据需要长期保存和版本回溯。深度集成场景的开发者基于数据结构二次开发比如自动布局、图表校验、跨项目对比。拿一个我实际见过的例子来说某团队之前在系统里集成的是一个开源图表库图是能画但用户只要把节点拖一下位置不会保存刷新页面就回到原点。后来他们把渲染层接到一个类似 diagram-design 的内核上一次交互就更新一次文档模型位置、连线、画布缩放比例全部持久化这个功能才算真正落地。所以如果你正准备做类似项目或者想复现一个可用的图表设计器这篇文章里的拆解可以让你少走很多弯路。下面所有内容都来自我实际跑过的方案结构可以直接参考。2. 技术选型原理渲染层、数据层、坐标体系三件事想清楚再动手2.1 渲染方案为什么最终选了 Canvas 而不是 SVG这是 diagram-design 建项目时第一个需要拍板的决定主渲染层用 Canvas 还是 SVG。我两边都试过也踩过不少坑。SVG 的优势是场景简单、开发快节点本身就是 DOM 元素click、mouseenter 事件直接挂上去样式用 CSS 控制导出为图片也容易。但问题出现在节点数量和交互复杂度上来之后一个图里放三百个节点、四百条连线SVG 的 DOM 节点数量会很可观拖动一个节点触发大面积重绘滚动和缩放都会出现肉眼可见的延迟。Canvas 模式则只有一块画布所有元素都被绘制上去渲染压力集中在 GPU几百个节点的图能保持流畅。代价是命中检测、事件派发都得自己实现连“鼠标有没有点到某个节点上”这种基础问题都需要手动计算。我给两个方案画了一条经验线可以作为参考对比项Canvas 方案SVG 方案节点数量 500性能平稳可继续优化明显卡顿复杂样式与渐变绘制代码稍复杂但表现力强CSS 可搞定事件绑定手动命中检测原生 DOM 事件缩放文字效果可控制需额外处理矢量清晰二次开发数据序列化天然与数据结构贴近需要从 DOM 反解容易失真实际项目里我选择的是 Canvas 作为主渲染层另外叠加了一个轻量 SVG 层只用来显示光标、临时选框这类“瞬态反馈元素”。这样既有 Canvas 的高性能又保留了少量 SVG 元素的便利性。如果团队工期紧张也可以全部用 Canvas把光标等元素直接手动绘制少一层就少一份维护成本。2.2 数据模型驱动渲染View 永远跟着 Model 走做图表设计器最容易犯的错是“程序里到处散落着节点状态的副本”。节点位置存在 A 变量里连线数据存在 B 数组里画布缩放存在 C 对象里看起来每个模块都清晰实际操作时发现同步次数非常频繁改一个节点位置要手动刷新三个地方十个功能之后状态就乱掉了。diagram-design 的内核遵循了单向数据流的原则一份 document 对象是唯一数据源画布上的所有元素都是这个对象的投影。const documentModel { version: 1.0.0, viewport: { x: 0, y: 0, scale: 1 }, nodes: [], edges: [], meta: { name: 未命名图表, updatedAt: } }渲染层只读取这份 model交互操作改写 model改完触发渲染回调。节点位置变化、连线路径变化、画布缩放变化全部汇聚到同一个流程里撤销重做、保存恢复、导出复制都变得非常简单因为只需要操作这一个数据结构。这种方式也特别适合团队协作。两个人同时打开同一张图只要 model 的 diff 规则一致每个操作都可以转成一条变更记录给后续做多人协同留了后路。2.3 坐标体系世界坐标与屏幕坐标背后的数学逻辑坐标体系是所有画布类项目的第一块基石很多开发者在上面栽过跟头。核心原因是代码里混用了两套坐标世界坐标和屏幕坐标。世界坐标是图表内容自己的坐标比如一个节点位于 (200, 100)这个值不会因为画布缩放而变化。屏幕坐标则是鼠标在浏览器窗口中的位置。中间还夹着一个“画布平移量”和“缩放比例”它们组合成 viewport。在两套坐标之间来回转换的公式不复杂但必须写清楚否则缩放中心、框选区域、拖拽对齐这些功能会越做越乱。function screenToWorld(viewport, sx, sy) { return { x: (sx - viewport.x) / viewport.scale, y: (sy - viewport.y) / viewport.scale } } function worldToScreen(viewport, wx, wy) { return { x: wx * viewport.scale viewport.x, y: wy * viewport.scale viewport.y } }这里的一个关键细节是所有鼠标事件首先都要转成世界坐标业务逻辑只跟世界坐标打交道。比如判断点击是否命中某个节点用的是世界坐标下的矩形范围拖动节点时把鼠标位移除上 scale 再加到节点坐标上。如果直接用屏幕坐标去判断节点矩形只要缩放比例不是 1所有判断和操作都会偏移越来越远。我在项目里把 viewport 单独抽成一个对象并约定任何组件不得直接读写 viewport只能通过 panBy、zoomAt 这类接口修改。这样即使后面加旋转、镜像等变换主流程也不会被破坏。3. 数据结构与核心功能拆解一个图表设计器到底要做什么3.1 节点模型从矩形开始但不局限于矩形节点的最基本形态是一个矩形x、y、width、height。x 和 y 通常指左上角坐标这是 Canvas 原生绘制最直接的表达方式。但真正做设计器节点不只有矩形一种形态圆角矩形、菱形、圆形、自定义图片节点都可能出现。我用的节点结构是下面这样兼容基础图形和自定义扩展const node { id: node-001, type: rect, x: 120, y: 80, width: 160, height: 56, zIndex: 1, data: { label: 开始节点, fontSize: 14, fill: #ffffff, stroke: #3b82f6 } }类型字段决定绘制函数数据字段保存样式和业务信息。绘制某个节点时先按 type 找到对应的 renderer再读取宽高和样式。这样做的好处是扩展新节点不需要改动核心循环新增一个 renderer 函数即可。节点的数据还有一个用途保存业务字段。比如把“审批人”“超时时间”等字段塞在 data 里节点渲染成什么样子可以根据数据动态变化。这让设计器不只是画图的工具还可以变成业务建模工具。3.2 连线锚点计算和路径生成是重头戏连线比节点复杂这部分值得花最多时间。两条线要解决三类问题连接点怎么算、路径怎么画、怎么让线不穿过节点。连接点锚定计算是一个矩形节点和另一个矩形节点之间的最短视觉连接。如果所有连线都从矩形中心点开始线会穿过节点盒子非常难看。正确的思路是从两个矩形中心点连一条射线分别计算射线与矩形边界相交的点作为连线的起点和终点。function getRectEdgePoint(center, dir, rect) { const dx dir.x const dy dir.y const len Math.hypot(dx, dy) || 1 const nx dx / len const ny dy / len const tx nx 0 ? Infinity : (rect.width / 2) / Math.abs(nx) const ty ny 0 ? Infinity : (rect.height / 2) / Math.abs(ny) const t Math.min(tx, ty) return { x: center.x nx * t, y: center.y ny * t } }路径生成还有更复杂的折线方案。折线需要计算从一个节点出口到另一个节点入口的拐点最常见的是“先垂直再水平”或“先水平再垂直”的简单路线。如果要做自动避障可以用网格搜索或 A* 算法但优先级排在这些基础功能之后。第一版先用带控制点的三次贝塞尔曲线视觉效果好代码也简单。注意锚点计算的极端情况是两个矩形重叠或中心点重合。中心点重合时方向向量的模长为 0必须做兜底处理比如默认向右、向下。否则线段方向除零会出现 NaN 坐标整条连线绘制失败。3.3 画布交互拖拽、缩放、框选的实现原理画布交互是用户感受最深的部分。拖拽节点的思路很直接pointerdown 记录按住的节点和起始偏移pointermove 更新节点坐标pointerup 结束操作。真正的细节在于指针捕获。我建议统一使用 Pointer Events 而不是 Mouse Events并且把 pointer capture 设置在画布容器上这样即使鼠标移出浏览器窗口移动事件仍然持续派发给画布不会出现拖到一半断掉的情况。实现代码大致是这样canvas.addEventListener(pointerdown, (e) { const worldPos screenToWorld(viewport, e.clientX, e.clientY) const hitNode hitTestNode(worldPos) if (hitNode) { draggingNodeId hitNode.id dragOffset { x: worldPos.x - hitNode.x, y: worldPos.y - hitNode.y } canvas.setPointerCapture(e.pointerId) } }) canvas.addEventListener(pointermove, (e) { if (!draggingNodeId) return const worldPos screenToWorld(viewport, e.clientX, e.clientY) updateNodePosition(draggingNodeId, { x: worldPos.x - dragOffset.x, y: worldPos.y - dragOffset.y }) })页面缩放是一个数学细节以鼠标所在位置为缩放中心。很多第一版实现都直接改 scale导致缩放后当前看的区域跑偏因为缩放中心被固定在了画布左上角。正确写法是先把鼠标位置的屏幕坐标转成世界坐标再按新的 scale 反算画布位移。function zoomAt(mouseScreenX, mouseScreenY, newScale) { const oldScale viewport.scale const world screenToWorld(viewport, mouseScreenX, mouseScreenY) viewport.scale clamp(newScale, 0.1, 4) viewport.x mouseScreenX - world.x * viewport.scale viewport.y mouseScreenY - world.y * viewport.scale }框选则是另一个精细动作空白处按下鼠标开始框选这时要用一个临时矩形表示选区。每移动一次鼠标选区覆盖的节点会被高亮。注意框选点击和拖拽节点必须区分判断条件只能是“按下时命中节点”还是“按下时命中空白区域”。3.4 撤销重做两种方案实际我只推荐快照流撤销重做是所有编辑类工具的刚需但很多人到了后期才想起这件事发现结构已经被改得很难做。常见两种做法命令模式把每次操作包装成 command 对象实现 execute 和 undo 方法精确控制每一步。这种方案内存友好但开发成本高每种操作都要维护反向操作逻辑节点删除后需要恢复完整数据连线增减还会关联到多个对象。我在 diagram-design 里用了更简单可靠的快照流方案。每次操作提交时把整个 document model 序列化成 JSON 存进历史栈撤销直接加载上一份快照。这个方案在几百节点的规模下单份快照也就几十 KB保留五十步完全够用换来的是实现简单、不会漏状态。const MAX_HISTORY 50 const historyStack [] const futureStack [] function commitSnapshot() { historyStack.push(JSON.stringify(documentModel)) if (historyStack.length MAX_HISTORY) historyStack.shift() futureStack.length 0 } function undo() { if (!historyStack.length) return futureStack.push(JSON.stringify(documentModel)) const prev JSON.parse(historyStack.pop()) loadDocument(prev) }快照流的关键坑在“什么时机提交”。我踩过之后得出的结论是拖拽节点过程中不要实时提交pointerup 时提交一次。否则拖动过程中产生了二十个中间状态撤销一次只回退一小步体验很差。所有操作都走统一的提交入口比如 “commit” 函数不要在各处直接 push history否则以后想增加“合并快照”逻辑会无从下手。4. 实操过程从零跑通一个最小可用的 diagram-design4.1 骨架搭建用最小工程把渲染循环跑起来第一步先把工程架子搭好。这里我用的原生 JavaScript 加 Canvas没有引入框架目的是让原理更清楚。npm create vitelatest diagram-design -- --template vanilla cd diagram-design npm install npm run dev进入项目后先写一个最小渲染循环清空画布把 viewport 变换应用到 Canvas 状态然后遍历 documentModel 的节点和连线依次绘制。function renderCanvas(canvas, ctx, model) { const dpr window.devicePixelRatio || 1 const width canvas.clientWidth const height canvas.clientHeight canvas.width width * dpr canvas.height height * dpr ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, width, height) ctx.save() ctx.translate(model.viewport.x, model.viewport.y) ctx.scale(model.viewport.scale, model.viewport.scale) drawEdges(ctx, model.edges) drawNodes(ctx, model.nodes) ctx.restore() }这里有两件小事容易忽略。第一件是设备像素比 DPR如果不做 Canvas 分辨率放大在高分屏上文字会发虚。第二件是渲染时坐标变换要用 ctx.scale 和 ctx.translate这样所有节点绘制代码都直接用世界坐标不用每个节点手动乘 scale。4.2 节点交互让节点能被选中、拖动、编辑文本第一个交互是节点选中和高亮。点击命中之后在 Canvas 上绘制一个选中框表示当前聚焦节点。这里的细节是选中框宽度不能随缩放比例变化太大否则缩小时选框细得看不见放大时又粗得离谱。所以绘制选中框时要把线宽除以 scale。文本编辑是另一个容易掉进坑里的环节。最简单的方式不是直接在 Canvas 上输入而是叠一个透明的 HTML 输入框位置经过坐标换算后覆盖在节点文字上方。确认后把文本写回节点数据再刷新一次渲染。我一个主要体会是任何修改节点数据的行为都必须回到“修改 model 再重绘”这条路不要在事件处理器里直接改 Canvas 像素。否则功能越加越乱。4.3 连线的交互实现创建连线、拖动中的临时线、命中检测创建连线的交互一般是在节点上按下鼠标拖动在空白处松开时判断是否落到了另一个节点上。如果命中目标节点就正式建立一条连线否则取消。拖动过程中显示的是一条“预览线”我把它放在一个单独的渲染变量里不写入 model等松手时才真正提交。这样既实现了交互反馈又不污染数据。线的命中检测是绘制完成后最难的点因为用户要能点击一条细线选中它。直接判断鼠标在哪条线段上需要把线段当成宽度较小的矩形用点到线段距离公式计算距离小于一定阈值就认为命中。在实际中和线条一样粗的像素很容易误点我把阈值放宽到 8 像素。用户感受到的是稍微偏向一点也能点中比精确计算体验好很多。4.4 导出与序列化JSON 工程文件和 PNG 图片设计器的数据必须能导出、能恢复、能分享。我把导出做成两种JSON 工程文件负责保留全部数据和画布状态PNG 图片用于对外展示。JSON 导出只需要把 model 序列化后下载但要做版本控制。数据结构里我用 version 字段标记当前 schema 版本未来的代码读到低版本文件时可以做迁移。不要忽略这一步否则改数据结构不影响已保存的文件。PNG 导出需要临时创建一个离屏 Canvas尺寸根据当前画布内容计算忽略空白区域。导出前把背景色处理权交给用户要不要透明背景、要不要白底是两张常见需求。一个容易踩的细节是文字字体尚未加载完就导出PNG 里文字会变成默认字体丢失视觉风格。正确的做法是导出前等待 document.fonts.ready确保字体加载完成再绘制。5. 高频问题与性能优化实录5.1 先放一张高频问题速查表这里整理了我被问得最多的问题按症状、原因、解法整理成表可以直接对照排查问题现象常见原因处理方案节点多了以后拖动卡顿每次移动都全量重绘全部节点连线增加渲染节流尽量只在动画帧内重绘对静态子图做离屏缓存在线条上很难点中命中阈值太窄像素级判断使用点到线段距离公式阈值放宽到 8 像素左右放大后文字发虚没有处理设备像素比 DPRCanvas 宽度乘以 dpr并用 setTransform 恢复拖动节点时文字闪烁每帧重绘全部图形出现脏帧重绘时先清屏再按顺序绘制必要时使用双缓冲离屏画布撤销之后状态错乱拖拽过程实时入栈中间态太多只在操作结束时 commit 一次画布缩放后鼠标位置对不上屏幕坐标未转换成世界坐标事件处理时统一走 screenToWorld导出图片全是空白离屏 Canvas 尺寸按 CSS 尺寸计算按内容真实包围盒计算尺寸连线穿过节点矩形锚点用的是中心点改为射线与矩形边界求交点5.2 实测中的性能优化从重绘策略到离屏缓存先讲渲染策略这一点。第一版是最朴素的“任何数据变动就整张重绘”节点少时体验不错节点到了 300 个以上时拖动就会卡。我后来把渲染分成两档需要的场景下走 requestAnimationFrame 节流避免一帧内重复触发多次重绘。let renderQueued false function requestRender() { if (renderQueued) return renderQueued true requestAnimationFrame(() { renderQueued false renderCanvas(canvas, ctx, documentModel) }) }更进一步的优化是离屏缓存。把样式基本不变的节点逐一渲染到小型离屏 Canvas 上主 Canvas 重绘时就变成一次 drawImage 调用CPU 和 GPU 开销都会低很多。体感上 500 个节点拖动流畅度提升明显。但离屏缓存不适合频繁变化的内容尤其是正在进行拖动的节点。所以缓存是针对“上次渲染后没有变化的节点”建立的变化发生时把对应缓存失效。5.3 交互层面的隐形坑交互层面有几个不遇到不会注意的坑现在分享出来。一是快速双击与拖动冲突。用户想在节点上双击编辑文本结果第一次 click 触发了选中第二次 click 又可能被识别成拖动。解决方案是以时间为约束两次点击的时间和位移都小于阈值时判定为双击否则继续走拖动逻辑。二是滚轮缩放与页面滚动冲突。在画布上滚动时如果只监听 wheel 事件并阻止默认行为页面滚动会被完全劫持。更好的做法是按住空格或 Ctrl 键时才拦截滚轮为缩放平时让滚轮正常滚动页面。这一点在嵌到业务系统时尤其重要。三是框选与拖拽的边界情况。从空白区域开始拖动是框选从节点开始拖动是移动节点。但如果鼠标从节点按下拖到空白处再松开应该视为一次移动操作。这一点处理不严谨的话会出现“拖了一个节点出来又冒出一个选框”的灵异现象。划清边界的方法是pointerdown 时记录起始命中对象pointermove 时如果超过一定位移阈值比如 4 像素就锁定当前操作类型整个 drag 生命周期不再变更。6. 项目扩展空间与我的经验小结6.1 后续可以考虑的几个扩展方向diagram-design 跑通基础功能之后可以在这几个方向继续深化。自动布局是高频需求。用户拖了几十个节点希望能一键排列整齐。实现时可以把节点按拓扑分层或用力导向图布局算法把节点視作带斥力的小球连线视作弹簧多次迭代后自然展开。这个功能对流程图和拓扑图尤其有用。节点分组与折叠能应对大型图表的可读性问题。把一组节点聚合为一个容器节点双击展开进入内部折叠时对外只显示一个汇总框。实现上可以在 model 里增加 group 嵌套结构渲染时先递归绘制子节点再绘制组节点。多人实时协作也可以基于数据和版本演进的思路展开。记录每条操作命令其他人收到后重放优化下就能支持多端同步。这块复杂度高但一旦做成产品的协作价值会大幅提升。还有一个方向是 AI 辅助生成。给定一段文本描述自动生成初始节点布局和连线结构再由用户微调。基础模型并不复杂把自然语言解析成节点和边再用自动布局算法排布。6.2 一些实战经验尤其是关于数据模型的如果让我用一段话来总结 diagram-design 最关键的一条经验就是先把数据模型想清楚再写渲染代码。我见过很多绘制工具项目渲染层写得很用心到头来数据层一塌糊涂节点坐标散落各处画布缩放没有统一入口后续每一个新功能都要付出双倍代价。数据模型稳定之后拖拽、撤销、保存、导出、协同全部变得顺理成章因为所有功能处理的都是同一份文档。第二个经验是缩小第一版范围。不要一上来就做自动布局、主题系统、多选对齐。一个最小可用的设计器只需要四个能力画节点、连线条、拖动缩放、保存恢复。这四个能力跑通之后整个架构的骨架已经立住了后面加功能都是往骨架里填肉。最后一条是关于调试的。Canvas 项目里我强烈建议写一个 debug 模式可以在空白处打印当前鼠标的世界坐标、命中对象、viewport 状态。交互问题一旦出现偏差这些调试信息能帮你快速定位是坐标转换的问题还是命中算法的问题而不是靠肉眼猜。diagram-design 这样的项目最大的魅力在于“基础功能不太难做到精致又非常难”。从数据模型、坐标体系、渲染调度到命中检测、撤销策略、导出细节每一层都有值得深耕的点。希望我的这些踩坑记录能帮你绕开那些我已经踩平了的坑把精力花在真正让工具变得好用的事情上。