2026/10/19 6:42:15

自建图表设计器:从数据模型到画布交互的完整实践

自建图表设计器:从数据模型到画布交互的完整实践 去年我们团队接手一个内部系统重构时我最头疼的并不是业务代码而是怎么把架构图画明白。通用绘图软件看着功能很多可真要把二十多个节点按层级排布好、连线上不交叉、标注和配色保持一致手动调一次就是一下午。需求一改某个模块从左挪到右整张图的连线又全乱了。折腾到第三版之后我决定不再忍受顺手起了个内部代号diagram-design做一个以数据为核心、画布交互为辅的图表设计器。节点、连线、锚点、分组全部是结构化数据画布只负责呈现和编辑它们。几个月下来这套东西已经在团队日常文档、评审材料里跑了起来。这篇复盘就聊聊 diagram-design 从想法到 V1 可用的完整过程适合想自建图表工具、做前端工具链或者只是好奇可视化编辑器内部逻辑的人。其实是想到哪写到哪尽量把关键的设计决策、翻车现场和补救方式都交代清楚。1. 项目缘起被图表工具逼到想自己写一个开始动工之前我先把“现在为什么难受”列了个清单免得做着做着做成一个市面上已有工具的复刻版。1.1 徒手画图最大的消耗不是画是改如果只是画一张不会再变的静态图任何通用绘图软件都够用了。真正的痛点全在“改”上。一个二十几个节点的部署拓扑图新增一个依赖关系就得手动拖一条线、绕开几个已有节点、再重新对齐一遍附近文字。更别提多人协作后的版本混乱有人用默认配色、有人改了字体、有人把图例放到了看不见的地方最后拼进文档时总得重新收拾一遍。这种图哪里是资产分明是负债。另一个隐含问题是样式标准。团队文档里的插图理想状态是统一风格、统一线宽、统一标注方式但靠人力去维护这套标准纯属空想。只有把图的样式规则沉淀到代码层面才能让每张新图一出厂就长得像一家人。这也是我把项目重点放在“数据模型 通用渲染”而不是“某一个具体图形”上的原因。布局和样式对研发人员来说是驱动力的不需要也不应该靠鼠标逐根对齐。1.2 代码生成式方案的尴尬图能出但人不爱改有经验的工程师很快会想到另一条路用代码描述图结构再由工具渲染成图片要改就改文本、再跑一次命令。这个方案确实解决了“可版本化、样式统一”的问题但它的缺点同样明显——画好之后不能在图上直接拖一拖、挪一挪想临时调整一个节点位置得先去理解别人写的一大段脚本产品、测试同学想自己微调门槛能劝退一批人。文本生成适合出图不适合“编辑图”。我需要的是两者的折中底层有严格的图数据模型方便批量处理和版本管理画布上又保留直接操作的能力让人类可以凭直觉调整布局。diagram-design 的定位就这样定下来了一个以 JSON 模型为事实来源、画布为编辑界面的轻量图表设计器。它既不是又一个通用的画图软件也不是纯粹的命令行出图工具而是夹在中间、服务于“需要边想边画”场景的编辑器。1.3 需求收敛四句话框定边界做工具最怕没边界所以我动笔前先把范围压缩成几句话支持节点、连线、锚点、分组这四类图元以及移动、缩放、框选、对齐参考线、网格吸附等画布基本操作。所有图数据可以被序列化为 JSON也能从 JSON 反序列化回来保证存档和传输。支持导出为矢量格式和位图格式满足文档插图、评审投屏这类场景。节点类型可注册、可扩展团队可以往里面塞自己的业务图元而不需要改动框架核心。同时明确不做的东西V1 不接自动布局不做复杂流程引擎不做多人实时协同。自动布局我后来做了一个非常简陋的“按字段等间距排列”当辅助真正复杂的拓扑布局留给后续版本。先把手工编辑体验打扎实这个判断在后面帮我们省掉了大量无底洞式开发。边界不是限制边界是让项目能够收尾的保障。2. 图数据模型把“画面”翻译成可读可写的数据如果只能从这次项目里带走一个理念那就是先把图变成数据再谈画图。这一节是 diagram-design 的地基也是全文最值得反复看的部分。2.1 最小完备模型节点、连线、锚点、分组图表设计器里的万物在我的模型里就只有这几样东西。先看节点的核心定义下面的 TypeScript 类型声明是整套模型的地基interface DiagramNode { id: string; type: string; x: number; y: number; width: number; height: number; zIndex: number; parentId?: string; ports: Port[]; data: Recordstring, unknown; }x/y/width/height 是节点在画布坐标里的位置和尺寸zIndex 控制叠放顺序type 指向注册表里的节点类型渲染框架根据它决定画成什么样子。ports 是锚点列表也就是连线可以挂靠的位置。data 是给业务用的自由字段比如服务节点里塞 owner、env、metrics 这些信息框架渲染不到但也替你保留下来这是后续做数据联动、表格导入导出的关键。parentId 用来实现分组嵌套子节点存的是相对父节点的坐标这样拖动父节点时整棵子树自然跟着走。连线模型长这样interface DiagramEdge { id: string; source: { nodeId: string; portId: string }; target: { nodeId: string; portId: string }; style: straight | orthogonal | bezier; points?: Point[]; // 手动覆盖路由 data: Recordstring, unknown; }连线不直接说“从 A 到 B”就完事而是绑定到具体的节点和端口上。端口锚定在节点边缘节点移动时连线的起点终点跟着动框架不需要去猜应该从哪里出发。这里有个我在初版踩过的反模式一开始连线用的是“从节点 A 中心到节点 B 中心”然后只要有节点一变大所有连线全部穿进图形内部画面辣眼睛。后来才把端口模型补上。所以端口不是锦上添花的功能它是让连线语义成立的前提。端口定义我推荐用相对节点左上角的坐标至于上下左右这种方位渲染层可以从相对坐标推导出来。相对坐标的好处是节点尺寸变化时端口可以跟着边缘走而不用手动改每一个数值。2.2 为什么所有变更都要集中到命令里模型建好之后最容易犯的错误是到处直接改某个回调里node.x 10另一个回调里edge.style bezier。改多了视图更新、撤销重做、历史记录全乱套。diagram-design 从一开始就定了一条死规矩任何交互操作都只能通过 Command 完成所有状态变更必须经过 Store 入口。class DiagramStore { private nodes new Mapstring, DiagramNode(); private edges new Mapstring, DiagramEdge(); private listeners new Set() void(); applyCommand(cmd: Command) { cmd.execute(this); this.listeners.forEach((listener) listener()); } }视图层只订阅 Store 的变化收到通知后统一重绘。好处是撤销重做只需要维护执行过的命令栈导出只需要序列化 Store 内部的 Map调试时看到某个状态不对翻命令历史就能还原现场。代价就是多写一点样板代码但这点代价在项目大了以后非常值得。如果你只想搭一个能画画的 demo不搞命令模式也可以但只要你打算支持撤销、历史面板、多端联动这套集中管理的模式几乎是必经之路。命令模式的核心不是“模式”本身而是它强制你思考每一个操作到底改了什么让数据变更变得可追踪。2.3 自定义节点类型注册表模式对通用绘图软件来说矩形、椭圆、箭头就够用了。但像我们团队要画服务拓扑、消息流转需要的图元明显更具体有的想画成数据库圆柱体有的想画成带状态灯的服务方块。如果每种样式都写死在渲染器里项目迟早变成一个大文件堆。解法是注册表模式让新节点类型“插进来就能用”type NodeRendererFactory (node: DiagramNode) string; const nodeRegistry new Mapstring, NodeRendererFactory(); export function registerNodeType(type: string, factory: NodeRendererFactory) { nodeRegistry.set(type, factory); }框架的核心只管位置、尺寸、连线、框选、缩放具体节点长什么样由注册进去的工厂函数负责。比如注册一个“消息队列”节点团队只要写一个函数根据 node.data 渲染出对应的图标、颜色和标签然后返回一段可插入的 SVG 标记或 DOM 元素。这样既让业务团队有极高的定制自由又不污染核心的编辑逻辑。这一块唯一的提醒是如果工厂函数返回的是字符串形式的 HTML/SVG导入外部数据时一定要做内容转义不能直接把用户填的文本塞进标记里渲染避免 XSS 风险。有了这套模型后面所有的算法和交互才有了稳定的输入输出。数据稳了渲染和操作只是它的投影。3. 连线路由与坐标变换图表编辑器里最难啃的两块骨头如果让我排序 diagram-design 里最费脑子的部分连线路由和坐标变换稳居前两名。很多 bug 的根源最后都指向“坐标系没对齐”。3.1 一个看似简单的问题鼠标点到哪儿了画布可以缩放和偏移这意味着鼠标在屏幕上点一下得到的是浏览器窗口里的坐标但模型里存的是画布坐标系坐标。两者之间夹着一个视口变换屏幕坐标 画布坐标 × 缩放比 视口偏移。反过来要找到“我这一下点到哪个节点”就得做逆变换interface Viewport { scale: number; x: number; y: number; } function screenToCanvas(pt: { x: number; y: number }, viewport: Viewport) { return { x: (pt.x - viewport.x) / viewport.scale, y: (pt.y - viewport.y) / viewport.scale, }; }看着简单实际项目里特别容易漏。最典型的一个 bug缩放倍数不等于 1 时点击命中测试全部偏移原因是某个事件处理器里直接用屏幕坐标去和模型坐标比较没有做 screenToCanvas 换算。这个问题只在缩放状态下触发开发时经常没注意直到用户放大到 150% 再操作才会冒出来。后来我把规则写成铁律事件处理器的第一件事永远是把屏幕坐标转成画布坐标后续所有逻辑都跑在画布坐标空间里再不要混着用。这条规则本身没有技术含量但它能消灭一大类“只在特定缩放比下出现”的幽灵 bug。3.2 正交连线的路由策略先延长、再拐弯连线如果都画成一条直线二十个节点交叉起来就会乱成一团。最常见的连线风格是正交折线竖着出线、横着走、竖着进线和电路图一个观感。diagram-design 的默认路由算法很简单——对大多数场景够用也容易理解和调试。思路分三步先从源端口沿端口方向延伸一段固定长度我习惯用 32px出线再从目标端口反方向延伸 32px 进线最后在两条延长线的末端之间找一个合适的中间拐点把它们连起来。function buildOrthoPath( start: Point, startDir: Direction, end: Point, endDir: Direction ): string { const out 32; const p1 { x: start.x startDir.x * out, y: start.y startDir.y * out, }; const p2 { x: end.x endDir.x * out, y: end.y endDir.y * out, }; if (p1.x p2.x || p1.y p2.y) { return M ${start.x} ${start.y} L ${p1.x} ${p1.y} L ${p2.x} ${p2.y} L ${end.x} ${end.y}; } const midX (p1.x p2.x) / 2; const midY (p1.y p2.y) / 2; return M ${start.x} ${start.y} L ${p1.x} ${p1.y} L ${midX} ${p1.y} L ${midX} ${p2.y} L ${p2.x} ${p2.y} L ${end.x} ${end.y}; }这段代码只是示意但已经能解释为什么“先延长、再拐弯”能工作出线方向是明确的延长出来的线段天然不会贴着节点边缘中间拐点只在两个垂直边上活动路径正交性自然成立。比最短路径搜索那种全图避障简单太多缺点是不会自动绕开挡路的节点。我选择把“绕开障碍物”这件事交给用户所有连线都可以通过 points 字段被手动拖拽覆盖用户觉得路径不爽时可以自己拖中间控制点来微调。完全自动避障的复杂度会指数级上升而且生成的结果经常不可预测在 V1 优先级里非常低。这里的原则是算法只做保证视线持续和方向感的基础路由特殊需求交给人的直觉去覆盖。3.3 分组嵌套从“节点坐标”到“屏幕坐标”的递归累加有了分组之后坐标问题又多了一层子节点存的是相对父节点的坐标真正画到屏幕上的位置要把所有祖先节点的坐标一路加上去。function getNodeScreenPosition(node: DiagramNode, store: DiagramStore): Point { let x node.x; let y node.y; let parentId node.parentId; while (parentId) { const parent store.getNode(parentId); if (!parent) break; x parent.x; y parent.y; parentId parent.parentId; } return { x, y }; }这个递归函数直接影响命中检测、框选、连线端点和对齐参考线的实现。如果三个地方分别用了三套不同的坐标计算方式缩放嵌套两层的图时一定会有偏差。我这边的统一约定是模型里永远存相对坐标事件、渲染、命中、对齐统一换算成全局画布坐标后再计算只有少数场景比如把节点拖进分组时才把全局坐标换算回新的相对坐标。这套约定一旦固定分组相关的 bug 少了很多。嵌套分组是一个看起来不难、但处处都在影响整体架构的设计决策最好在项目早期就定清楚。4. 渲染层选型SVG 起步、Canvas 兜底的架构折中数据模型和路由算法聊完接下来是“到底用什么画出来”。这个选型决定开发效率和性能上限值得单独说。4.1 SVG 与 Canvas怎么选维度SVGCanvas事件与命中检测浏览器原生每个图元都是 DOM 节点需要自己做坐标命中检测性能拐点上千个图元开始明显卡顿上万图元也能保持流畅样式控制直接写 CSS主题化非常方便全靠绘制代码控制二次开发难度低非常直观高需要维护自己的绘制逻辑对 diagram-design 的目标场景来说团队内部画的架构图、拓扑图通常集中在几十到几百个节点之间SVG 完全够用。SVG 带来最大的红利是事件模型——节点上的点击、hover、拖拽天然有序不用自己写射线法或者包围盒检测。Canvas 虽然性能上限高但需要额外实现高分屏适配、文本布局、事件分发和局部重绘开发量至少翻一倍。所以我的决定是V1 全部走 SVG把性能优化留到“真出现几百个节点同时操作卡顿”的那一天再考虑。这个决定让我把有限的精力投在了逻辑层而不是绘制层。4.2 渲染抽象把“画什么”和“怎么画”拆开虽然 V1 用了 SVG我不想把渲染代码写死在业务逻辑里。我定义了一个中性的渲染指令描述type RenderItem | { shape: rect; x: number; y: number; width: number; height: number; fill: string; stroke?: string } | { shape: path; d: string; stroke: string; strokeWidth: number } | { shape: text; x: number; y: number; content: string; fontSize: number }; interface Renderer { mount(container: HTMLElement): void; draw(items: RenderItem[]): void; destroy(): void; }SVG Renderer 把这个数组翻译成 SVG 元素未来如果要换 Canvas Renderer只要把这个数组用画笔实时绘制出来就行上层业务代码不需要动。这个抽象很薄成本很低但它保证了系统不会在一棵 SVG 的树上吊死。后期如果接入 WebGL 做大规模图同样的 RenderList 仍然能复用。现在回头看这个决策是项目里性价比最高的一个因为它花掉的时间极短却让后续性能方案有了回旋余地。4.3 渲染细节里的小坑高分屏、线宽与文字SVG 虽然省心有几个细节我还是想单独拿出来说因为它们都真实踩过。第一导出位图时要处理 devicePixelRatio。很多人直接用 SVG 源来画 PNG把尺寸写上就完事结果高分屏上导出的图片发虚或模糊。正确做法是导出时按目标 dpr 设置输出尺寸和 scale让每个物理像素都落到该有的位置。第二线宽不能随便缩放。画布整体缩小到 50% 时边框线也会跟着细了一半视觉上会忽粗忽细。SVG 里可以用vector-effectnon-scaling-stroke让描边宽度不随缩放变化这样缩小时线看起来始终是同一粗细观感稳定很多。第三文字在缩放极值处要特殊处理。缩小到 0.3 倍时再精致的说明文字也成了马赛克放大到 3 倍时文字又可能占满整个节点。我的做法是分级渲染缩放小于 0.5 时只显示节点名称大于 2 时显示详情数据。这种“按细节级别渲染”的思路和游戏里的 LOD 很像能有效保护画布可读性。第四局部重绘。别每次操作都对整棵画布做全量重建我会维护一个 dirty 节点集合只有变化过的节点及其连线才更新其余元素保持原样。几十个节点的项目里全量重绘问题不大到了几百个节点时全量重建的卡顿感会非常明显。5. 交互细节的工程实现拖拽、对齐、撤销重做的取舍画布类工具一大半的工作量其实在交互手感上。这章的每个细节单看起来都不难合在一起决定了一个工具是“能用”还是“好用”。5.1 拖拽节点时如何让整棵子树跟着走拖动一个分组时组里所有子节点必须跟着动。但这不意味着要把每个子节点的坐标都改一遍——那样命令栈会被撑爆撤销时也要逐个恢复。正确姿势只有一个只修改被拖动根节点的坐标子节点坐标原地不动因为子节点是相对坐标父节点一旦移动它们的屏幕位置自动跟着变。class MoveNodeCommand implements Command { label 移动节点; constructor(private nodeId: string, private dx: number, private dy: number) {} execute(store) { const node store.getNode(this.nodeId)!; node.x this.dx; node.y this.dy; } undo(store) { const node store.getNode(this.nodeId)!; node.x - this.dx; node.y - this.dy; } }视觉上子树整体移动数据上只改了一个根节点坐标命令体积恒定。这是“相对坐标 递归累加”模型带来的最直接收益。拖动过程中的命中检测则用上一章的递归函数把所有子节点的屏幕坐标算出来再做相交判断不能直接拿子节点相对坐标去和鼠标位置比较。这里的关键不是算法有多复杂而是“改什么、不改什么”的判断要清楚。5.2 框选、吸附与对齐参考线手感来自这几个小东西框选矩形发生在屏幕空间。用户看到的是屏幕拖动框选时的直觉也是屏幕上的范围所以框选矩形要转成画布坐标但相交判断时我会用节点的屏幕坐标去做。这样在不同缩放比例下框选结果和用户预期保持一致不会出现“明明圈住了松开鼠标却少选一个”的违和感。网格吸附用一句代码就能讲清楚function snapValue(v: number, gridSize: number) { return Math.round(v / gridSize) * gridSize; }但注意只在做整图平移等操作时直接把节点的绝对坐标做 snap如果拖进分组、即将变成相对坐标时要先算好父节点的全局位置再把节点位置换算到父节点的局部坐标系里做 snap否则子节点可能一进入分组就跳变几像素看起来非常难受。对齐参考线是另一个增色项。我实现的逻辑是拖动过程中遍历其它节点的上下左右四条边如果目标边缘和某条边的距离小于 5px屏幕距离就把目标位置吸附过去并在画布上画一条临时辅助线。遍历全部节点看似开销大实际上一个画布里几百个节点完全可以接受。关键是先统一坐标系——所有参与比较的边都换算到全局画布坐标不然放大缩放后再对齐必然出错。5.3 为什么撤销重做选命令模式而不是快照最简单朴素的撤销方案是“每次操作后保存一份完整图数据快照”撤销时把快照倒回去。几十个节点时确实能跑但到了几百个节点每一次操作都深拷贝整个图内存和 CPU 都绷不住而且快照方案很难做“合并”拖拽过程会产生几十个中间快照撤销时得疯狂点快捷键才能退一步。命令模式是把每次操作的逆操作保存在栈里interface Command { label: string; execute(store: DiagramStore): void; undo(store: DiagramStore): void; }每个用户意图对应一个命令。连续拖拽同一节点时可以在栈顶合并相邻的 MoveNodeCommand把多次微小位移合成一次大位移撤销时一步到位。这个合并逻辑稍微有点技巧只有当新命令和栈顶命令类型相同、操作对象相同时才合并同时更新累计位移量。另外我坚持给每个命令打一个 label后来做历史面板、快捷键提示、键盘可访问性都靠它这笔账非常划算。这块还有一个血泪教训命令里改模型的时候一定不要再绕过 Store 去直接改初始引用。我一度在某个拖拽优化里为了省事直接修改了节点对象结果内部状态被污染历史记录回放全部错位花了一整天排查。命令模式的意义就是“所有变更留痕”任何绕过都是自找麻烦。6. 复盘那些坑、那些教训和下一步想做的事项目做到 V1 可用只是开始真正让我成长最快的是后面几个月里的排错和反思。简单记录几个印象最深的现场。6.1 四个印象深刻的翻车现场完整复盘第一个是缩放点击偏移问题。现象很好描述画布放大到 150% 以后点击任何节点都偏到右下方但缩放为 100% 时一切正常。排查到最终才发现框选和命中检测两个模块各自实现了自己的坐标换算其中一个漏了除以 scale。表面原因是忘记换算深层原因是代码里同时存在“屏幕坐标”“画布坐标”两套体系没有统一入口。后来我把所有事件入口都套了一层 screenToCanvas这类问题基本绝迹。第二个是“数据改了但页面没反应”。有个同事往 node.data 里塞了新字段期望界面上的标签刷新结果等了半天没动静。原因很典型他直接修改了 node.data 里的对象而 Store 的变更通知只在 applyCommand 时触发这种旁路修改自然没有通知。这逼我定了一条纪律任何 UI 可见状态的变化都必须走命令视图层只相信 Store 通知不相信对象引用。这条纪律虽然严格但长期看非常省心。第三个是双击编辑经常被拖拽打断。双击节点进入文本编辑但第一次 click 总是触发了一次拖拽导致输入框刚出现又跳走。根因是“单击”和“拖拽”的判据太粗。我用时间和位移双重阈值解决按下到松开时间小于 250ms 且位移小于 5px 才算 click否则算拖拽。这在交互层是个通用解法凡是“轻点”和“拖动”容易混淆的场景都适用。第四个是上百个节点首次渲染卡顿。打开一份大图时一次性生成几百个 SVG 节点页面明显卡一下。我用了两级优化先做视口裁剪只渲染可视区域的节点再用 requestAnimationFrame 分批渲染把一次大重绘拆成多帧小更新。实际体感从卡顿变得基本无感。这个坑提醒我性能问题要提前想但不用提前做——先留好优化钩子等实测出现再动手。6.2 如果让我重来三件事我会提前做第一先定义带版本号的 JSON Schema而不是先写渲染代码。模型层是地基地基不稳后面每层都在还债。版本号是为了将来模型演进的迁移有据可查否则旧存档一多就废了。第二渲染抽象从第一天就要做。哪怕 V1 确定只用 SVG也把 RenderList 这种中性描述先定义好。后来真切到 Canvas 渲染器时庆幸还留着这层缓冲。就算不换渲染器这层抽象也让代码更容易测试和扩展。第三把自动化测试重点放在坐标换算和路由算法上。交互类 bug 有七八成最终都出在这两个数学问题上。纯函数式的 screenToCanvas、snapValue、buildOrthoPath 非常适合写单元测试多写几个用例能省出大量手工点测时间。算法可以迭代测试可以长期积累这两块投入的回报率远高于在组件样式上抠细节。6.3 后续如果想要继续扩展我会往这几个方向走自动布局是绕不开的下一步但我会把它做成“建议布局”而非“强制布局”一键把当前乱七八糟的节点按节奏排成树形或分层排完之后用户仍然可以自由拖拽调整不能被算法绑架。文档的交互式嵌入也很有价值导出一张静态图只是终点之一如果插图本身能嵌入文档、允许读者缩放漫游甚至选中某个节点查看数据价值会大很多。再往后是语义化模型和自然语言生成的结合让用户直接描述“我要画一个支付链路包含下单、鉴权、扣款三个模块”系统生成初步图稿人在画布上精修。这条路我把数据模型准备好之后实现成本比从零开始低得多。最后再讲一件我反复体会最深的事。diagram-design 从开始到 V1 能用的这段时间里最有价值的决策不是选了 SVG 还是 Canvas、路由算法用了几个拐点而是把所有东西都变成了数据节点是数据、连线是数据、操作是命令。有了它撤销、导出、联动、测试、扩展全都是水到渠成的事。以后再做类似的工具我会先画一个最小的数据结构再写第一行渲染代码让数据始终当主角界面永远只做它的投影。对于正在计划自己做绘图工具的朋友我唯一的建议也是这个先别急着画矩形先把你想要表达的图用数据描述出来。