diagram-design这个词乍看很宽泛但只要你动手做过浏览器里的图设计器就知道它远不是“把线画出来”那么简单。去年我在一个内部配置可视化平台里需要让业务方像拼积木一样拖出系统架构图结果逛了一圈现成的图编辑方案越看越觉得定制成本高最后干脆自己从零写了一个核心引擎。这篇文章把我从数据建模、渲染选型到交互踩坑的完整过程整理出来适合打算自研流程编辑器、架构图工具或节点式编排器的同学参考也适合想搞明白图设计器内部原理的人入门。1. 为什么自己写一套diagram-design先看清市面方案的取舍1.1 现成图编辑器的三种路线分别卡在哪市面上的图编辑方案大致分三类。第一类是集成度极高的重型画布工具自带节点库、面板、缩略图、历史记录开箱即用但问题也出在“集成度高”——你很难把它的节点样式改成自己业务的形态连自定义一条连线的箭头都要翻半天文档。第二类是偏底层的图编辑内核只提供画布、节点、连线这些最小单元交互方式相对自由但学习曲线陡遇到特殊交互比如锚点拖线、框选批量移动、分组折叠仍然需要自己啃源码。第三类是纯粹的渲染库它只负责把图“画出来”拖拽、缩放、选中这些逻辑全部要自己实现。我在那个平台里的需求其实很典型节点长什么样要完全跟着业务字段走连线要能区分成功流和异常流整张图要能折叠分组还要方便对接后端的结构同步。试了一圈下来重型工具定制成本太高底层库的默认交互又跟我的预期差得远改起来和新写一套差不多。最后我的结论是如果业务对图的“皮”和“骨”都有强要求自研一套轻量内核反而可控。1.2 自研前必须划清的功能边界自研最忌讳一开始就想做“完整图编辑软件”。我给自己划定的边界是支持任意矩形/圆角节点、节点多锚点、贝塞尔连线、画布平移缩放、框选、拖线建边、分组折叠、撤销重做、JSON序列化。至于缩略图、小地图、自动布局、多选对齐这些全部放到后期扩展项不写进第一版。这个边界不是随便拍的。数据模型、渲染层、交互层这三块只要设计合理后期加功能都是加法不会推翻重来。真正容易翻车的反而是那些看起来“很简单”的细节比如连线命中检测、坐标缩放后文字模糊、触摸板手势冲突。这些坑我后面会逐一展开。2. 数据模型用一份不变的JSON描述任意图2.1 节点、锚点、连线、视口的基本字段设计我花了两天时间磨数据模型因为它决定了后面所有代码的走向。最终的核心结构长这样interface DiagramData { version: 1, nodes: DiagramNode[], edges: DiagramEdge[], groups: DiagramGroup[], viewport: { x: number, y: number, zoom: number } } interface DiagramNode { id: string, type: string, // 业务节点类型 x: number, // 画布坐标左上角 y: number, width: number, height: number, ports: Port[], // 锚点列表 properties: Recordstring, unknown, // 业务属性 groupId?: string // 所属分组 } interface Port { id: string, align: left | right | top | bottom, index: number // 锚点在该边上的位置顺序 } interface DiagramEdge { id: string, source: { nodeId: string, portId: string }, target: { nodeId: string, portId: string }, type: string, // 边类型决定样式 properties: Recordstring, unknown }这里最容易犯的错误是连线只存“sourceNodeId”和“targetNodeId”。一旦节点上有多个锚点比如一个服务节点右下有三个出参口你根本不知道这条线是从哪个口出来的。所以连线必须精确到端口维度source 和 target 都指向一个“节点锚点”的二元组合。viewport 为什么也要存进数据里因为用户缩放拖拽到某个位置后下次打开要保持现场。很多自研者把视口状态放在组件内部刷新后恢复不了硬生生变成“用户自己重新找位置”。2.2 分组嵌套以及一个反直觉的连线模型分组我用了两次递归嵌套的 tree 结构每个 group 记录以下字段interface DiagramGroup { id: string, name: string, x: number, y: number, width: number, height: number, children: Array{ type: node | group, id: string }, collapsed: boolean }节点和子分组都通过 children 引用避免在节点上维护一层套一层的 parent 链。折叠分组时只需要把该分组内所有节点的可视状态置为隐藏并把指向隐藏节点的边也隐藏。这里要注意一点边的数据不能因为分组折叠就被删掉不然展开分组后连线全部消失用户心态就崩了。所以折叠操作只影响“渲染层”的显示列表不动数据层。分组还有一种做法是把子节点坐标转成相对父容器的坐标做成真嵌套。我一开始想省事全部用绝对坐标后来发现折叠后要记录每个子节点的原始可见状态绝对坐标反而更简单。折叠动画做完之后子节点坐标不用改只是 parent 容器把高度收回去里面的元素裁切掉即可。所以建议第一版分组就做“容器裁切内容隐藏”不做子坐标换算能省下大量边界 bug。2.3 序列化与撤销重做的基础约束数据模型设计得当与否直接决定 undo/redo 好不好做。我采用的是 Command 模式每次操作产生一个 commandcommand 里有 do 和 undo 两个方法历史栈只存 command不存全量快照。interface Command { do: () void, undo: () void, merge?: (next: Command) Command | null }比如移动节点do 是把节点坐标改成目标值undo 是改回旧坐标。拖线建边do 是插入 edgeundo 是删除 edge。框选删除是多个 command 合并成一个复合 commandundo 时一次全部恢复。这份数据模型天然可序列化协调后端也方便后端下发一份 DiagramData前端直接渲染前端改动结束后把整棵 DiagramData 上传回去。为了兼容迭代我特意在顶层加了 version 字段后面加字段时统一走迁移函数不会出现老数据打开就崩溃的场面。3. 渲染引擎选型SVG画线、DOM画节点的混合方案3.1 Canvas 全量渲染的第一个教训一开始我做过一版“全 Canvas 方案”当时想的是性能无敌上万节点都不怕。但真正写下来发现麻烦远超预期。Canvas 是单层位图所有节点、文字、连线都要自己 draw命中检测也要自己算几何关系。想要给节点加个圆角阴影Canvas API 当然能画但每次变化都要重新 fillRect 全套改一处样式就要注意重绘区域。更痛苦的是文字在缩放下的处理zoom 到 150% 时字体要不要跟着变大如果直接 scale 整个画布文字会变糊如果反算 font-size 补偿位置和 line-height 又容易抖。还有一个隐蔽问题Canvas 里的元素没有真正的 DOM 事件你需要自己维护一套“坐标→元素”的映射表在 mousemove 里做逆矩阵变换。说得直白点你就是在重复造浏览器的 hit-test 系统。3.2 为什么最终选择 SVG 边 DOM 节点后来我改成了混合渲染方案底层是 SVG 画布专门画边、箭头、高亮框上层是绝对定位的 HTML DOM每个节点一个 div。两者绝对定位的基准点都是同一个容器画布坐标到屏幕坐标的换算规则完全一致。SVG 画边的优势很明显。贝塞尔曲线就是现成的path改 d 属性就能实现平滑连线箭头用 marker 可以统一维护颜色和大小选中高亮时给 path 加粗、换颜色几行代码搞定。DOM 画节点的优势更直观节点内部能放任意 HTML按钮、进度条、状态图标都能直接嵌进去CSS 的 hover、active、transition 全部复用浏览器能力。节点要加个“运行中”的脉冲效果一段 keyframes 就完成了这在 Canvas 里要手写动画循环。代价是节点多到几千个之后 DOM 一定会卡但实际业务图很少超过几百个节点这个取舍是划算的。如果真遇到超大图后面再做“视口内节点渲染”也不迟。3.3 两层渲染如何同步视图变换SVG 层和 DOM 层不能各自为政我统一交给一个视口变换解决const toScreen (p: { x: number, y: number }) ({ x: (p.x - viewport.x) * viewport.zoom, y: (p.y - viewport.y) * viewport.zoom });每次 viewport 变化SVG 整体加一个 transform 属性DOM 节点层也套一层同样的 transform。节点内部的字体大小会随 zoom 变化这在大多数场景下是合理的——整个图跟着放大文字自然也放大。但缩放后文字模糊很烦我发现整倍数 zoom 比如 1、2、3 时文字最清晰非整倍数如 1.3、2.7 时会有明显锯齿。第一版我没在意用户放大到 1.7 后说字“黏糊糊的”详情我放到后面的坑位讲。4. 交互层的核心逻辑拖拽、连线和坐标变换4.1 屏幕坐标、画布坐标与缩放比例的三层换算图设计器里最基础也最重要的交互是正确换算三类坐标鼠标所在的屏幕坐标、容器内部的客户区坐标、还有画布坐标。很多 bug 都是这三种坐标混用造成的。我统一以容器左上角为原点监听 PointerEvent 的 clientX/clientY再减去容器 getBoundingClientRect 的 left/top得到相对容器的屏幕坐标const rect container.getBoundingClientRect(); const screenPoint { x: e.clientX - rect.left, y: e.clientY - rect.top };然后换算成画布坐标const canvasPoint { x: (screenPoint.x - viewport.x) / viewport.zoom, y: (screenPoint.y - viewport.y) / viewport.zoom };所有业务判断比如是否点到锚点、拖拽到哪个节点上、框选矩形是否相交一律用画布坐标。屏幕上看到的距离和实际交互坐标不能混着算。最初我在拖线时用屏幕坐标判断“是否接近目标锚点”zoom 一放大原本该命中的锚点因为视觉距离变大反而判成未命中改回画布坐标后问题立刻消失。4.2 从锚点拖出连线的状态机与命中检测拖线建边是整个编辑器交互里最需要设计的状态机。我维护一个简单状态机idle空闲。connecting-from按下了某个锚点正在拖出连线。connecting-to拖拽过程中当前 hover 到某个目标锚点。dragging-node拖拽移动节点。marquee空白处按下拖框。每种状态对应一段 pointermove 逻辑。拖线时最核心的是实时画一条“临时边”起点固定为源锚点终点跟随鼠标画布坐标。当 hover 经过一个可接受的目标锚点时把临时边的终点吸附到目标锚点上同时给目标锚点加一个高亮圆环。目标锚点可接受性的判定我是这样做的遍历所有节点的 ports逐一计算鼠标画布坐标到 port 画布坐标的欧氏距离小于某个阈值比如 16 像素除以 zoom 的一小段实际我直接用了 14px 的固定半径就算命中。注意这里阈值不应该随 zoom 变化否则放大后想连一条线会异常难瞄准。这也是为什么很多成熟的图编辑器无论怎么缩放锚点的吸附范围看起来都不变。4.3 框选、平移与自动对齐的配套交互多选我用了一个简单矩形相交检测。框选开始点记 mouseDownCanvasPointpointermove 时更新矩形最后用节点的包围盒与矩形做相交判断。这里 DOM 节点渲染和选中态的同步很容易踩坑框选过程中不能每次 mousemove 都去重新渲染节点列表最好只改“选中框层”的几何属性鼠标松开后再批量更新节点选中态。平移我实现了两种按住画布空白处拖动以及滚轮。滚轮自带速度不均匀的问题我干脆禁用了默认滚动用 deltaY 直接调整 viewport.x/yconst factor event.deltaY 0 ? -1 : 1; viewport.x factor * 40; viewport.y factor * 40;自动对齐我做了最简版拖一个节点时如果它的横向中心线、纵向中心线、边缘线接近其他节点的对应线就把它“吸附”过去并在画布上画一条半透明的参考线。参考线属于纯渲染态不写入数据模型这样不会污染序列化。这个功能对用户体感提升巨大强烈建议第一版就做虽然实现起来很琐碎但脏活累活做完之后整体质感完全不一样。5. 实测踩坑记录图设计器里最隐蔽的5个问题5.1 连线命中检测投影距离还是多边形点击一条连线来选中它听起来很简单实际是第一个翻车点。线只有 1px 粗时手指几乎点不到。我一开始的做法是把每段贝塞尔曲线按参数 t 采样 30 个点依次计算鼠标到每个采样点的直线距离小于阈值则命中。这个方案在曲线上没有问题但曲线的拐弯处需要更多采样才能覆盖否则会出现“看着在线旁边点却无效”的尴尬。后来我把所有边变成“透明粗描边可见细描边”的双 path。命中检测用透明粗 path视觉展示用细 path。用户的肉眼看不到粗 path 的存在但鼠标操作体验立刻提升。这个技巧在 SVG 里实现只用了几行代码const hitPath document.createElementNS(svgNS, path); hitPath.setAttribute(stroke, transparent); hitPath.setAttribute(stroke-width, 12); hitPath.setAttribute(fill, none);用透明粗 path 做命中彻底绕开了复杂的曲线采样计算。实测下来比任何几何采样方案都稳而且性能高得多。5.2 文字在缩放场景下为什么越变越糊这是用户反馈最多的体验问题。放大到 1.7 倍时节点内文字明显发虚整个图看起来像蒙了一层雾。原因在于浏览器对非整数的 CSS transform 缩放文字栅格化时会出现亚像素偏移。我试了两种修复方案。第一种是文字反缩放也就是节点内部的内容在 zoom 变化时反向 scale试图保持字号不变但这样节点整体尺寸会跟外部框对不上布局一乱。第二种是把缩放步进限制成整倍数但用户又觉得 1.5 倍很实用不能一刀切。最后一次做实验的时候我发现把节点的渲染从“外层 transform scale”改成“内层手动换算 concat 每个节点的尺寸和位置”文字反而清晰很多。也就是说不动 CSS scale直接把坐标乘上 zoom 再写进 left/top/width/height让浏览器按真实像素排版。代价是节点移动时会增加一点计算量但换来的是清晰的文字渲染。5.3 触摸板双指缩放和画布拖拽打架开发时我用的是普通鼠标完全没考虑触摸板。结果上线后一批用户用手提电脑崩溃双指滑动页面时整个图表被拖走双指捏合缩放又跟页面缩放冲突。排查后发现触摸板的双指操作在浏览器里通常会触发 wheel 事件其中捏合缩放会带上 ctrlKey 标记。所以我在 wheel handler 里做了分支if (event.ctrlKey) { // 双指捏合执行以光标为中心的缩放 } else { // 普通滚轮平移画布 }此外还要阻止默认行为event.preventDefault()否则页面会在双指滑动时跟着滚动。这轮修完再配合 pointer 事件监听双击触摸板就正常了。5.4 大量节点下的重绘卡顿与优化思路两百个节点以内混合渲染完全流畅。到了八百个节点明显感觉到拖动画布时掉帧。我用性能面板看耗时主要在两层一是每个人节点 DOM 都在频繁重排二是 SVG 里几百条 path 在重新计算 d 属性。第一版我每帧都遍历全部节点把每个节点的 left/top 重新算一遍。后来优化成只渲染“视口范围内”的节点超出屏幕的节点跳过。核心是判断节点的包围盒是否与视口矩形相交相交则渲染不相交就隐藏。同时把节点的坐标更新从遍历数组改成只遍历发生了 drag/drop 的那一组节点。这个优化之后两千个节点的图都能流畅拖动。5.5 导入 JSON 后坐标偏移的坐标系陷阱后端导出的图传到前端后节点全都偏到右下角。查了半天发现不是我坐标算错而是后端把节点宽度写成了带 border 的值我渲染时又套用 box-sizing: border-box于是每个节点都多了几像素内边距。节点一多偏移量不断累加。教训是数据模型里的 width/height 必须明确成“节点内容区的外包围盒”不含阴影和内边距渲染时用 CSS 把盒模型固定死。我在 Schema 注释里写清楚“width equals style width including border”后端解析时做统一转换坐标偏移问题直接消失。6. 从diagram-design引擎到通用图设计能力的扩展方向6.1 自动布局拓扑排序还是力导向节点多了以后手摆已经不现实。我接入了两套自动布局引擎。业务流水线用层级布局先把节点按依赖关系做拓扑排序再按层级分配列坐标同一层内节点均匀分布在行上。这种方法适合流程清晰的架构图。配置耦合关系强的场景用力导向布局让节点根据边的权重互相推拉收敛后形成聚类效果。力导向的计算量不小几百节点时我用 requestAnimationFrame 分批迭代每帧只算一部分节点避免主线程卡死。6.2 组合、折叠与嵌套数据模型兜不兜得住回到最开始的数据模型只要有 groups 和 groupId 字段组合、折叠、嵌套这些扩展就不需要改动渲染引擎。折叠一个分组时我只需要把隐藏节点和边的 Display 状态置为 false所有坐标原样保留。展开时重新显示任何数据都不丢。如果再想给分组加“只读锁定”“模板引用”那几乎完全靠 properties 字段自由扩展这得益于当初坚持的“业务属性全部走 properties 字典”的设计。6.3 导出PNG、SVG与保存草稿的工程配套除了在线保存图编辑器还有一个刚需导出图片。SVG 导出不难把当前画布内容备份到独立 SVG 节点里设置好宽高和 preserveAspectRatio生成 blob 下载即可。PNG 导出相对麻烦我用的是离屏 canvas 方案建一个隐藏 canvas把 SVG 内容通过 image 标签绘制上去再转成 PNG blob。需要注意的坑是字体和阴影在导出时可能不一致导出前最好把字体统一加载完毕。这套引擎做完之后业务方几乎没再抱怨过画图体验。现在回头看diagram-design 的核心乐趣在于用简单的数据模型承载复杂的交互需求同时用混合渲染在性能和灵活性之间取平衡。如果让我重来一次最先动工的一定还是数据模型先把所有交互都抽象成能序列化的 JSON 语义把视觉表现留到后面再加这条路我觉得是最稳的。