1. 项目要解决什么问题整体思路从哪里来我在差不多半年前接手了内部一个很头疼的需求团队里做架构评审、写技术方案、画流程图过去一直靠通用绘图软件或在线白板来搞。刚开始还好等文档数量多起来问题全冒出来了——图散落在不同文件里没法检索画完的图很难和代码仓库、接口文档关联改一处节点坐标另一张图忘了同步方案评审的时候被人当场抓出来错漏。说白了工具本身不背锅是我们需要一套“能嵌进日常工作流、能被代码化复用、能自动整理版式”的绘图方案。这个项目标题叫做 diagram-design听起来像是个绘图应用但更准确的定位是一个面向开发者和技术团队的轻量级图表设计引擎。它解决的核心矛盾是既要让不懂设计的同事也能快速画出一张架构清晰、连线规整的图又要让懂得折腾的人可以像写代码一样定义图表内容甚至用脚本批量生成图。确定这个方向之前我们也翻过不少现成的方案。一类是重量级绘图软件功能全面但想实现和内部系统深度集成往往要去折腾它的插件体系而且License和团队规模直接挂钩性价比不高。另一类是代码即图表的文本方案把图的描述写成文本自动排版生成简单场景非常高效但交互感差团队里非技术背景的同事完全不想碰。还有一类是在线白板协作体验好但数据都在别人的服务里导出和定制都受限和我们要做的“内置到文档系统中”这个目标差距太远。所以最后我们把项目拆成了两个目标。第一做一个数据驱动的绘图内核用统一的JSON结构描述节点、连线、画布和样式渲染层和逻辑层完全脱钩。这样同样的数据今天渲染成SVG明天需要输出成PNG甚至转成文本描述都只是换个适配器的问题。第二在渲染层之上做一套足够顺手的基础交互——拖拽节点、画连线、缩放平移、自动对齐、撤销重做——每个功能单独拎出来都是最朴素的实现但组合起来就是日常画图的核心体验。现在回看这个设计最大的好处是让项目始终没有偏离“能真正被用起来”这条线。很多绘图工具项目死掉不是技术不行是过度设计了。比如一开始就追求多人实时协同、无限画布、智能布局一应俱全。我们的原则很简单**先解决“画完图能放进文档里、还能改得动”的问题再谈更好看的渲染、更聪明的算法。**实际开发中这个顺序帮我们避免了很多无谓的返工。2. 核心技术拆解数据模型、渲染方案与布局算法怎么选2.1 数据模型是一切的基础怎么定义Node和Edge一张图拆到底无非是节点和边。但真正上手之后会发现节点和边一旦放到真实业务里细节就多得吓人。拿节点来说除了 id、坐标、宽高这些基本属性还需要考虑类型。我们内部把节点类型规定为四类基础形状节点、容器节点、文本标签节点和组件节点。基础形状就是矩形、圆形这些用于流程图和简单框图容器节点可以包含其他节点用于画系统边界或分组文本标签只是显示文字不参与连线组件节点是后来扩展的对应代码里的接口或依赖能附加元数据。为什么一开始就要把类型分得这么细因为后续的渲染、布局、交互都会依赖类型做分支处理。如果偷懒把所有节点都当成“一个带文字的矩形”画拓扑图的时候会发现容器没法贴边连线语义上也说不清楚哪些节点属于同一个子系统。模拟项目X里我们就踩过这个坑后来返工加了 type 字段虽然改动不算大但引出了一连串的兼容处理。所以设计数据模型时哪怕MVP阶段用不到全部类型也要把扩展字段的位置留出来。边的模型比节点更复杂一些。一条边至少要有 source、target 两个端点但真实连线往往不是两点之间的一条直线而是一条折线或者曲线。因此我们把边的路径抽出来支持两种存储方式第一种是自动布局路径只记录端点ID路径由布局层动态计算第二种是手动路径记录一串途经点用户拖出来的形变保持住。刚开始我们觉得手动路径没必要后来发现画跨层的复杂时序图时用户对连线的形态有很强的控制欲自动布线很难满足所有场景这个能力必须保留。画布本身也要建模。缩放比例、平移偏移量、视口大小都放在一个 canvasState 对象里。这里有个容易忽视的细节业务坐标和屏幕坐标要严格分离。业务坐标是节点在图上的真实位置不随缩放和平移改变屏幕坐标是用户看到的位置等于业务坐标乘以缩放比例再加上平移偏移。所有交互事件拿到的都是屏幕坐标要做的第一件事就是换算回业务坐标否则拖拽节点的时候图会“飘”。这个换算看似简单实战中却是出bug最多的地方之一后面排查部分我会再详细说。2.2 SVG还是Canvas没有唯一答案但起步阶段我站SVG渲染层的选型是项目里吵得最凶的一个技术决策。Canvas方案的支持者认为性能上限高几千个节点也不卡适合做大图SVG方案的支持者说DOM操作方便、事件绑定直观而且无限缩放不模糊。我们最后选了SVG而且短期内没有迁移到Canvas的打算。原因其实很务实。第一技术团队画的架构图、流程图节点数量通常在一两百以内很难撑到Canvas发挥优势的量级。第二SVG天然支持点击、悬停这些事件处理节点上直接绑监听器就行不需要自己实现命中检测。Canvas要做命中检测就得维护一份坐标索引或者把每个节点抽成独立对象做碰撞计算开发成本不是一个量级。第三SVG可以当作DOM一样用CSS控制样式主题切换、暗色模式、高亮状态都省事。当然SVG的代价我们也认了。节点特别多的时候比如上千个DOM节点数会拖累性能缩放平移时重绘开销也比较大。我们的应对方案是渲染层定义一个抽象的Renderer接口SVG做第一个实现但不把业务逻辑绑死在SVG上。万一以后真要做几千节点的脑图再补一个Canvas Renderer就行。接口的抽象成本其实很低但给架构留了一条退路这件事值得做。2.3 自动布局算法从分层到连线交叉的最小化绘图工具里最能体现“技术含量”的部分就是自动布局。用户手动摆好的图一般不丑但丑图让用户手动摆平就难受了。我们在MVP阶段做了两个布局算法层级布局和力导向布局。层级布局主要适用流程图、依赖图这种有明确方向性的场景。思路分三步第一步拓扑排序把有向无环图的节点按依赖顺序排成多层第二步逐层放置节点同层节点纵向对齐宽度按最大节点宽度统一第三步优化同层内部顺序让相邻层之间的连线交叉尽量少。第三步听起来技术感很强实际上用的是一个很朴素的贪心策略反复交换同层相邻节点每交换一次统计连线交叉次数如果变少就保留跑若干轮取一个局部最优。这当然不是全局最优解但对日常场景已经够好了。力导向布局适合那些没有明确方向感的图比如脑图之外的系统关系图。它的原理大家应该不陌生给节点之间施加“弹簧力”互相连接的节点被拉近不连接的节点被推开迭代几十轮之后趋于稳定。这个算法写起来不复杂但有两个工程细节要注意一是迭代次数不能死板固定要在能量变化小于阈值时提前收敛否则用户会看到图“晃半天”二是初始坐标别全放原点先用随机或网格散开否则容易陷入对称状态的死锁图最终变成几条直线。布局算法做完之后我们发现真正影响使用感受的不是算法本身而是布局触发策略。布局按钮要不要放在工具栏用户手动拖走一个节点后下次点自动布局会不会把用户的手动调整全部打乱我们最终的做法是给自动布局单独加一个“布局锁定”开关锁定状态下布局算法只计算新节点的位置不重排已有节点。这个开关很小但它避免了“我一刷新精心调整的图全乱了”的吐槽。3. 实操过程与核心环节实现从工程骨架到交互闭环3.1 工程搭建与模块划分先把基础设施立起来项目用TypeScript开发构建工具选的Vite。选Vite的原因很直接开发启动快、热更新快调试图形界面改完代码能立刻看到效果体验远好于老一套Webpack配置。工程结构上我按功能模块分包而不是按页面分包因为核心是一个图形引擎不是业务页面。目录大致是 models、renderers、layouts、interactions、commands、exporters 这几个包。models 放数据模型定义和序列化逻辑。renderers 放SVG渲染实现。layouts 放布局算法与渲染层隔离算法只读模型数据算出结果后回填坐标。interactions 放交互控制器每种交互拖拽、连线、框选一个模块。commands 放撤销重做用的命令对象每个命令实现 execute 和 undo 两个方法。exporters 放导出逻辑包括导出SVG、PNG和JSON三种格式。这个拆分的核心思想是单向数据流交互控制器捕获用户操作生成命令命令修改模型模型变更触发渲染层重绘绝不允许交互模块直接操作DOM。刚开始团队成员不理解觉得多绕了一层“直接改Div的样式不就行了吗”但第一版没有命令层的时候撤销功能写得很痛苦——因为状态分散在各处撤销一个操作要逆推这个操作改过哪些DOM节点逻辑根本没法维护。后来重构为命令模式之后撤销重做就变得非常单纯记录操作类型和相关数据调用undo时反向执行。所以建议做类似项目的朋友命令层从第一行代码就要引入别等烦躁了再重构。3.2 节点拖拽、连线和缩放平移怎么做到手感不飘交互部分我们做了三件基础但关键的事拖拽节点、画连线、画布缩放平移。每件事单看都不难但要组合起来做到“手感不飘”还是有不少细节。拖拽节点核心是坐标换算。Pointer按下时记录起始业务坐标Pointer移动时用当前屏幕坐标减去起始屏幕坐标除以缩放比例得到业务坐标的增量把这个增量加到节点坐标上。这里有个特别容易踩的坑如果移动时直接用屏幕坐标计算新位置缩放比例不为1的时候节点拖动的速度会和鼠标不一样要么太快要么太慢视觉上就是“飘了”。我们用的换算公式是新业务坐标 旧业务坐标 (当前屏幕坐标 - 起始屏幕坐标) / 缩放比例这个公式写起来一行但作用很大。另外拖拽过程中要实时请求重绘但不必每个Pixel事件都重绘可以借助 requestAnimationFrame 做节流每次动画帧只处理最后一次坐标更新。实测下来这样的流畅度和直接同步更新几乎没有差别但CPU占用明显下降。画连线时最头疼的是端点吸附。用户从某个节点开始拉线鼠标在移动过程中如果能贴近另一个节点的边缘应该高亮显示并吸附过去。这要求做一次最近距离检测。我们最初的实现是遍历所有节点计算距离节点一多就卡顿。后来做了一次简化先把屏幕划分成网格常用网格边长32每个节点注册到它覆盖的网格中检测时只遍历鼠标周围9个网格里的节点。这个“空间哈希”的优化让性能完全不再是问题。缩放平移的体验核心是锚点。鼠标滚轮缩放时如果以画布原点为中心用户会感觉缩放内容和鼠标位置脱节体验很差。正确的做法是以鼠标所在位置为锚点缩放缩放前后要保证鼠标指向的业务坐标点在屏幕上落在同一个位置。公式是新平移偏移 鼠标屏幕坐标 - 鼠标业务坐标 * 新缩放比例这么处理后无论鼠标停在图上的哪个位置滚轮缩放都像是以鼠标指向的那一点为中心展开手感非常自然。3.3 撤销重做、多选与快捷键决定工具“专业感”的功能撤销重做是命令模式实现后收益最大的功能。我们定义了 AddNodeCommand、MoveNodeCommand、AddEdgeCommand、DeleteSelectionCommand 等几个基础命令。MoveNodeCommand 要记录每个被移动节点的起始坐标和结束坐标这样撤销时能精确回到原位。这里有个经验命令的执行和撤销要同时测试因为交互模块往往对执行路径很熟悉但撤销路径平时没人走一旦走了就可能漏掉状态同步。多选交互我们花了两个晚上做顺滑。框选的时候要区分“从空白处拖拽”和“从节点上拖拽”——前者是框选后者是移动单个节点。判定方式很简单Pointer按下时如果命中节点就是移动命不中就进入待框选状态移动超过一定像素阈值后才真正开始框选这样可以避免画图时不小心拖出一个误选框。框选区域与节点的相交判断可以用矩形重叠判断但为了手感更好我们做成了“节点中心点在框内就算选中”而不是完整的矩形相交因为后者在框选靠近边缘的节点时差一点没框住会让人觉得“怎么选不中”。快捷键这块我们没有做复杂的设计只实现了最常用的几个Delete删除选中CtrlC复制CtrlV粘贴CtrlZ撤销CtrlShiftZ重做。但复制粘贴有一个隐藏需求复制出来的新节点要偏移一段距离避免完全盖在原节点上让用户以为没复制成功。偏移量取20个业务坐标单位新节点还会自动设置为选中状态并清空原节点的选中状态。这些小细节叠加起来工具的使用体验才会明显不一样。3.4 导出模块SVG直接保存PNG要处理字体和背景图表画完最终要落到文档或演示文稿里导出功能如果做得糙前面所有交互做得再好都会打折扣。导出我们做了三种格式SVG、PNG、JSON。SVG最简单把渲染好的SVG字符串序列化之后保存就行但要注意把样式从CSS类内联到元素上因为很多文档系统插入SVG时会过滤掉style标签。这个坑我们是在某个文档平台汇报时发现的——图里所有文字飞到左上角样式全丢了。PNG导出相对麻烦一些因为需要把SVG渲染到Canvas上再转成位图。步骤是把SVG字符串转成Blob通过 Image 对象加载然后绘制到Canvas上再调用 toDataURL 导出。这个过程中必须处理两个问题一是字体SVG里的文字如果用了外部字体Canvas渲染时会fallback到系统默认字体所以导出前要确保图像加载完成或者把文字转成Path二是背景SVG默认背景是透明的导出PNG时如果不填充白色背景放到浅色文档里没问题但放到深色PPT里文字会看不清。我们最后在导出设置里加了背景色选项默认白色允许用户改成透明或者深色。JSON导出让图具备“可恢复性”。保存的数据包括节点数组、边数组、画布状态和自定义元数据约定好版本号。每次改完图用户可以把JSON存进版本库下次打开直接恢复。这个功能刚开始我们觉得是锦上添花后来发现在代码评审里特别有用评审人可以打开JSON diff精确看到这次改动移动了哪些节点、加了哪些连线而不是对着两张PNG肉眼找不同。4. 常见问题与排查技巧实录4.1 坐标系换算错乱导致拖拽“飘移”问题出在缩放状态没同步这个问题在项目上线后的使用反馈里排第一。现象是画布缩放之后拖拽节点会出现节点跟手的距离忽近忽远缩得越小越明显。排查了交互代码逻辑没问题最后发现是Data模型里的canvasState和Renderer里实际生效的SVG transform不一致。具体原因是交互模块读缩放比例是从模型的canvasState里拿的但某次渲染时视图层在SVG上设置transform用的是另一份缓存的缩放值两者差了0.2。也就是说模型认为缩放倍数是1.2视图实际是1.0。拖拽时换算公式用了1.2屏幕上看到的效果自然就偏了。定位这个bug的方法也比较基础在Renderer的重绘方法里加一行日志把canvasState和viewBox打印出来拖动几次后对比变化。第一次拖动就能发现两者不同步。根治方案是禁止模型和视图双份状态渲染层的transform每次重绘都从模型的canvasState里重新计算生成绝不允许独立维护。这次排查之后我们代码里形成了一个约定所有模式数据只能存在于models层Renderers只做读取和呈现。4.2 节点一多就卡顿一条曲线路径可能是元凶当时测试组反馈画一张包含300多个节点的系统拓扑图整体缩放和拖拽的帧率只有个位数。我们用性能分析工具看了一下发现卡顿不是节点绘制引起的而是每条边的路径计算。因为早期实现里每条边在每次重绘时都要重新计算一次折线路径即使这条边的端点没有移动。300个节点对应七八百条边每帧做七八百次路径规划直接把主线程压垮了。优化策略有两层。第一层是脏检查给边加一个 Dirty 标记只有当 source 或 target 的坐标发生变化或者途经点被手动修改时才重算路径。大多数场景下一次拖拽只影响几条到几十条边其他边的路径可以完全复用。第二层是平面化路径点计算折线路径时把节点的几何信息宽高、端口位置展开为点数组只算一次之后渲染时直接读点数组画折线不再依赖节点实时数据。两层优化做完同样300节点的图缩放拖拽帧率恢复到50帧左右而且是在普通办公笔记本上测的。顺带说一句如果真碰到几千节点的超大规模图前面的SVG方案肯定撑不住那就是到时候迁移Canvas Renderer的时机了。4.3 删除节点后“幽灵线”挂在画布上关联维护要做的彻底画图时选中一个节点按Delete节点消失了但连在这个节点上的边还在——页面上出现一条从空地连到空地的“幽灵线”。用户非常反感这个体验因为幽灵线会让整张图看起来又脏又乱而且不好选中、不好删掉得用框选才能清理干净。这个问题本质是数据模型没有保证一致性维护。我们的初始实现里删除节点只从nodes数组里删掉这个对象没有同步处理edges里所有指向该节点的边。这属于典型的“少写一行代码”但引发的用户困惑远超过代码量。修复方案分三步。第一步封装 deleteNode(id) 方法内部不仅删除节点还会遍历edges把所有 source 或 target 等于该ID的边一并删除并清理这些边上的监听器。第二步删除操作需要发出完整的变更事件让渲染层做一次增量重绘不能只删节点元素而忘了删除边元素。第三步如果是批量删除比如框选删除那么先收集所有待删除节点ID再去清理一次关联边避免逐个删导致遍历开销。这个逻辑后来我们写成了removeNodes(ids)的原子操作并且放在命令层里保证撤销时能同时还原节点和边。4.4 文本框编辑时的失焦问题焦点管理比想象中麻烦节点上的文字双击可以进入编辑状态但每编辑一个字输入框就失焦一次用户输完的字符全丢。我们的第一反应是“输入框的blur事件和点击事件冲突了”仔细查下来发现是点击节点外区域触发画布选中状态更新导致整个节点DOM重新渲染输入框被销毁了。根因是渲染层在每次数据变化后做了全量重绘输入框所在的节点被替换成新DOM节点输入焦点自然就没了。解决办法有两个方案第一种是重绘时做节点级diff只有状态变化的节点才更新DOM第二种是进入编辑状态时把该节点的渲染层升级为“静态覆盖层”——用一个独立的HTML输入框悬浮在节点上方结束时把值写回模型。我们选了第二种因为它实现起来更快而且不会破坏SVG渲染的既有逻辑。这个小功能看着不起眼但几乎每个实际使用的人都会触发一次修完之后用户的负面反馈直线下降。4.5 常见问题速查表问题现象根因排查 / 解决思路拖拽节点飘移、不跟手模型与视图的缩放状态不一致统一从canvasState读取坐标换算参数渲染层不缓存独立状态大量节点时缩放卡顿每条边每帧重算路径加Dirty标记只需在端点变更时重算路径路径点平面化存储删除节点后残留连线未同步清理关联边删除方法内原子清理source/target指向该节点的边配合命令层撤销文本框边输入边失焦全量重绘导致输入节点DOM被替换编辑状态使用独立覆盖层输入框值提交后写回模型导出的PNG文字错乱SVG外部字体未内嵌、背景透明导出前等待字体加载或转Path导出参数提供背景色选项IE等旧浏览器打不开使用了PointerEvent等新API引入事件API的降级polyfill或限定现代浏览器并给出提示页5. 写在整理完所有代码之后的一点心得项目做到现在核心功能已经能稳定支撑团队日常画图需求了。如果让我提炼出三件最值得分享的经验第一件是数据模型先于渲染方案。项目初期最容易犯的错误是把渲染技术当作核心一头扎进SVG或Canvas的API细节里结果画出来的图好看但扩展、序列化、批量修改都困难。我们把大部分时间花在定义好节点、边、画布状态的数据结构上后续无论是布局算法、导出模块还是新交互都是在一个稳的地基上加代码。第二件经验是剥离“给人一种专业的错觉”的伪需求。做绘图工具很容易被“智能布局”“自动美化”“协同编辑”这些功能吸引觉得做出来才够厉害。但实际去观察用户真正高频的操作无非就是拖节点、连边、调样式、导出图片。这些基础体验做扎实比一堆炫酷但没人用的功能重要得多。团队一开始坚持做手动的对齐参考线虽然“自动对齐”听起来更聪明但参考线让用户可以自己掌握对齐意图这在真实世界里更可靠。第三件经验是不要害怕重构但要带着命令模式这种底层设计去重构。绘图类的软件状态变化路径非常复杂如果没有清晰的命令层所有功能都可能被撤销重做问题绊住。我们很庆幸在开发早期就把命令模式立了起来后面每加一个功能都先想到“这个操作可不可以撤销”这已经成了一种编码惯性。当然现在的diagram-design还称不上完备。下一步计划里我们正在考虑接入节点组件的自定义描述能力让每个节点能挂上更丰富的业务元数据配合文档系统生成自动索引。另外一个已经着手验证的方向是更轻量的协作能力——不是多人同时编辑而是通知机制告诉当前正在看这张图的人哪个节点被谁改过。这些功能还在评估中但基于现在的架构我有信心它们不会带来伤筋动骨的改动。最后分享一个小技巧也是我们团队内部现在画图编码前必做的一步先把最终要表达的信息列成一个清单再动手画图。比如画一张部署架构图先写下“应用节点6个、服务依赖7条、外部系统2个、安全边界1条”剩下的事情就是往画布上摆。这个习惯看起来和工具完全无关但实际使用下来它能最大程度发挥绘图工具的效率避免画到一半发现漏了一个关键模块又得推倒重来。画图终究是辅助思考的手段脑子里的信息结构清晰了图自然会好看。
阅读完成 · 觉得有帮助?