做可视化类产品有个特别尴尬的阶段功能看起来都能用但一旦要加新图型、改交互、调样式代码就变成一团乱麻。我在做了几套不同的图编辑模块后决定把公共能力抽成一个独立项目名字就叫 diagram-design。它解决的问题其实很朴素——在你往画布上放节点、连边、缩放平移之前先把数据模型定清楚让整个编辑流程是数据驱动的而不是靠一堆全局变量和临时事件凑出来。这篇文章我会从定位、架构、实操到踩坑记录完整讲一遍适合正在做流程图设计器、拓扑可视化、知识图谱编辑器的同学参考。无论你是想找一份能直接改的代码骨架还是想确认自己项目里的渲染方案和布局思路有没有走偏这篇都能给你一些可以落地的判断依据。1. 项目定位diagram-design 到底解决什么问题1.1 传统图编辑代码为什么越写越乱不少团队做图编辑器起步都很像。先在画布上画几个矩形然后监听鼠标拖拽改一下矩形的位置接着加连线再为连线补箭头后面还要处理缩放、吸附、删除、属性面板……表面上每块逻辑都不难但代码累积到一定程度后就会出现几个特征节点和边的数据结构散落在组件状态里、渲染层和业务数据揉在一起、布局算法一跑就把用户手动调过的位置全部覆盖掉。这些问题的根因是缺少一个清晰的领域模型。diagram-design 的设计初衷就是先把“图”拆成稳定对象节点、边、画布、视图再用统一的数据流把四者串起来。这样做的好处不是初期快而是后期改得动。1.2 目标使用场景这个方案最适用的是这几类项目可视化流程设计器比如审批流、任务编排、逻辑编排。网络拓扑、依赖关系图谱、组织架构图。低代码平台里面的表单联动设计器或页面流设计器。任何需要用户手动编排节点、连接关系并持久化保存的前端页面。如果你只是做一张静态展示图其实不用引入这样的架构直接用现成的绘图软件导成图片更高效。diagram-design 的核心价值在于“用户要在页面上直接编辑图”并且编辑结果需要回传到后端、再渲染成不同形态。1.3 我对这个项目定下的三条原则第一数据模型独立于渲染层。不管底层用的是 SVG、Canvas 还是别的方案内部存的都是统一的节点和边结构。第二交互只改数据不改视图状态。拖拽、连线、删除这些动作最终都落到对数据模型的增删改上视图只是数据的投影。第三条是布局算法可选不能让自动布局破坏手动调整结果。用户动过位置的节点算法再跑一遍也要保留用户优先级。这三条原则看起来很简单但在实际开发中只要坚持住后面每一块功能都会顺很多。2. 整体架构与关键设计决策2.1 四层模型数据、布局、渲染、交互diagram-design 整体分了四层每一层只依赖下一层不跨层调用。数据层负责定义节点、边、分组、画布属性。布局层负责计算节点坐标和边路径。渲染层把数据变成 SVG 元素或 Canvas 绘制指令。交互层处理鼠标、键盘、触摸事件再把结果写回数据层。这种分层方式在团队协作时特别有用。同事可以只关注数据层写导入导出另一个人专注布局算法剩下的专门打磨交互细节。只要接口约定稳定互相之间几乎不会产生冲突。2.2 渲染方案SVG 还是 Canvas这是我在项目里被问得最多的问题。我的结论是如果图规模在几千个节点以内并且需要复杂交互、自定义节点内容优先选 SVG。如果节点规模上万并且主要做静态展示或简单 hover 效果选 Canvas 更划算。SVG 的优势是有独立的 DOM 节点可以做很方便的事件绑定、CSS 样式控制、局部更新。缺点是大规模场景下性能下滑明显因为浏览器要维护大量 DOM 节点。Canvas 的绘制更底层帧率上限高但实现自定义节点的成本相对更高文本换行、事件命中等都需要自己算。diagram-design 当前版本基于 SVG 实现。选它不是为了赶时髦而是我调研过现有需求大部分使用者需要富文本节点、自定义图标、节点内嵌表单这些场景用 SVG 开发效率最高。为了避免性能问题我在内部做了可视区域裁剪和动态挂载只渲染视口内的节点。2.3 布局算法怎么选布局是图设计工具里最容易好心办坏事的部分。常见的思路有两种分层布局适合流程图、依赖关系图会把节点按层级从上到下排列力导向布局适合知识图谱、社交网络通过模拟物理作用力让节点自然分布。diagram-design 内部先内置了一版分层布局算法处理有向无环图的层级划分。选它作为默认是因为大多数流程编辑场景都遵循从上到下、从左到右的阅读顺序。力导向布局我留了扩展点没有耦合在核心流程里。布局模块一定要设计成可插拔的。我曾经见过一个项目自动布局内置在渲染循环里导致用户想手动微调一个节点都得先绕过自动重排这种体验非常劝退。2.4 交互状态机图编辑器最容易写乱的是交互状态。拖拽节点、拖拽画布、框选节点、连线中、正在缩放这些状态如果只用几个布尔值表示很容易出现拖完画布又触发了节点点击这类问题。我在项目里引入了一个交互状态机把所有交互拆成 idle、draggingNode、draggingCanvas、connecting、boxSelecting、resizing 几个状态。每次事件进来先判断当前状态再决定这次事件交给谁处理。状态切换时有明确的 enter 和 exit 回调用来清理临时状态和光标样式。状态机看起来多写了代码但对长期维护的收益非常大。尤其是后面要支持触摸屏、触控笔这类多输入源设备时没有一个清晰的状态机事件冲突能把人逼疯。3. 从零实现一个可用的 diagram-design 核心流程3.1 项目结构建议我推荐按模块拆而不是按页面拆。一个比较舒服的结构是这样diagram-design/ core/ types.ts // 节点、边、视图模型类型定义 model.ts // 图数据增删改查 viewport.ts // 缩放、平移、坐标转换 history.ts // 撤销重做 layout/ layered.ts // 分层布局 base.ts // 布局算法接口 render/ svgRenderer.ts // SVG 渲染 nodeRenderer.ts // 节点渲染工厂 edgeRenderer.ts // 边渲染工厂 interaction/ stateMachine.ts // 交互状态机 dragController.ts connectController.ts selectionController.ts index.ts // 对外统一入口这个结构有几个好处。core 不依赖渲染层所以未来如果要把 SVG 换成 Canvascore 部分可以原封不动。layout 只依赖 core 的数据结构每个布局算法是一个函数输入节点和边输出计算后的位置。interaction 层比较薄它做的事就是从事件里解读用户意图调用 core 的方法。3.2 核心数据模型数据模型是整个项目的地基。我用了 TypeScript 定义因为类型系统能在编译阶段拦住大量低级错误。export type NodeId string; export interface DiagramNode { id: NodeId; label: string; position: { x: number; y: number }; size: { width: number; height: number }; style?: Recordstring, string; locked?: boolean; data?: unknown; } export interface DiagramEdge { id: string; source: NodeId; target: NodeId; label?: string; sourceAnchor?: top | bottom | left | right; targetAnchor?: top | bottom | left | right; points?: Array{ x: number; y: number }; style?: Recordstring, string; } export interface DiagramModel { nodes: DiagramNode[]; edges: DiagramEdge[]; viewport: { x: number; y: number; scale: number; }; }这里有一个容易被忽略的设计点边上的 points 字段是可选的手动路径点。自动布局可以生成默认路径但用户一旦手动调整过折线路径就必须以用户提供的 points 为准。我见过很多工具在加载数据时直接重新计算全部边路径导致用户保存过的连线样式打开就变样。diagram-design 的规则很简单没有 points 就自动生成有 points 就原样保留。3.3 画布坐标与屏幕坐标的转换做图编辑器最容易出错的地方有两个一个是鼠标位置计算一个是缩放平移后的坐标换算。我把这个功能收敛成了一个 Viewport 模块核心就两个函数。export class Viewport { constructor( private canvasSize: { width: number; height: number }, private view: { x: number; y: number; scale: number }, ) {} screenToWorld(screenPos: { x: number; y: number }) { return { x: (screenPos.x - this.view.x) / this.view.scale, y: (screenPos.y - this.view.y) / this.view.scale, }; } worldToScreen(worldPos: { x: number; y: number }) { return { x: worldPos.x * this.view.scale this.view.x, y: worldPos.y * this.view.scale this.view.y, }; } applyToSvgElement(svgEl: SVGGraphicsElement) { svgEl.setAttribute( transform, translate(${this.view.x}, ${this.view.y}) scale(${this.view.scale}), ); } }实际使用中我把缩放和平移的变换放在最外层 SVG 的g元素上内部的节点都使用世界坐标绘制。这样节点拖拽时只需要改世界坐标不需要关心当前缩放比例。screenToWorld 负责把鼠标的屏幕坐标转换成世界坐标再进行节点位置计算。3.4 SVG 渲染层实现渲染层我采用了按类型注册渲染器的思路。每种节点类型对应一个渲染函数入口根据节点类型去查找对应函数。export class SvgRenderer { private root: SVGSVGElement; private nodeLayer: SVGGElement; private edgeLayer: SVGGElement; constructor(container: HTMLElement) { const svg document.createElementNS(http://www.w3.org/2000/svg, svg); svg.style.width 100%; svg.style.height 100%; container.appendChild(svg); this.root svg; this.edgeLayer this.createLayer(); this.nodeLayer this.createLayer(); this.root.appendChild(this.edgeLayer); this.root.appendChild(this.nodeLayer); } render(model: DiagramModel) { this.renderEdges(model.edges); this.renderNodes(model.nodes); } private renderEdges(edges: DiagramEdge[]) { this.edgeLayer.innerHTML ; for (const edge of edges) { const path document.createElementNS( http://www.w3.org/2000/svg, path, ); path.setAttribute(d, this.buildPath(edge)); path.setAttribute(stroke, #94a3b8); path.setAttribute(stroke-width, 1.5); path.setAttribute(fill, none); path.dataset.edgeId edge.id; this.edgeLayer.appendChild(path); } } private renderNodes(nodes: DiagramNode[]) { this.nodeLayer.innerHTML ; for (const node of nodes) { const g document.createElementNS(http://www.w3.org/2000/svg, g); g.setAttribute(transform, translate(${node.position.x}, ${node.position.y})); const rect document.createElementNS(http://www.w3.org/2000/svg, rect); rect.setAttribute(width, String(node.size.width)); rect.setAttribute(height, String(node.size.height)); rect.setAttribute(rx, 6); rect.setAttribute(fill, #fff); rect.setAttribute(stroke, #334155); rect.setAttribute(stroke-width, 1); g.appendChild(rect); const text document.createElementNS(http://www.w3.org/2000/svg, text); text.setAttribute(x, 0); text.setAttribute(y, 0); text.setAttribute(dominant-baseline, middle); text.setAttribute(text-anchor, middle); text.textContent node.label; g.appendChild(text); this.nodeLayer.appendChild(g); } } }这里有一个性能细节我用innerHTML 清空图层是因为当前版本图规模不大整体重绘足够简单。如果后续节点数上去了应该改为缓存节点 DOM 映射用 diff 方式更新位置和属性而不是全部重建。我给渲染层留了updateNode(node)这样的单节点更新接口就是为了在这个阶段切换时不用重写整个渲染器。3.5 分层布局算法实现自动布局我选了分层排列。基本思想是先给每个节点分配层级再在同一层内按顺序排列最后计算坐标。export function layeredLayout(nodes: DiagramNode[], edges: DiagramEdge[]) { const indegree new Mapstring, number(); const outNodes new Mapstring, string[](); const layers: string[][] []; for (const node of nodes) indegree.set(node.id, 0); for (const edge of edges) { indegree.set(edge.target, (indegree.get(edge.target) ?? 0) 1); outNodes.set( edge.source, [...(outNodes.get(edge.source) ?? []), edge.target], ); } let queue: string[] []; for (const [id, deg] of indegree) { if (deg 0) queue.push(id); } while (queue.length 0) { layers.push(queue); const nextQueue: string[] []; for (const id of queue) { for (const target of outNodes.get(id) ?? []) { indegree.set(target, (indegree.get(target) ?? 0) - 1); if (indegree.get(target) 0) nextQueue.push(target); } } queue nextQueue; } const nodeMap new Map(nodes.map((n) [n.id, n])); const nodeWidth 160; const nodeHeight 56; const gapX 60; const gapY 40; layers.forEach((layer, layerIndex) { layer.forEach((nodeId, columnIndex) { const node nodeMap.get(nodeId); if (!node) return; node.position.x columnIndex * (nodeWidth gapX); node.position.y layerIndex * (nodeHeight gapY); }); }); return nodes; }这个版本针对有向无环图已经能应付大多数流程编辑场景。真正在生产里使用还需要处理环状依赖、同层节点顺序优化、跨层边的折线路径计算。我建议按这个基础版本先跑通全流程再针对你的业务图结构逐步加策略。不要一开始就追求完美布局因为业务图往往有很特殊的手动调整需求算法再漂亮也替代不了人的判断。3.6 拖拽与连线交互交互层里最常用的就是节点拖拽。我把拖拽逻辑抽成了 DragController它只负责计算位移和更新节点数据。export class DragController { private draggingId: NodeId | null null; private startPos { x: 0, y: 0 }; private lastScreen { x: 0, y: 0 }; constructor( private viewport: Viewport, private model: DiagramModel, private onNodeMoved: (node: DiagramNode) void, ) {} onPointerDown(nodeId: NodeId, screenPos: { x: number; y: number }) { const node this.model.nodes.find((n) n.id nodeId); if (!node || node.locked) return; this.draggingId nodeId; this.startPos { ...node.position }; this.lastScreen screenPos; } onPointerMove(screenPos: { x: number; y: number }) { if (!this.draggingId) return; const worldDelta this.viewport.screenToWorld(screenPos); const lastWorld this.viewport.screenToWorld(this.lastScreen); const dx worldDelta.x - lastWorld.x; const dy worldDelta.y - lastWorld.y; const node this.model.nodes.find((n) n.id this.draggingId); if (node) { node.position.x dx; node.position.y dy; this.onNodeMoved(node); } this.lastScreen screenPos; } onPointerUp() { this.draggingId null; } }这里的关键是鼠标移动事件拿到的坐标是屏幕坐标但节点存在世界坐标里。如果拖拽时直接设置node.position mouseScreenPos那缩放状态下节点就会乱飘。正确做法是计算两次屏幕坐标对应的世界坐标差然后累加位移。这个细节是我最初做第一版编辑器时踩过的坑怎么强调都不过分。连线交互我会做成一个独立控制器。用户从一个节点的连接点按下拖拽到另一个节点上释放然后创建一条 edge。需要注意的是按下和释放时都要做坐标转换和节点命中检测避免把连线起点算成画布坐标。4. 常见问题与排查技巧实录4.1 节点一多就卡顿SVG 渲染在节点数达到三四千时性能问题会比较明显。我实测下来卡顿主要不是绘制本身而是每次模型更新都全量重建 DOM。解决办法是缩小更新范围拖动单个节点时只更新那个节点对应g元素的 transform拖动画布时只更新最外层 viewport 的 transform。如果不得不做全量重绘也尽量用requestAnimationFrame合并多次更新。另一个优化方向是视口裁剪。比如整个画布有五千个节点但屏幕里只看得见几十个那渲染层只需要创建可见节点对应的 DOM。diagram-design 的 Viewport 模块维护了当前可视区域的世界坐标范围渲染前做一个矩形相交判断过滤掉视口外的节点。4.2 缩放后坐标漂移、节点错位坐标漂移大多来自同一个原因在不同地方用了不同坐标系。我见过某位同事的代码鼠标事件里直接用clientX减去容器偏移量当世界坐标用。页面不缩放、不滚动时没问题一旦画布整体缩放或页面有滚动条位置就全错。排查这类问题时先确认三件事一SVG 根元素的大小是不是和容器一致二viewport 的 scale 和 translate 是不是只设置在根g元素上三所有从屏幕坐标反算世界坐标的地方是不是都经过同一个 screenToWorld 方法。如果这三处统一了坐标问题基本能消灭掉 90%。4.3 连线命中区域太小默认的路径描边大概两像素宽用户很难点到。我给边加了一层不可见的宽命中区域先绘制一条透明但粗细为 12px 的路径再在它上面绘制实际可见路径。这样用户闭着眼睛都能选中连线视觉上却看不到多余的东西。4.4 撤销重做时状态不一致撤销重做模块我一开始直接存整个 DiagramModel 的快照。节点少的时候挺好用节点多起来内存占用很快。另外快照恢复会覆盖用户正在进行的操作比如正在拖节点时按了撤销画布和拖拽状态就会打架。我的改进方案是记录操作命令而不是存全量快照。每次操作对应一个 command带 undo 和 redo 两个方法。比如移动节点undo 就是把节点位置设置为操作前的值。这样撤销重做可以精确控制到原子操作也没有快照的内存压力。唯一麻烦的是有些操作没法逆运算比如删除节点后重新创建需要保存节点的 id 和全部属性。总体来看命令模式虽然写起来啰嗦但体验和稳定性都远高于快照方案。4.5 自动布局覆盖手动调整我刚做自动布局时直接在布局函数里遍历所有节点并设置 position。结果用户辛苦手动对齐的一批节点点一下自动排列就全打乱了。现在的做法是给节点加一个layoutPriority字段。布局算法计算新位置之前先检查节点有没有手动调整过。手动调整过的节点在自动布局时保持不动未被碰过的节点参与布局。这个逻辑不能写死所以在 layout/base.ts 里定义了统一的接口每种布局算法都按这个规则执行。5. 我在实际项目中沉淀的一点经验5.1 先定义好“什么都不做”的状态图编辑器里最容易被遗漏的不是某个功能而是当用户没有进行任何操作时系统应该是什么状态。指针悬停在空白处、悬停在节点上、按下空白开始框选、按下节点开始拖拽这些状态之间必须划分清楚边界。我建议开发过程中随时问自己如果用户在这一半突然按了 Esc交互状态会不会卡死如果会说明状态机和数据的同步还没做完整。5.2 给所有数据加上稳定 ID现在听起来是常识但我确实见过有人用数组下标作为节点唯一标识。一旦节点被删除或排序变化所有引用它的边和选中的记录都会错乱。diagram-design 的节点和边都有自己的 id导入导出、历史记录、跨端同步都依赖这个 id。生产环境下哪怕只有几十个节点也务必用 id 而非索引。5.3 导出格式要留扩展余地图设计工具一定会做保存、加载和导出。我建议把导出格式设计成版本化 JSON而不是直接把组件状态序列化出去。业务里我在根对象上加了一个schemaVersion字段未来升级数据模型时可以做迁移。另一类需求是导出图片SVG 转 PNG 要先把文字转成路径或者内联字体否则到别的机器上字体就会丢失。这一块我在项目早期没注意后来专门写了导出前处理流程才解决。5.4 交互优先级要有明确顺序框选、节点拖拽、连线、画布平移这些操作经常同时具备触发条件。我的做法是给交互优先级排序最小面积命中优先于空白区域拖拽按住空格键时强制进入画布平移模式连线状态下点击节点只作为连线目标不再触发节点选中。这个优先级逻辑写在一个独立函数里事件进来先跑一遍规则得出唯一结论再分发。这个设计让整个交互层的 bug 少了很多。6. 后续还可以怎么扩展diagram-design 现在的形态只是一个核心框架很多扩展方向我都梳理过。简单讲三个我自己接下来打算做的事。第一个是多选编辑。单节点拖拽已经支持但框选、多节点对齐、批量删除、批量样式修改还没有做完整。实现多选时建议在模型层维护selectedNodeIds: Setstring所有交互都基于这个集合判断而不是把选中状态零散存在各个节点里。第二个是迷你地图。对于画布特别大的场景右下角放一个缩略图能显著提升用户长距离导航的体验。迷你地图实现思路不复杂把所有节点在世界坐标下缩小绘制在一个小 SVG 里再用一个矩形表示当前视口范围。第三个是协作编辑。多人同时编辑同一张图时核心问题从本地状态管理变成了冲突处理。我建议在 commands 模块上加操作日志同步每个用户把命令广播给其他人并重放天然就能解决大部分冲突。这比直接同步全量模型要可靠得多。7. 最后一个提醒如果只让我给一条建议我会说在写任何渲染代码之前先把核心数据模型花时间抠清楚。diagram-design 之所以能稳定迭代靠的不是某段精妙的交互代码而是节点、边、视图三者之间的关系从头到尾没变过。后面所有功能都是在这条稳定底线上加起来的。你能看到这里说明你大概率也在盘算做一个属于自己的图编辑器。建议先拿一个真实业务场景跑一遍整个流程哪怕是只支持节点和连线的原型都行。跑完之后你会发现真正麻烦的从来不是画一个矩形而是数据—布局—渲染—交互这条链路能不能始终一致地转起来。