☰
从零实现一个Web图表面板编辑器:SVG渲染、数据模型与坐标变换
2026/10/21 9:46:29 网站建设 项目流程

diagram-design是我最近在业余时间维护的一个Web图表面板项目,目标很朴素:做一个跑在浏览器里的轻量级“流程/架构图编辑器”,可以自由拖拽节点、连接关系线、缩放平移画布、撤销重做,还能把画好的图导出成JSON或PNG。起因是团队内部每次画架构图都靠PPT和画图板,改一处细节要重新截图发群里,协作体验极差。于是我就想自己写一个既轻量又能嵌入现有系统的编辑器。这个项目做完之后,最大的体会是:它表面的难度看起来是“拖拖拽拽”,但真正花时间的地方全在图数据模型和坐标变换上。如果你也想做一个类似的Diagram工具,或者单纯对前端图形编辑器的实现思路感兴趣,这篇内容应该可以当作一份从零开始的手记来读。

1. 项目定位与核心设计思路

1.1 需求场景:一个轻量图表面板到底要解决什么问题

动手之前我没有急着写代码,而是先列了一下团队里的真实使用场景。我们要做的主要是架构图、流程图和模块依赖图,这类图有几个共同点:节点数量不大,通常几十个;节点之间有明确的连线关系;强调排版清楚、导出方便。基于这个判断,我把范围收得非常窄:不做绘图软件级别的自由曲线,也不需要多用户实时协同,先做好“节点、连线、画布”这三件事。

所以diagram-design的核心定位是:一个可嵌入的、面向设计/研发人员的图表面板组件,而不是一个通用绘图工具。这个定位决定了后面很多设计取舍。比如我不打算支持太多图形类型,先把矩形、圆形、菱形和自定义Path节点做好;连线类型也先做直线、折线、贝塞尔三种。这些在绝大多数架构图场景中已经够用。

另一方面,我明确要求它必须支持JSON序列化。因为业务方不只要在浏览器里看得见,还要能保存、能恢复、能对接后端。只要图模型是纯数据,那么后面无论做存储、版本对比还是自动布局,都会轻松很多。这一点我觉得是大多数自研图表编辑器最容易忽略的,很多人一上来就写DOM和SVG,结果画是画出来了,数据模型一塌糊涂。

1.2 技术选型:为什么是SVG而不是Canvas

这是被问得最多的问题。diagram-design的渲染层选的是SVG,而不是Canvas,原因有三点。

第一,图元的数量量级不同。架构图和流程图里几十上百个节点,对SVG来说完全不是压力。Canvas的优势要到大几千上万个节点时才明显,而且为了达到可交互,Canvas还需要自己实现拾取(hit test)逻辑,开发成本直接上一个台阶。

第二,SVG天然支持DOM事件。每个节点都是一个真实的元素,click、mousedown、mouseenter这些事件开箱即用。调试的时候Elements面板里直接能看到节点结构,非常直观。如果用Canvas,所有的命中和事件分发都要自己写,可以说是用代码量换性能。

第三,可访问性和可复制性。SVG文本可以被浏览器选中、复制,也可以被屏幕阅读器读取。对于一个需要嵌入到企业系统里的组件,这一点是加分项。

当然,SVG也有坑,最大的坑是大量节点时重绘性能问题。我在后文会专门讲怎么用可视区裁剪和React.memo来缓解。这里想说的是:技术选型没有绝对正确,关键看你的场景数量级。如果你的图动辄上千节点,还是要认真考虑Canvas或WebGL方案。

1.3 整体架构:编辑器和渲染器分离

diagram-design最核心的架构决策,是把“图的模型”和“图的渲染”分离。所有节点和连线都先抽象成纯JSON数据,渲染层只做数据到图形的映射。举个简单的例子:

{ "nodes": [ { "id": "node-1", "type": "rect", "position": { "x": 120, "y": 80 }, "size": { "width": 160, "height": 60 }, "data": { "label": "订单服务" } } ], "edges": [ { "id": "edge-1", "source": "node-1", "target": "node-2", "sourcePort": "right", "targetPort": "left", "type": "bezier" } ], "viewport": { "x": 0, "y": 0, "zoom": 1 } }

渲染层不持有任何业务状态,它只根据这份数据生成SVG元素。这样带来的好处非常明显:撤销重做变得好做,因为每次只要保存快照;导入导出变得简单,因为数据本身就是对象;如果要接后端协同,也只需要用WebSocket同步这份JSON。很多人在做图编辑器的时候,喜欢直接把DOM状态当成数据源,结果节点一多就分不清到底谁才是“唯一事实来源”。我在diagram-design里从一开始就规定:只有store里的图模型是事实来源,DOM只是它的投影。

1.4 目录结构:让核心引擎保持框架无关

在包结构上,我把项目拆成了三层:

  • core:图模型、坐标变换、碰撞检测、导出逻辑,不依赖React。
  • render:React组件,把core里的模型渲染为SVG,处理DOM事件。
  • plugins:可选功能,比如小地图、网格背景、自定义节点类型。

这样分层的好处是,以后如果我要换框架,或者把core放到Node.js端跑单元测试,都不会被React绑定。实际上我已经这么做了,所有坐标计算和导出函数的单元测试全部跑在Node环境里,不需要启动浏览器。这对长期维护非常重要,因为交互代码最容易出现回归,而纯函数的测试成本低、稳定性高。

2. 图数据模型的构建:节点和边

2.1 节点模型:不只是x和y

一开始我以为节点就是一个矩形加坐标,越写越发现不够。真实场景中,节点需要端口(anchor point),也就是连线的连接点。两个节点的连线不应该从矩形中心开始,而应该从边缘的某个点开始。如果所有线都从头中心连到尾中心,图稍微复杂一点就乱成一团。

所以diagram-design里的节点模型比普通矩形多了一个ports字段。每个端口有id、相对位置和方向。端口可以放在节点上下左右四个边,也可以自定义角度。渲染时,连线会精确地从sourcePort坐标出发,连接到targetPort坐标。

节点还可以有形状。最简单的是矩形,但流程图里也需要菱形判断节点、圆形起止节点。我把类型抽象成type字段,渲染时根据type决定用哪种SVG元素。如果你需要完全自定义节点,diagram-design支持注册自定义渲染器,接收node对象和自己写的React组件。这个机制非常实用,比如让某个节点内部显示多个标签、状态灯甚至输入框。

下面是节点结构的一个示例:

interface GraphNode { id: string; type: string; position: Point; // 左上角坐标 size: Size; ports?: Port[]; data: Record<string, unknown>; }

要注意的是,data字段里尽量不要放嵌套的复杂对象,因为每次快照都可能序列化和比较。我在实际使用中遇到过把整个组件实例塞进data里导致无法导出JSON的问题,后来严格规定data必须是可JSON化的数据。

2.2 边的实现:直线、折线和贝塞尔

边是图编辑器里比节点更难的部分。diagram-design里支持三种连线类型:

  • 直线:直接连接两个锚点,适合关系清晰的场景。
  • 折线:自动计算拐点,适合流程图里的上下左右连接。
  • 贝塞尔曲线:用二次或三次曲线,视觉效果更平滑,适合跨层级的复杂连线。

折线的拐点计算是最麻烦的。最简单的策略是走中间点:从source出发先水平或垂直走到两个端点的中点,再走到target。如果source和target相对位置比较偏,这种策略会出现奇怪的绕路。后来我改成L型的计算方式:根据两个端口的方向,先沿方向延伸一段距离,再转弯。比如从节点右侧出发就向右延伸40像素,从节点左侧出发就向左延伸40像素,然后再连接。这样大部分连线都不会穿过节点区域。

贝塞尔曲线的实现相对简单,用两个控制点把source和target连起来。控制点根据端口方向生成:

function getEdgePath(source: Point, target: Point, sourceDir: Direction, targetDir: Direction) { const dist = Math.max(40, Math.abs(target.x - source.x) * 0.5); const cp1 = offsetPoint(source, sourceDir, dist); const cp2 = offsetPoint(target, targetDir, dist); return `M ${source.x} ${source.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${target.x} ${target.y}`; }

这里offsetPoint会根据方向算出对应的控制点坐标。比如方向是right,就在x轴上加上距离;方向是top,就在y轴上减去距离。你可以把它理解为把一根橡皮筋的两端分别往连接方向拽一段,这样曲线就不会在节点背后乱穿。

2.3 画布坐标与视图变换

图编辑器还有一个容易搞混的点:世界坐标和屏幕坐标。世界坐标是指图本身的坐标系,不管你怎么缩放平移,节点的position永远不变。屏幕坐标是用户在浏览器里看到的坐标,每个像素点都会随着viewport的位移和缩放而改变。

diagram-design里定义了一个viewport:

interface Viewport { x: number; // 横向偏移 y: number; // 纵向偏移 zoom: number; // 缩放倍数 }

从世界坐标转换到屏幕坐标的公式:

screenX = worldX * zoom + viewport.x screenY = worldY * zoom + viewport.y

反向转换是:

worldX = (screenX - viewport.x) / zoom worldY = (screenY - viewport.y) / zoom

这两组公式是整个编辑器交互的地基。我踩过最大的坑就是在写拖拽时直接用鼠标的屏幕坐标去更新节点的position,结果一旦缩放比例不是1,节点就会“飞”出去。正确做法是先把鼠标坐标转成世界坐标,再更新节点position。后面所有交互,包括框选、画线、缩放,都要遵循这个原则。

3. 核心交互与渲染实现

3.1 拖拽、框选和缩放平移的实现套路

先说节点拖拽。每次处理pointerdown事件时记录拖拽起始的世界坐标nodeStartPos,以及鼠标起始的屏幕坐标。pointermove时,计算鼠标移动的屏幕增量,再除以zoom,得到世界坐标增量。然后节点的新位置就是nodeStartPos加上增量。

这段逻辑看起来简单,但要注意三点:第一,事件要绑定在窗口上而不是节点上,这样鼠标移出节点也能继续拖;第二,理论上一帧只需要渲染一次,但高频move事件容易造成抖动,我直接用requestAnimationFrame做了节流;第三,拖拽过程中不能直接改position的引用,要返回新的节点对象,否则撤销历史会乱。

框选稍微复杂一点。框选时记录按下时的屏幕坐标selectionStart,移动时得到当前屏幕坐标selectionEnd,用这两个点构建一个矩形。然后遍历所有节点,把每个节点的屏幕包围盒与框选矩形做碰撞检测。

这里的关键点是,框选矩形通常用屏幕坐标计算,但节点的位置是world坐标,所以需要先转换。我为了统一,把框选矩形也转换成world坐标来检测,避免zoom变化时出现选择偏差。

画布平移和缩放相对独立。平移就是按住空白区域拖动时,修改viewport.x和viewport.y。缩放是修改zoom,同时要保证鼠标所在的世界点不变,这样缩放时才不会“漂移”。具体公式是先把鼠标屏幕坐标转成缩放前的世界坐标,再计算缩放后viewport应该落在哪里:

worldBeforeZoom = (mouseScreen - viewportBeforeZoom) / zoomBeforeZoom viewportAfterZoom = mouseScreen - worldBeforeZoom * zoomAfterZoom

这个算清楚之后,滚轮缩放就会非常跟手,鼠标指着哪里,哪里就保持不动。

3.2 渲染优化:从能用到流畅的实战技巧

diagram-design在刚开始时十分卡顿,拖一个节点,整个图都在闪。后来我总结出三个真正有效的优化手段。

第一个是可视区裁剪。既然我们的节点数量通常只有几十到几百,最直接的办法就是只渲染视口内看得见的节点。viewport变化时,先算当前可视的world矩形范围,然后过滤掉范围之外的节点。这个操作可以把渲染成本从“所有节点的数量”降到“一屏内的节点数量”,效果立竿见影。

第二个是React.memo + 局部订阅。diagram-design不是用一个巨型store直接订阅所有组件,而是用zustand的selector让每个节点组件只订阅自己node对象的引用。拖拽A节点时,只有A节点的组件和关联的边会更新,其他节点不会被重渲染。这个优化做完之后,哪怕拖拽过程再快,也不会引起全图卡顿。

第三个是把边路径计算变成纯函数并做缓存。边的path和两个端口的坐标有关,如果端点没变,路径就不应该重新计算。这里可以用useMemo来缓存,依赖项是sourcePort坐标和targetPort坐标。在复杂图上,这条优化能省掉大量无意义的路径计算。

顺便说一句,SVG里如果要做小地图(minimap),不要直接DOM操作去画缩略图。我是在小地图组件里用React维护一个独立的轻量层级,用仿射变换把全节点映射到一个固定尺寸的矩形中。这个映射非常简单,每次只要把node.position * miniScale,然后画几个小方块就行。

3.3 撤销重做的完整实现

撤销重做我一开始打算用命令模式,每条操作记录execute和undo方法。后来发现图编辑器里的操作往往不是简单的单步命令,比如一次拖拽会产生几十个pointermove事件,如果每一个都记一条历史,用户撤销20步还在回退同一段拖拽。所以最终方案改成了“快照 + 变更合并”。

具体逻辑是:在pointerdown时记录当前图数据快照,在pointerup时把最新快照推入历史栈。对于其他操作,比如新增节点、删除节点、修改节点文字,则在操作前后各记录一份快照。每次操作期间,store会短暂禁用历史记录,等操作完整结束时再入栈。

历史栈还需要限制长度。我设置了50步,超出之后丢弃最老的快照。这个值看起来够用,但在长会话中可能不够,所以我把它做成了配置项。

撤销时要注意,在恢复快照后需要重新计算所有边的path,并且要刷新可视区。我踩过一次坑:撤销节点位置后,边居然还停留在原来的位置,因为边的path是useMemo缓存的,而依赖没有跟着变。解决办法是给每个快照排一个版本号,所有缓存都要以版本号作为依赖之一。

4. 从零搭一个可用Demo:关键模块与核心代码

4.1 初始化项目和目录结构

diagram-design本体我用Vite + React + TypeScript搭起来,状态管理用zustand,SVG直接写在JSX里。初始化之后,目录结构大致是这样:

src/ core/ types.ts transform.ts edge.ts export.ts store/ diagramStore.ts components/ Canvas.tsx NodeView.tsx EdgeView.tsx MiniMap.tsx plugins/ grid.ts

core目录不引用任何React代码,这样在Node环境里跑测试很方便。store目录负责全局状态。组件目录只做渲染和事件。plugins目录放可选增强功能。

4.2 用zustand管理图状态

整个图编辑器用一个store管理:

interface DiagramState { nodes: GraphNode[]; edges: GraphEdge[]; viewport: Viewport; selectedIds: string[]; addNode: (node: GraphNode) => void; updateNodePosition: (id: string, position: Point) => void; setViewport: (viewport: Viewport) => void; undo: () => void; redo: () => void; }

这里我最想提醒的是:更新任何节点时,都不要直接改原对象。比如拖拽更新位置,要使用map方法返回一个新数组和新节点对象。这样每个组件通过selector拿到的新节点引用会发生变化,才能触发React重渲染。如果图省事用了mutable更新,React.memo会直接失效,因为引用没变。

4.3 实现节点拖拽和连接线创建

节点拖拽的代码逻辑很多,但核心只有两步。第一步在node组件的onPointerDown里记录初始状态并调用store的startDrag。这段状态一般放到一个dragRef里,不直接放store,因为拖拽过程中不需要所有组件都感知。第二步在window的pointermove事件中计算增量并更新节点位置。

连接线的创建交互是:每个端口上设一个圆形热区,拖拽该热区时在根SVG上生成一条预览边。预览边的起点是当前端口坐标,终点是鼠标位置。鼠标移动过程中,我们要不断检测鼠标下方是否存在可以连接的端口。检测方式是遍历所有节点,找出距离鼠标最近且类型匹配的端口,距离小于阈值时高亮。松开鼠标时,如果目标端口有效,则创建一条真实边,否则移除预览边。

检测端口的代码可以写得比较暴力,因为节点量少。如果节点上千,这里要做空间索引(比如网格哈希)。我在项目里先暴力遍历,实测下来效果还不错,就没有提前优化。

4.4 导出JSON与PNG的完整流程

导出JSON非常直接,序列化store里的nodes、edges、viewport即可。需要注意两点:一是导出的数据要经过一层copy,避免数据结构里混入运行时状态;二是如果有自定义节点类型,导出的type要能被外部系统识别。

导出PNG稍微绕一点。我采用的方法是把SVG XML序列化到字符串,然后通过Image对象加载进Canvas,最后调用canvas.toDataURL下载。这里有几个容易忽略的细节:

  • SVG字符串必须带xmlns,否则浏览器拒绝渲染。
  • 如果节点里用到了外部字体,导出前要确保字体已加载完成,否则文字会错位。
  • 背景默认是透明的,导出时最好在Canvas里先填充白色背景。
  • 尺寸要根据当前viewport和整个图的包围盒计算,不能只导出视口那一块。
function exportPNG() { const svg = document.getElementById('diagram-svg'); const xml = new XMLSerializer().serializeToString(svg); const svg64 = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(xml))); const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width * 2; canvas.height = img.height * 2; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.scale(2, 2); ctx.drawImage(img, 0, 0); // 触发下载 }; img.src = svg64; }

上面的2倍缩放是为了导出的图片更清晰,你可以根据需求调整倍率。

5. 常见问题与排查技巧实录

5.1 节点拖拽时跳变:坐标变换没做干净

这是一个非常典型的bug。表现是:第一次拖节点时,节点位置会突然跳到鼠标旁边。原因基本可以锁定在鼠标屏幕坐标到世界坐标的转换上。如果你在pointerdown时记录了鼠标的屏幕坐标,但updateNodePosition时却直接把这个屏幕坐标当成世界坐标,那当viewport有偏移或缩放时,节点一定会跳。

排查方法很直接:打印pointerdown时的screen坐标、世界坐标、viewport,然后手动用公式演算一遍,看看哪里对不上。这个bug几乎所有人都要踩一次,踩完之后你才会真的理解为什么要区分两套坐标系。

5.2 复杂图拖动卡顿:重渲染范围失控

当边超过50条时,拖动手感就会变差,最直接的原因是每次节点位置变化都触发了所有边的重渲染。排查时可以先在React DevTools里打开Highlight updates,看看拖动节点时哪些组件在闪烁。

解决办法我在前面提过:用selector让每个节点组件只依赖自己的node数据,边组件只依赖两个端口的坐标。同时,边path的计算要用useMemo缓存,并且缓存依赖只列端口坐标和类型,不要依赖整个edge对象。这样一次拖拽只会引起当前节点和它直接相连的边更新。

还有一种情况是整棵SVG外层组件因为viewport变化而重建,导致所有子节点全部重挂载。要避免给整个SVG根节点传一个不稳定的style对象,每次render时对象引用都会变。

5.3 连线重叠严重:增加锚点偏移

多个边连接到同一个端口时,所有的线会叠在一起,完全看不出走向。这个问题的本质是多个边共享了同一个起点和终点坐标。解决办法是给每条边在端口处加一个偏移量。

偏移方向沿端口方向的垂直线方向,偏移大小可以按连接到该端口的边的序号依次递增。比如右侧端口有3条边连出去,第1条向上偏移8px,第2条不偏移,第3条向下偏移8px。这种策略虽然简单,但对抗“多线重叠”效果非常好。如果同时还有多个端口可用,也可以优先选择负载最小的端口。

5.4 撤销重做在某些操作后失效

如果你发现撤销一次后节点位置变了,但边的路径没变,或者撤销到某一步时图变乱了,那几乎都是快照缓存没刷新的问题。我后来强制规定:每次undo、redo后,全图的渲染版本号统一加1,所有边path和节点memo的依赖都要带上这个版本号。这样即使对象内容没变,也会主动重算。代价是多了一些重绘,但稳定性和可预期性高得多。

另一个容易踩的是:第一次加载图时,历史栈里残留了上一次会话的操作记录。正确的做法是在加载新图、重置store时,清空历史栈。否则用户点击撤销,会回退到另一张图的状态,那种体验非常崩溃。

5.5 滚轮缩放时页面跟着滚动

这是浏览器的默认行为。滚轮缩放需要监听wheel事件并调用preventDefault,但现代浏览器默认把wheel事件标记为passive,导致preventDefault不生效。解决办法是在addEventListener第三个参数里显式设置passive: false:

canvasEl.addEventListener('wheel', handleWheel, { passive: false });

我在这里卡了一个多小时,检查了半天也没发现问题,后来看了监听器配置才恍然大悟。顺便说一下,监听器要用同一个引用,否则removeEventListener不生效。

最后再分享一个小经验。diagram-design做到后面阶段,我发现自己花在纯交互上的时间,其实远远少于调整数据模型和坐标变换的时间。这个项目也让我形成了一个习惯:凡是图形类工具,先把“对象模型”设计清楚,再从模型出发去实现交互,最终出来的代码会稳定很多。如果你也准备做自己的图表编辑器,我建议先从“鼠标点在哪里、对象落在哪里”这两条坐标链路开始,把核心公式写在纸上,再去写业务逻辑。这样后面再复杂的交互,兜底都会很牢。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询