最近在梳理自己的技术工具箱时,顺手把一个反复用过很多次的思路沉淀成了一个小模块,名字就叫 diagram-design。简单说,它是一套面向“节点 + 连线”类图形场景的设计与渲染方案,不是某个具体软件,也不是某个绑定框架的插件,而是一种用数据驱动的方式去画架构图、流程图、拓扑图、思维导图甚至状态机的通用套路。工作中只要涉及“把一堆对象之间的关系画出来”,你大概率就会用到这套东西。
这篇文章适合两类人看:一类是前端开发者,尤其是经常跟可视化、编辑器、低代码表单打交道的,另一类是产品经理或技术方案设计者,想搞清楚“为什么有些图能拖拽、能缩放、能自动排版,背后到底发生了什么”。我不会在这里堆概念,而是直接拆解这个设计模块的核心思路、数据结构、渲染原理和实操过程,顺带把我在真实项目里踩过的一些坑也一并放在最后。如果你想自己动手实现一个类似的图表设计器,这篇文章可以当作一份比较完整的路线图。
1. 整体设计思路与方案取舍
1.1 diagram-design 到底解决什么问题
先说清楚一个容易混淆的点:diagram-design 听起来像是一个“设计图”工具,但实际上它解决的问题是“图如何从数据变成可交互的界面”。所谓“图”,指的是由节点(Node)和边(Edge)组成的结构。节点可以是一个服务器、一个业务流程步骤、一个类、一个组件,边表示它们之间的关系,比如调用、依赖、跳转、连线。你把这个数据结构喂给渲染层,diagram-design 负责把它画出来,并且让用户可以用鼠标选中、拖动、缩放、甚至编辑。
在真实的项目里,这套东西最常见的落地形态是:
- 架构可视化:把微服务、中间件、存储节点画在一张图上,用连线表示调用关系;
- 流程编排:允许用户拖拽节点、连线来设计一个审批流或工作流;
- 图谱展示:在知识库场景下展示实体之间的关系网络;
- 配置可视化:把一份 JSON 配置渲染成一张可读性极强的图,支持回写。
我在一个实际项目里,需要把一份嵌套很深的配置文件渲染成架构图,并且支持用户直接拖拽调整位置后再导出。如果在这个场景下手写一大堆 DOM 和事件绑定,代码很快就会失控。diagram-design 的核心价值就在于:你把图的数据结构定义清楚,剩下的布局、渲染、交互都围绕一套统一模型展开,而不是“这里画个框,那里画条线”的零散逻辑。
1.2 为什么不自上而下直接画图
很多人第一次做这类需求,第一反应是:直接写 SVG 或 Canvas,把每个矩形画出来,再画几条线连起来,这不就完事了吗?
对于一次性、静态的图,确实可以这么干。但一旦进入“可编辑”或者“动态更新”的场景,直接绘制会迅速暴露出几个痛点:
- 节点和边没有独立的数据结构,想删除某个节点,你得手动找到它对应的图形元素并移除;
- 节点位置变化时,所有连线都要跟着重算端点,没有统一机制就到处都是散落的重绘代码;
- 选中、拖拽、缩放这些交互状态难以维护,因为使用者无法区分“哪个数据对象对应哪个图形对象”;
- 如果想增加一种新的节点类型,可能需要改动多处逻辑,扩展成本很高。
diagram-design 的设计思路是把“数据模型”和“视图表现”分开。所有节点和边的业务数据都存在普通对象里,视图层只是一个“投影”。你改的是数据,界面随之更新;反之你在界面上拖拽,本质上是修改数据的坐标字段。这样无论图有多复杂,逻辑上都是清晰的,开发效率会高很多。
1.3 渲染层选型:SVG 还是 Canvas 还是 DOM
在方案设计里,最容易引发争论的是渲染层的选择。我自己的实践是从需求出发来做选型,而不是盲目追求某一种技术。
| 对比维度 | SVG | Canvas | 普通 DOM |
|---|---|---|---|
| 图形数量 | 几千个节点内都很流畅 | 上万元素性能优势明显 | 几百个以内还行,多了会卡 |
| 交互能力 | 每个元素天然是独立的,事件绑定方便 | 需要自己维护命中检测 | 交互最强,但样式和连线区域难处理 |
| 缩放平移 | 整体 viewBox 缩放即可 | 需要重绘或调整相机参数 | 需要 transform 联动,容易出问题 |
| 文本处理 | 原生 TSPAN、中文换行好处理 | 用 fillText,换行要自己算 | 文本表现力最强 |
| 复杂连线 | 边、箭头、曲线都容易做 | 同样容易,但需要手动绘制 | 比较麻烦,不适合做主渲染 |
| 动态更新 | 可以增量更新 | 通常整个重绘 | 变更多时会频繁触发重排 |
在我的绝大多数 diagram-design 场景里,节点数量也就是几十到几百个,而且需要支持节点的点击、拖拽、右键菜单。我选择 SVG 作为主渲染方案有几个很实际的理由:
- 每个节点和边都有对应的真实 DOM 元素,调试时直接在 DevTools 里点开就能看;
- 事件机制是现成的,click、mousedown、mousemove 直接绑在具体元素上;
- 文档结构的语义化很好,异常状态可以直接通过 CSS 来高亮;
- 导出图片时,SVG 可以直接序列化成文件,不用像 Canvas 那样专门做截图。
如果有一天节点数量真的到几千甚至上万,我可能会把主渲染层替换成 Canvas,但保留 SVG 做绘制叠加层,用于选中框和辅助线。这个方案变更不影响上层数据结构,也印证了“数据层与视图层分离”带来的好处。
1.4 四层架构:数据层、计算层、渲染层、交互层
我在设计这个模块时,把整个系统分成了四层,职责清晰,彼此之间只通过约定好的接口通信:
- 数据层:维护节点数组、边数组、画布缩放比例、视口偏移量。这层不关心任何视觉细节。
- 计算层:接收数据层的基础数据,执行布局算法、连线路径计算、边界碰撞检测、自动排版等纯函数逻辑。这层不碰 DOM,逻辑可以单独测试。
- 渲染层:拿到计算层产出的坐标数据,创建/更新 SVG 里的节点元素和边元素。这层只做“数据变视图”的映射。
- 交互层:监听鼠标/触摸事件,把拖拽、缩放、框选等操作翻译成数据层的修改。
这四层各司其职,代码就不会纠缠成一大团。我最开始写第一个版本时,把所有逻辑挤在一个类里,导致后来加一个“框选”功能时,不得不重构了一半代码。后来才痛下决心分层,把布局算法、渲染逻辑、事件逻辑全部拆到独立的模块里,整个项目的可维护性立刻上了一个台阶。
2. 核心细节拆解与数据模型设计
2.1 节点数据结构的字段设计
节点的数据结构是整个 diagram-design 的地基。如果这里设计得不好,后面积累大量业务数据后会很难迁移。我在项目中使用的节点数据结构大概长这样:
interface DiagramNode { id: string; type: 'normal' | 'start' | 'end' | 'branch' | 'group'; x: number; y: number; width: number; height: number; label: string; style?: { fill?: string; stroke?: string; textColor?: string; radius?: number; }; data?: Record<string, unknown>; parentId?: string | null; collapsed?: boolean; ports?: PortPosition[]; }这里有几个字段值得单独解释一下:
- id 是稳定且唯一的,因为所有连线都通过 id 引用节点。如果 id 是动态生成的,刷新后连线就会断裂。
- x 和 y 表示节点左上角的坐标,而不是中心点。使用左上角坐标系,计算包围盒时更直接。
- type 决定了节点的视觉风格。可以是简单的矩形,带圆角,也可以是圆形或菱形,渲染层根据 type 分发到不同的绘制函数。
- parentId 用于支持嵌套分组。这个字段在后续做折叠时很重要,如果节点属于某个组,移动组时所有子节点会一起移动。
- ports 是连接点列表,表示边上可以挂接的位置。默认情况下,我取节点四边的中点作为默认锚点;复杂节点可以声明多个端口,连线时锚点优先取端口位置,而不是系统自动计算。
另外,我养成了一个习惯:任何自定义数据都放在 data 字段里,不让业务字段污染图形字段。因为图形的 x、y、width、height 本身经常在交互中被修改,如果业务数据和坐标混在一起,序列化回来时容易弄脏。
2.2 边数据结构的字段设计
边是连接两个节点的“关系描述”。它的结构比节点要复杂一些,因为一条边需要知道从哪来、到哪去、以什么路径来,以及是否带有标签。
interface DiagramEdge { id: string; source: string; target: string; sourcePort?: string | null; targetPort?: string | null; label?: string; pathType: 'straight' | 'polyline' | 'bezier' | 'orthogonal'; style?: { stroke?: string; strokeWidth?: number; dash?: string; arrowEnd?: boolean; }; data?: Record<string, unknown>; }在设计这个结构时,我踩过一个坑:一开始没有把 sourcePort 和 targetPort 作为独立字段,而是直接让 source 和 target 存成“节点id:端口id”的拼接形式。后来发现,解析起来非常麻烦,而且一旦端口 id 中包含冒号,就会解析错位。后来统一改为 source 只存节点 id,端口信息单独放,逻辑一下就清晰了。
pathType 决定了边的渲染方式。straight 就是直线,polyline 是折线,bezier 是平滑曲线,orthogonal 是正交线段(横平竖直,像电路图一样)。我实际用得最多的是 polyline 和 orthogonal,因为这两种风格在架构图里看起来最规整。
2.3 画布状态与坐标系统
除了节点和边,整个画布还有一层“视口状态”,包括缩放比例和位移偏移:
interface ViewportState { scale: number; offsetX: number; offsetY: number; }在渲染时,我不会直接修改节点的真实坐标,而是把画布坐标换算到屏幕坐标。屏幕坐标 = 画布坐标 × scale + offset。这样的好处是,缩放和移动画布时只需要改 viewport 状态,所有节点的数据坐标不会受到任何影响。用户放大缩小只是在“看”,不是在“改”。
这一点在被很多人忽略。如果缩放时直接改节点的 x/y,那么用户再保存文件时,坐标数据就已经被污染了,而且缩小后放大的话坐标回不来,图的位置会漂移。所以我把节点坐标和视口坐标严格区分开,是 diagram-design 的一个关键约定。
2.4 连线路径计算的核心逻辑
连线是设计图里最容易翻车的地方。节点一多,边交叉、箭头错位、线穿过节点的问题就会频繁暴露。我先处理最简单的场景:矩形节点之间的连线。
默认情况下,我会计算源矩形和目标矩形的中心点,然后找出一条起点和终点分别在两个矩形边缘上的连线。做法是:先尝试从四个方向中选择最优的方向。
具体步骤是:
- 计算中心连线向量,也就是目标中心减去源中心的向量;
- 根据向量落在哪个角度区间,决定线的起点落在源矩形的哪一条边,终点落在目标矩形的哪一条边;
- 如果是正交折线,就把路径分成三段或五段,中间段保证水平或者垂直。
举个例子,源节点在左边,目标节点在右边,那起点就取源节点的右边缘中点,终点取目标节点的左边缘中点。如果两个节点上下分布,则起点取下边缘中点,终点取上边缘中点。如果源节点和目标节点在水平方向重叠,但垂直方向有偏移,则起点取底部中点,终点取顶部中点,中间用两段垂直和一段水平线连接。
处理这个逻辑的时候,我实现了一个纯函数,输入是源矩形、目标矩形和路径类型,输出是一条 SVG path 字符串。这个函数不依赖任何 DOM,所以单元测试也特别好写。我把所有几何计算都集中在这个纯函数模块里,后续如果想增加新的路径算法,不需要动渲染层。
3. 实操过程:从零搭建一个可运行的 diagram-design 核心模块
3.1 项目结构与工程搭建
我先用 TypeScript 搭建一个最小可运行的模块,不需要脚手架,直接使用浏览器原生能力配合一个简单的构建工具。目录结构如下:
diagram-design/ ├── src/ │ ├── model/ │ │ ├── types.ts │ │ ├── graph.ts │ ├── layout/ │ │ ├── simpleGrid.ts │ │ ├── treeLayout.ts │ ├── geometry/ │ │ ├── anchor.ts │ │ ├── edgePath.ts │ ├── render/ │ │ ├── svgRenderer.ts │ ├── interaction/ │ │ ├── dragController.ts │ │ ├── zoomController.ts ├── examples/ │ ├── basic.html │ ├── basic.ts ├── package.json └── tsconfig.jsongraph.ts 里维护最简单的数据增删查接口:
class DiagramGraph { private nodes = new Map<string, DiagramNode>(); private edges = new Map<string, DiagramEdge>(); addNode(node: DiagramNode) { this.nodes.set(node.id, node); } addEdge(edge: DiagramEdge) { if (!this.nodes.has(edge.source) || !this.nodes.has(edge.target)) { throw new Error(`Edge ${edge.id} references missing node`); } this.edges.set(edge.id, edge); } removeNode(id: string) { this.nodes.delete(id); for (const [edgeId, edge] of this.edges) { if (edge.source === id || edge.target === id) { this.edges.delete(edgeId); } } } getNode(id: string) { return this.nodes.get(id); } getNodes() { return [...this.nodes.values()]; } getEdges() { return [...this.edges.values()]; } }这里的一个细节是,addEdge 时要校验 source 和 target 是否真的存在。如果先加边再加节点,顺序错了就直接抛错,避免保存了“悬挂边”导致后续渲染崩溃。实际项目中,我在加载远程数据时也会做同样的校验,发现悬空引用时,要么跳过该边,要么自动创建丢失的节点并在标签上标记异常。
3.2 布局算法:把散乱的数据铺在画布上
如果用户上传的节点都没有坐标,直接画图会全叠在原点。所以需要一个简单的自动布局模块。我实现两种最基本的布局:网格布局和树形布局。
网格布局的逻辑很简单:把节点按照指定的列数或行数排布,每个节点占用固定宽高,节点间设置间距:
function gridLayout(nodes: DiagramNode[], options: { cols?: number; gapX?: number; gapY?: number }): void { const cols = options.cols ?? Math.ceil(Math.sqrt(nodes.length)); const gapX = options.gapX ?? 40; const gapY = options.gapY ?? 40; nodes.forEach((node, index) => { const row = Math.floor(index / cols); const col = index % cols; node.x = col * (node.width + gapX); node.y = row * (node.height + gapY); }); }树形布局更复杂一些。我把所有节点和边构建成一个森林结构,从没有父节点的根节点开始,采用深度优先遍历的方式计算子节点位置,同一层的节点在垂直方向对齐,子节点在水平方向右移:
function treeLayout( graph: DiagramGraph, options: { levelGap?: number; siblingGap?: number } ): void { const levelGap = options.levelGap ?? 80; const siblingGap = options.siblingGap ?? 40; const childrenMap: Record<string, string[]> = {}; for (const node of graph.getNodes()) { childrenMap[node.id] = []; } for (const edge of graph.getEdges()) { if (childrenMap[edge.source]) { childrenMap[edge.source].push(edge.target); } } const depths: Record<string, number> = {}; const queue = graph.getNodes().filter((n) => { return !graph.getEdges().some((e) => e.target === n.id); }); for (const root of queue) { depths[root.id] = 0; } while (queue.length > 0) { const cursor = queue.shift()!; for (const childId of childrenMap[cursor.id] || []) { if (depths[childId] === undefined || depths[childId] < depths[cursor.id] + 1) { depths[childId] = depths[cursor.id] + 1; } queue.push(graph.getNode(childId)!); } } const depthGroups: DiagramNode[][] = []; for (const node of graph.getNodes()) { const depth = depths[node.id] ?? 0; (depthGroups[depth] = depthGroups[depth] ?? []).push(node); } depthGroups.forEach((group, depth) => { group.forEach((node, index) => { node.x = depth * (node.width + levelGap); node.y = index * (node.height + siblingGap); }); }); }这个算法只是最基础的版本,工业级布局算法会复杂得多,包括子树宽度测量、对称性调整、交叉最小化等。但如果只是快速把数据铺开,这层足够用了。等用户手动拖拽调整后,布局结果就退出舞台,用户放置的位置成为新的坐标。
3.3 SVG 渲染层:从数据到图形的关键映射
渲染层是 diagram-design 最直观的部分。我使用 SVG 的全局坐标系,画布内部永远使用一个根<g>元素承接所有节点和边:
class SvgRenderer { private svg: SVGSVGElement; private root: SVGGElement; private elements = new Map<string, SVGGElement>(); constructor(container: HTMLElement) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); const root = document.createElementNS('http://www.w3.org/2000/svg', 'g'); svg.appendChild(root); container.appendChild(svg); this.svg = svg; this.root = root; } setViewport(scale: number, offsetX: number, offsetY: number) { this.root.setAttribute('transform', `translate(${offsetX} ${offsetY}) scale(${scale})`); } render(graph: DiagramGraph) { this.clear(); for (const edge of graph.getEdges()) { const path = this.renderEdge(edge, graph); this.root.appendChild(path); } for (const node of graph.getNodes()) { const g = this.renderNode(node); this.root.appendChild(g); this.elements.set(node.id, g); } } private renderNode(node: DiagramNode): SVGGElement { const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', String(node.x)); rect.setAttribute('y', String(node.y)); rect.setAttribute('width', String(node.width)); rect.setAttribute('height', String(node.height)); rect.setAttribute('fill', node.style?.fill ?? '#ffffff'); rect.setAttribute('stroke', node.style?.stroke ?? '#888888'); rect.setAttribute('rx', String(node.style?.radius ?? 6)); g.appendChild(rect); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', String(node.x + node.width / 2)); text.setAttribute('y', String(node.y + node.height / 2)); text.setAttribute('text-anchor', 'middle'); text.setAttribute('dominant-baseline', 'middle'); text.textContent = node.label; g.appendChild(text); return g; } private renderEdge(edge: DiagramEdge, graph: DiagramGraph): SVGPathElement { const source = graph.getNode(edge.source)!; const target = graph.getNode(edge.target)!; const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const d = computeEdgePath(source, target, edge.pathType); path.setAttribute('d', d); path.setAttribute('fill', 'none'); path.setAttribute('stroke', edge.style?.stroke ?? '#999999'); path.setAttribute('stroke-width', String(edge.style?.strokeWidth ?? 2)); if (edge.style?.dash) { path.setAttribute('stroke-dasharray', edge.style.dash); } if (edge.style?.arrowEnd) { appendArrow(path, source, target); } return path; } clear() { while (this.root.firstChild) { this.root.removeChild(this.root.firstChild); } this.elements.clear(); } }注意渲染顺序:先渲染边,再渲染节点。这样节点永远浮在边之上,不会出现边遮盖文字或节点图形的情况。如果不小心把节点渲染在边之前,后续会发现连线穿过节点矩形,视觉效果非常差。
3.4 缩放与平移:让画布用起来更顺手
缩放和平移是设计图工具最基本的能力。我把交互层和渲染层解耦,交互层只负责修改 viewport 状态,渲染层根据新状态设置根元素的 transform。
在实现鼠标滚轮缩放时,我以鼠标所在位置为缩放中心:
function onWheel(event: WheelEvent) { const rect = svg.getBoundingClientRect(); const mouseX = event.clientX - rect.left; const mouseY = event.clientY - rect.top; const factor = event.deltaY > 0 ? 0.9 : 1.1; const nextScale = Math.min(4, Math.max(0.2, viewport.scale * factor)); const k = nextScale / viewport.scale; viewport.offsetX = mouseX - (mouseX - viewport.offsetX) * k; viewport.offsetY = mouseY - (mouseY - viewport.offsetY) * k; viewport.scale = nextScale; renderer.setViewport(viewport.scale, viewport.offsetX, viewport.offsetY); }这个公式每次都需要在心里推导一遍,很容易敲错。其实它的本质是:为了让鼠标指向的点在缩放前后保持在屏幕上的相同位置,偏移量需要跟着比例调整。如果直接改 scale 不改 offset,那缩放中心总是左上角,用户体验会很差。
平移的实现和平常拖动差不多,在画布空白区域按下鼠标时记录起点,移动时更新 offset:
let dragging = false; let startX = 0; let startY = 0; svg.addEventListener('mousedown', (e) => { if (e.target === svg || e.target === root) { dragging = true; startX = e.clientX - viewport.offsetX; startY = e.clientY - viewport.offsetY; } }); window.addEventListener('mousemove', (e) => { if (!dragging) return; viewport.offsetX = e.clientX - startX; viewport.offsetY = e.clientY - startY; renderer.setViewport(viewport.scale, viewport.offsetX, viewport.offsetY); }); window.addEventListener('mouseup', () => { dragging = false; });平移时拖动的起始坐标记录方式容易出错。如果把 startX 直接记成 e.clientX,那每次移动都会产生跳变,因为 offset 会被覆盖而不是累加。正确做法是把鼠标位置减去当前偏移量,再在新位置回算偏移量。
3.5 拖拽节点与实时更新边
拖拽节点是 diagram-design 交互里最基本的功能。关键点在于:拖拽过程中不仅要更新节点本身的坐标,还必须要同步更新关联边的路径,否则边会像一个“断裂的橡皮筋”,完全不听指挥。
我的实现方式是:
- 拖拽开始时,在节点元素上绑定 mousedown;
- 记录初始鼠标位置和节点初始坐标;
- mousemove 时,计算鼠标位移并叠加到节点坐标;
- 在鼠标移动回调里,重新渲染与该节点相关的所有边。
这里有一个性能取舍:最省事的做法是每次 mousemove 都全量重绘整张图。如果节点只有几十个,这么做没有任何问题;如果节点达到几百个,全量重绘会导致明显的卡顿。我会做一个增量更新,只更新拖拽节点的位置以及与该节点关联的边:
function updateConnectedEdges(nodeId: string) { const edges = graph.getEdges().filter((e) => e.source === nodeId || e.target === nodeId); for (const edge of edges) { const pathElement = renderer.getEdgeElement(edge.id); if (pathElement) { const source = graph.getNode(edge.source)!; const target = graph.getNode(edge.target)!; const d = computeEdgePath(source, target, edge.pathType); pathElement.setAttribute('d', d); } } }为了让边元素也可以被单独查询,renderer 里维护一个 edgeElements 的 Map,键是边的 id。增量更新时,不是清空重画,而是只修改 path 元素的 d 属性,这会比整图重绘快非常多。当然,如果节点的视觉样式也变了,比如拖拽悬停时加高亮,那么该节点的属性更新同样走增量逻辑。我把这个模式称作“最小渲染更新”,在复杂度接近上限时能明显感觉到流畅度差异。
3.6 节点选中状态与右键菜单
一个“能画图”的模块,还必须要支持选中。选中状态的实现并不难,但有几个细节很容易被忽略:
- 节点被点击时,避免触发画布拖拽平移;
- 选中状态要显示高亮边框,最好用独立的 className 或 style 切换,而不是重新画一个矩形;
- 点击空白区域时,取消所有选中。
我的实现方式是在渲染节点时,给每个节点组附加一个>root.addEventListener('click', (e) => { const target = (e.target as SVGElement).closest('[data-node-id]') as SVGGElement | null; if (target) { const nodeId = target.dataset.nodeId; selectNode(nodeId); } else { clearSelection(); } });
右键菜单我选择单独实现一个浮层组件,而不是用浏览器的原生 contextmenu。原生菜单样式太受限,而且跨平台行为不一致。右键浮层会基于鼠标位置显示操作项,如“在此添加子节点”“删除节点”“复制节点”“导出子图”,这些菜单项最终都转化为对数据层的一系列操作。
这里想提醒一点:右键菜单弹出来之后,点击菜单内部的任何鼠标事件都会触发 root 上的 click 取消选中逻辑,所以你需要在菜单容器上监听 mousedown 并调用 stopPropagation。不然会出现“刚点完菜单,节点选中状态就被清掉了”的奇怪现象。
3.7 复杂路径与箭头优化:让连线不再“穿墙”
再回到连线。直线连线最容易出现的问题是,箭头贴死在一个点上,一旦缩放较小,箭头就会“糊成一团”。我建议在 path 元素上绘制箭头时,不要直接画一个单独的 marker,因为 marker 在 SVG 里的缩放行为经常和主图形脱节,尤其是坐标已经整体被 transform 缩放时,箭头比例会显得奇怪。我曾经就是用 marker,结果在 0.5 倍缩放下箭头变得特别小,几乎看不见。
我的替代方案是手工绘制箭头三角形,在渲染边时,根据终点方向动态计算两个箭翼的位置:
function appendArrow(path: SVGPathElement, source: DiagramNode, target: DiagramNode) { const endX = target.x + target.width / 2; const endY = target.y + target.height / 2; const angle = Math.atan2(endY - (source.y + source.height / 2), endX - (source.x + source.width / 2)); const arrowSize = 10; const p1 = `${endX},${endY}`; const p2 = `${endX - arrowSize * Math.cos(angle - Math.PI / 6)},${endY - arrowSize * Math.sin(angle - Math.PI / 6)}`; const p3 = `${endX - arrowSize * Math.cos(angle + Math.PI / 6)},${endY - arrowSize * Math.sin(angle + Math.PI / 6)}`; const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); polygon.setAttribute('points', `${p1} ${p2} ${p3}`); polygon.setAttribute('fill', '#888888'); path.parentNode?.appendChild(polygon); }手工计算的箭头虽然多一点代码量,但可控性非常高:无论你怎么缩放画布,箭头始终跟路径末端贴合,视觉比例稳定。如果将来需要箭头颜色和路径颜色保持一致,可以把 fill 改成读取边的样式值。
3.8 通过数据序列化与导入导出闭环
diagram-design 的最终价值体现了:一份图数据可以被保存、被加载、被共享。我定义了一个序列化接口,把 graph 里所有节点和边输出为纯 JSON:
function serialize(graph: DiagramGraph): string { return JSON.stringify({ version: 1, nodes: graph.getNodes(), edges: graph.getEdges(), }, null, 2); }加载时,我会先校验 version 字段,再逐条创建节点和边,防止历史数据或不同模型的数据在加载时就崩溃。对于字段不存在的情况,比如老版本没有 style 字段,我会在加载过程里补全默认值:
function deserialize(input: string): DiagramGraph { const raw = JSON.parse(input); const graph = new DiagramGraph(); for (const node of raw.nodes) { graph.addNode({ ...node, style: node.style ?? { fill: '#ffffff', stroke: '#888888' }, }); } for (const edge of raw.edges) { graph.addEdge(edge); } return graph; }很多人在序列化时容易漏掉 viewport 信息,比如用户把某个区域放大到了 2 倍,保存后下次打开却回到了 1 倍。这虽然不影响数据正确性,但会影响体验。我后来把 viewport 也一并序列化,存成 meta 字段。加载时恢复 viewport,用户回到上次编辑的视角,这一步虽然小,但给使用者的感受会好很多。
4. 常见问题与排查技巧实录
4.1 节点重叠和连线交叉,该从哪里查
在自动布局后,经常会出现两个节点水平方向重叠、边交叉纠缠的情况。我一般的排查顺序是:
- 先确认节点的坐标不是全部等于 0。如果所有节点的 x/y 都是 0,那布局算法可能没跑,或者节点没有挨个赋值。
- 再看坐标是否有负数。图数据里出现负数坐标本身不是问题,但会让用户第一眼看不到内容,误以为“图丢了”。
- 看同层级节点的宽度是否差异过大。比如有一个节点特别宽,而布局时没有考虑最宽节点,就会导致同一行的其他节点被“压扁”甚至重叠。
很多时候交叉不是因为布局算法复杂,而是因为节点的 width 和 height 设置得不太合理,导致计算出的包围盒和视觉矩形不一致。所以确保每个节点的 width 和 height 都不是 0,是一个经常被忽略但非常重要的前提。
4.2 连线角度不对,箭头方向是反的
箭头方向反了,或者线从节点的“肚子”穿出去,通常是因为锚点计算只考虑了中心点连线方向,忽略了矩形边界的实际交点。
我遇到过一种非常典型的情况:源节点和目标节点都是宽矩形,中心点连线方向指向左侧,但源节点的中心已经在目标矩形内部。这种情况下,直接从中心连线取方向再延长到边缘,会得到一条“穿过目标再反弹”的怪异曲线。
解决方法是:在计算锚点时,先判断两个矩形是否重叠。如果重叠,则退化为把源节点的下边缘中点连接到目标节点的上边缘中点。如果仍然重叠,则直接连接两个距离最近的角点。这个逻辑比较繁琐,但属于“不做就一定会被吐槽”的细节。
4.3 性能卡顿:到底要不要优化
如果节点数超过 500,SVG 的实时拖拽确实会开始吃力。我整理过几个实用的优化策略,按优先级排序:
- 使用 requestAnimationFrame 合并高频的 mousemove 重绘,不要在每次 move 事件里立刻同步 DOM。渲染出来的效果对用户来说一样流畅,但 CPU 负担会小很多。
- 只在拖拽时重绘受影响的边,而不是全量渲染。
- 节点的 hover 效果尽量不要改变坐标,只改变 stroke 或 fill,避免强制布局重排。
- 如果图里有很多超出视口的节点,可以做一个简单的视口裁剪,只渲染窗口范围内的元素。这个在前面没提到,但在大规模图上它是最有效的一招。
我自己的经验是:在绝大多数业务场景里,先实现一个正确的版本,再根据实际卡顿数据决定是否上视口裁剪或 Canvas 渲染。无脑追求极限性能反而会牺牲掉代码可读性,得不偿失。
4.4 中文文本换行和样式丢失
SVG 的 text 元素不会自动换行,这是很多人第一次做 diagram-design 就会遇到的痛点。我给节点的宽度有限,label 一长就直接溢出。
解决思路有几种:
- 限制字符数,超出用省略号,但这样用户看不到完整内容;
- 手动按像素宽度拆分 label,使用多个 tspan 子元素实现多行;
- 在渲染层计算文本实际宽度,按固定宽度逐字累加,遇到超过宽度就换行。
我使用的是第二种的变体:在渲染节点时,根据 fontSize 和 label 字符数估算行数。中文字符宽度大致等于字号大小,西文字符大约等于字号的一半。根据这个估算,把 label 拆成数组,循环创建 tspan,并设置 dy 偏移。最外面 text 的 y 坐标要整体上移,以保证多行文本垂直居中,否则文字会跑到底部去。
这部分我建议一定要在早期就处理,否则后续所有节点样式一旦统一调整,文本位置就会整体错位,修改成本非常集中。
4.5 编组移动和坐标漂移问题
diagram-design 里最隐蔽的坐标问题来自“父节点移动,子节点原地不动”。在数据模型里,如果节点 A 的 parentId 是 groupG,那我移动 groupG 时,A 的坐标需要进行相同的位移。这个位移叠加逻辑要放在交互层,而不是渲染层。
我踩过的一个坑是:先把 groupG 的坐标改了,然后遍历子节点时,再用子节点的旧坐标计算新的相对位置。结果导致子节点被移动了两次,位置越来越偏。
正确的做法是:在拖拽开始时,记录所有受影响节点的起始坐标,在移动过程中统一计算增量并赋值,而不是一边改动 groupG 的坐标,一边在遍历子节点时依赖已经修改过的坐标。这个逻辑本质上和 React 里“不要在渲染期间改状态”是一个道理,很容易踩但也很容易排查,只需要在移动开始时打一个日志,对比一下每个节点的 targetX 和 sourceX 就能发现。
4.6 数据校验:加边的顺序和重复 id
最后一个建议是,在设计入口函数时,尽量做到失败可控。我会给 diagram-design 增加一个简单的 schema 校验,确保:
- 每个节点的 id 全局唯一;
- 边的 source 和 target 都能在节点列表里找到;
- 节点和边的 id 命名空间不冲突;
- 宽高都是有限正数。
这些校验可能看起来有点冗余,但一旦数据来源变成多种格式(服务端接口、用户导入、第三方插件),就会发现它们的价值。比如某个格式把宽高写成了字符串“800px”,如果不做校验,SVG 的 setAttribute 虽然不报错,但图形大小会异常,而且极难排查。
5. 项目后续的扩展思路
diagram-design 这个设计思路其实不止能用在绘图工具里,它天然适合延伸为表单设计器、页面搭建器、规则引擎可视化等场景。原因是,一旦你建立了节点和边的抽象,任何“对象及对象关系”都能装进这套模型里。
我个人比较推荐的扩展方向有三个:
- 编辑器化:在现有基础上增加撤销重做、多选、对齐辅助线、自动吸附、迷你地图等功能,让它从“能看”变成“好用”;
- 数据联动:把节点的 data 字段与真实业务数据打通,点击一个节点就打开详情面板,编辑完再写回 data,整套系统就成了真正的业务前端;
- 动态模拟:在状态机图或流程图上增加运行时高亮,比如当前执行的步骤显示指定颜色,这样这张图就从静态展示变成了实时监控面板。
从技术角度说,这四个方向都不需要推翻现有架构,只需要在交互层和计算层分别增加功能模块。这也是我为什么一直坚持分层设计的原因——如果哪一天需要支持 React 或 Vue 环境,我只需要把渲染层替换成对应框架的组件渲染器,数据层和几何计算层完全可以原封不动复用。
6. 一些个人的实际操作体会
diagram-design 这个模块前前后后调整了多次,最大的一个体会就是:不要急着写代码,先把数据模型定下来。数据模型如果定对了,后面所有布局、渲染、交互都是水到渠成的事。如果数据模型定错了,哪怕再好的渲染技巧也救不回来,因为所有逻辑都在跟错误的数据结构较劲。
还有一点是:这类工具非常依赖“细节体验”。箭头大小、选中边框、拖拽时的辅助线、缩放下限、空白处双击创建节点,这些微小的东西决定了使用者愿不愿意真的用你的图来梳理逻辑。一个有灵魂的 diagram-design 工具,靠的不是一个炫酷的布局算法,而是这些看起来不起眼但处处体现逻辑的交互细节。
最后分享一个小技巧:调试这类 SVG 图时,我会在交互层临时打印一份“可视区域坐标”到控制台表格式输出,包含鼠标坐标、视口偏移、缩放比例和当前命中的节点 id。这样在排查“为什么鼠标点不到节点”这类问题时会非常快。因为很多时候,问题其实出在 offsetX / offsetY 算错了,而不是节点本身的坐标错了。养成把几何状态用一张表打出来的习惯,会比在 DOM 里一点点翻样式高效得多。