最近在重构一个内部用的图表设计模块,项目代号就叫 diagram-design。它的定位非常明确:在自研业务系统里嵌入一个能拖拽画图的小型编辑器,让用户把节点拖到画布上,用连线把逻辑关系串起来,再导出成结构清晰的架构图、流程图或拓扑图。如果你正在评估“要不要自己写一个图表设计工具”,或者已经动手做到一半卡在连线算法、性能优化这些环节,这篇文章应该能帮你省掉不少弯路。
我最初也想过直接用现成的开源方案,但调研一圈后发现,它们要么组件样式深度绑定,要么联动交互改起来牵动全身,要么是大而全的桌面级应用、塞进浏览器里适配成本高得离谱。后来我在团队内部搭了一个约两周的验证版本,用 TypeScript 从零实现数据模型、渲染层和交互层,再把验证版的代码抽成独立模块逐步完善,这才有了 diagram-design 的完整形态。这篇文章会把我从需求拆解、技术选型、核心实现到踩坑排错的完整过程整理出来,某些方案不一定是唯一解,但都是我曾经实测跑通过的路线。
1. 需求拆解:先想清楚要做一个“什么样”的图表设计器
1.1 我为什么决定自研
先说使用场景。团队的产品需要让运营同学自己配置业务模块之间的依赖关系,比如当前服务依赖哪些下游服务、请求经过哪些网关、哪些流程节点之间存在先后关系。原计划是接入一套开源画布组件,试了两三天后发现几个问题:
- 开源组件普遍内置了一套数据格式,和业务接口返回的数据结构对接不顺畅,每次都要做一层转换。
- 内部交互习惯和组件默认行为冲突严重,比如我们希望双击节点直接进入编辑态,但组件默认双击是放大画布。
- 后续要支持自定义节点皮肤、自定义行业形状,开源方案扩展时对内部实现侵入太深,升级版本也容易导致回归。
自己写并不是为了炫技,而是为了完全控制交互节奏和数据边界。做完之后回头复盘,自研的实际成本和接入一个重量级开源框架后做二次适配的成本,其实差不了太多,但自研的收益是每一行逻辑都可控、可测试、可解释。
1.2 把需求砍到最小可用集合
动手之前我列了一个需求清单,然后做了两轮减法。第一版清单里有节点拖拽、连线、框选、撤销重做、缩略图、自动布局、多人协作、主题定制、插件机制。只保留其中最基本的能力作为第一版目标:
- 在画布上创建、删除、拖拽节点。
- 从节点边缘拖出一条连线,到达另一个节点时自动吸附。
- 画布整体缩放和平移。
- 数据和视图分离,模型层与渲染层互不依赖。
- 可序列化为 JSON,可导入导出。
多人协作、自动布局这些直接在清单上标记了“V2 再考虑”。这么做是踩过一个教训的结果,早年做另一款可视化工具时,一开始就铺了太多功能,结果基础交互还没来得及打磨,就被复杂功能拖垮了进度。对于 diagram-design 这种体量的项目,先把骨架搭稳比什么都重要。
1.3 数据模型先行,怎么存决定了后续怎么画
动手前需要明确一个问题:节点和连线在内存里到底怎么表示。这个决定会影响后续所有模块的写法。我用的是“画布-节点-连线”三层结构,外加一个端口概念。每个节点声明若干锚点,连线从锚点出发,这样后续布局和连线跟随逻辑都有统一的起点。
export interface DiagramNode { id: string; type: string; // "rect" | "circle" | "custom" x: number; // 左上角坐标,当前只支持矩形包围盒 y: number; width: number; height: number; ports: Port[]; // 锚点:添加连线时的连接点 data: Record<string, unknown>; // 业务自定义数据,不参与渲染语义 } export interface DiagramEdge { id: string; sourceNodeId: string; sourcePortId: string; targetNodeId: string; targetPortId: string; points: Point[]; // 预计算的折线路径点,会随节点移动重新计算 } export interface DiagramModel { nodes: DiagramNode[]; edges: DiagramEdge[]; }第二阶段我引入了“模型坐标系”和“屏幕坐标系”的概念,这是我觉得解决很多交互 bug 的关键点。数据层里存的坐标永远都是未缩放、未平移的画布坐标,所有计算都以这个坐标系为准,只有在渲染到界面上那一刻才应用视图变换。当时没这么做,导致连线的锚点、拖拽的位置判断经常出现“明明鼠标在这里,线却从别的地方伸出来”的错位问题,后面会给这个坑单独一节讲。
2. 技术选型:渲染层、交互层与状态管理的权衡
2.1 渲染方案:SVG 还是 Canvas
这是最纠结的一个决策。我在验证阶段各写了一个简易实现,用同一批 500 个节点、800 条连线的测试数据做对比,结论如下:
| 场景 | SVG | Canvas |
|---|---|---|
| 节点数量 < 200 | 渲染流畅,开发效率高 | 流畅,但交互命中检测需要自己算 |
| 节点数量 1000+ | 明显卡顿,DOM 节点过多 | 30~60FPS 稳定 |
| 拖拽过程重绘 | 每个节点独立更新,浏览器自动处理 | 需要手动控制重绘区域 |
| 样式定制 | 天然支持 CSS,动画方便 | 全部依赖绘制代码 |
| 导出图片 | 序列化 SVG 即可 | 需要走 toDataURL 流程 |
我的最终选择是折中方案:默认渲染层用 Canvas,交互层用 SVG 透明蒙版做命中检测。节点数量不多时完全不感知差异,而一旦有人把几千个节点一次性贴进画布,Canvas 的稳定性优势立刻体现出来。但要注意,这个方案实现复杂度会高一些,如果你预期图表的规模不会超过三五百个元素,直接用纯 SVG 实现反而是开发效率最高的。
2.2 状态管理:不用框架绑定,手写简单事件总线
很多类似的轮子项目会顺手引入一套重型的全局状态管理库。我没有这么做,diagram-design 的状态变化是高度集中且可预期的,无非就是节点增删改查、边增删改查、视图变换、选中集合变化这几类。我手写了一个很轻量的命令总线:
export type Command = | { type: "addNode"; node: DiagramNode } | { type: "moveNode"; id: string; dx: number; dy: number } | { type: "removeNode"; id: string } | { type: "addEdge"; edge: DiagramEdge } | { type: "updateViewport"; zoom: number; offsetX: number; offsetY: number }; class DiagramStore { private listeners = new Set<() => void>(); dispatch(cmd: Command) { switch (cmd.type) { case "addNode": this.model.nodes.push(cmd.node); break; case "moveNode": const n = this.model.nodes.find(x => x.id === cmd.id); if (n) { n.x += cmd.dx; n.y += cmd.dy; } break; // ... 省略其他分支 } this.listeners.forEach(l => l()); } }这段代码的核心思想是“唯一数据源 + 广播机制”。任何模块想拿到最新状态,只需要订阅 store 的变更事件即可。到目前来看,这个设计足够支撑单画布场景下所有交互操作,也不需要引入额外的概念负担。
2.3 撤销重做的实现思路
撤销重做往往是新手做这类项目最头疼的部分。我的做法很直接,既然 dispatch 命令已经集中了所有变更,那就将每次变更前的 model 深度快照压入历史栈:
const history: DiagramModel[] = []; let future: DiagramModel[] = []; function commit(model: DiagramModel) { history.push(cloneDeep(model)); if (history.length > 50) history.shift(); // 限制栈深度 future = []; } function undo(): DiagramModel | null { const prev = history.pop(); if (!prev) return null; future.push(cloneDeep(currentModel)); return prev; } function redo(): DiagramModel | null { const next = future.pop(); if (!next) return null; history.push(cloneDeep(currentModel)); return next; }深度克隆在节点数量几千以上时会成为性能瓶颈,但作为第一版实现,这个方案简单到不会出错。如果后续真要支撑大规模图数据,可以改成记录“操作类型 + 反向操作参数”的增量模式,但在 500 个节点以内,每次快照的耗时基本在个位数毫秒级,体感可以忽略。
3. 核心模块实现细节:节点、连线与视图变换
3.1 节点的绘制与尺寸计算
节点是图上最基础的可视单元。我做的是一个圆角矩形节点,内容包括左侧端口区、右侧端口区、顶部标题栏和中间文本。文本会自动换行,换行后的高度动态影响节点整体高度,这部分逻辑比较容易被忽略。
实现文本换行时,我用的方案是逐字测量累宽,超出目标宽度就换行。听起来很笨,但在中英文混排场景下,比用 CSS 的 word-break 可控得多:
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { const lines: string[] = []; let line = ""; for (const ch of text) { const testLine = line + ch; if (ctx.measureText(testLine).width > maxWidth && line) { lines.push(line); line = ch; } else { line = testLine; } } if (line) lines.push(line); return lines; }节点高度在绘制前先统一计算一次,再写回模型数据,顺带着做了一层缓存。当一个节点被选中并缩放尺寸时,缓存失效后重新测量。这个改动最初只是为了处理那种“文字全部挤成省略号”的 bug,后来发现对整体渲染性能也有帮助。
3.2 连线的折线路径计算
连线是 diagram-design 里我认为设计难度仅次于缩放平移的模块。用户从端口 A 拖到端口 B,如果只是拉一条直线,视觉上显得非常粗糙。实际做的是正交折线:从起点先水平走出一段距离,再垂直走,再水平进入终点。
这里有两个关键分支:
- 如果起点和终点水平方向有交叠,则使用三段线:水平段 + 垂直段 + 水平段。
- 如果起点和终点水平方向完全错开,则使用五段线,中间会增加一段水平连接段。
连线的实际端点坐标不是节点左上角,而是节点四边中点的“锚点”。锚点位置会随节点移动而更新。可以理解为,每当节点位置变化,所有连接到它的边的路径都会重新计算一次。这个计算量在测试数据下并不大,但需要注意不要在每帧所有边都全量重算,只重算与移动节点相连的那些边。
3.3 缩放平移:视图变换矩阵的“唯一”入口
我把缩放和平移封装成了统一的视图变换函数,只要能确保“渲染管线里所有坐标都经过 transform 映射”,就不会出现画布内容漂移的问题:
export function modelToScreen( point: Point, viewport: { zoom: number; offsetX: number; offsetY: number } ): Point { return { x: point.x * viewport.zoom + viewport.offsetX, y: point.y * viewport.zoom + viewport.offsetY, }; } export function screenToModel( point: Point, viewport: { zoom: number; offsetX: number; offsetY: number } ): Point { return { x: (point.x - viewport.offsetX) / viewport.zoom, y: (point.y - viewport.offsetY) / viewport.zoom, }; }鼠标事件进入画布后,第一步要把屏幕坐标换算成模型坐标,然后所有命中检测和拖拽逻辑都基于模型坐标。这一步到位之后,我遇到的坐标错位问题大约减少了八成。这就是数据层与视图层分离最大的收益——所有脏活累活在入口处处理掉,内部直接使用洁净的模型坐标。
4. 实操过程:从零搭建一个可运行的最小版本
4.1 初始化工程与目录划分
我搭建工程时用的是 Vite 作为开发服务器,TypeScript 做类型约束。项目目录结构最初规划为:
diagram-design/ ├── src/ │ ├── core/ # 数据模型定义、store、历史栈 │ ├── render/ # Canvas渲染器、SVG叠加层 │ ├── interaction/ # 鼠标事件、键盘事件、拖拽逻辑 │ ├── layout/ # 简单布局算法、自动排列 │ ├── ui/ # 工具栏、缩略图等UI组件 │ └── utils/ # 数学工具、坐标转换、路径计算 ├── examples/ └── package.json这里有一个自认为比较重要的工程建议:核心包(core)绝不依赖渲染层的任何代码。渲染层可以调用 core 的数据结构,但 core 不会反向 import 渲染层。一旦出现逆向依赖,说明边界被打破了。这样模块单独测试时也很方便,我可以直接在 Node 环境里加一段脚本验证数据逻辑,完全不需要启动浏览器。
4.2 创建一个最小可运行画布
这个版本的画布核心逻辑可以压缩到很少的代码。下面是初始化一个可用的编辑器实例所需的代码骨架,去掉了很多装饰:
import { DiagramStore, createNode, createEdge } from "./core"; import { CanvasRenderer } from "./render/CanvasRenderer"; import { InteractionManager } from "./interaction/InteractionManager"; const store = new DiagramStore(); const renderer = new CanvasRenderer(document.getElementById("canvas")!); const interaction = new InteractionManager(renderer); store.subscribe(() => renderer.render(store.model)); const nodeA = createNode({ x: 100, y: 100, width: 140, height: 60 }); const nodeB = createNode({ x: 320, y: 200, width: 140, height: 60 }); store.dispatch({ type: "addNode", node: nodeA }); store.dispatch({ type: "addNode", node: nodeB }); const edge = createEdge({ sourceNodeId: nodeA.id, sourcePortId: nodeA.ports[1].id, targetNodeId: nodeB.id, targetPortId: nodeB.ports[0].id, }); store.dispatch({ type: "addEdge", edge });这里没有一步到位的框架优化,核心目的是先让整条链路跑通:命令进 store,store 广播更新,renderer 重绘画布。当这个闭环稳定后,剩余功能都只是在往这条链路上挂业务逻辑。
4.3 交互层:事件命中与拖拽
如果自己写过类似项目,应该会认同这个结论:Canvas 渲染不难,难的是鼠标事件到来时,怎么知道用户点到了哪个元素。
我的方案是给每个节点维护一个矩形边界对象,每次点击先通过坐标比对,找出在鼠标边界范围内的节点。为了避免频繁遍历所有节点,我做了四叉树索引,实际测试中 5000 个节点的点击命中耗时在微秒级。
拖拽逻辑则相对简单。mouseDown 时记录起始模型坐标和节点原位置,mouseMove 时计算偏移,每帧用 requestAnimationFrame 节流更新。一个容易忽略的细节是拖拽期间不应该让浏览器默认行为介入,需要主动阻止文本选择事件,否则在拖拽过程中会出现蓝色选区,非常影响观感。
5. 常见问题与排查技巧实录
5.1 节点一多就卡顿
我第一版纯 SVG 实现在 300 个节点时开始掉帧,到了 800 个节点已经让人无法接受。定位过程分了三步:
- 打开浏览器性能面板,抓取 JavaScript 执行耗时,发现每次移动节点都会触达整个视图重绘。
- 确认核心瓶颈在于每次渲染都遍历了所有节点和连线,其中大部分节点根本没有进入可视区域。
- 优化方式是引入“区域裁剪”:只渲染视口范围内的节点,配合一个稀疏的包围盒索引跳过不可见元素。
实测数据:5000 个节点中有 80% 在视口外部,不做裁剪时每帧需要绘制 5000 个元素,裁剪后只需要绘制视口内实际可见的约 800 个左右。配合增量重绘,体感立刻变得顺滑。
5.2 连线“断层”的经典问题
开发过程中碰到一个特别容易复现的 bug:拖动节点时,连线的起点还是拖拽前的位置,但终点已经跟着新位置移动了,整个阶段看起来像线从节点中间断开。
定位后发现原因很经典——在 mouseMove 里移动节点时,更新的是模型坐标,但渲染层在那一帧的缓存里还读取着上一次的连线路径数据。修复方式是在移动节点后同步触发一次“重算与其相连的所有边”的动作,再触发渲染;
function moveNode(id: string, dx: number, dy: number) { const node = store.getNode(id); node.x += dx; node.y += dy; // 关键:该节点相关的所有边都需要重新计算路径 const relatedEdges = store.edges.filter( e => e.sourceNodeId === id || e.targetNodeId === id ); relatedEdges.forEach(edge => recomputeEdgePath(edge)); store.notifyChange(); }这类问题的共性在于“数据更新”和“派生数据更新”没有放在同一个事务里。后来我形成一个习惯:所有“移动节点”的命令,内部会附带“重算相关边”的副作用,保证一次 dispatch 后,模型里存的数据永远是一致且可渲染的。
5.3 导出图片时的跨域与字体问题
导出 PNG 是图表设计器的刚需。实现初期,直接在画布上绘制节点后调用 toDataURL 生成图片,一切正常。但业务场景中节点背景图通常来自 CDN,这就踩到了一个跨域坑——Canvas 上绘制了跨域图片后,整个画布会被浏览器标记为“已被污染”,再想导出数据会被拒绝。
解决途径是在图片元素加载前就设置好 crossOrigin="anonymous",同时在服务端返回正确的 CORS 头。如果后端没法改响应头,就只能在加载图片时先转成 base64 数据流再绘制,虽然会额外增加一次转换,但至少能把导出链路救活。字体问题也类似:如果节点文本使用了自定义字体,导出前需要等待字体加载完成,否则文字位置会产生偏移。
5.4 数据序列化里的 id 兼容坑
节点和连线都依赖 id 关联。在一次联调中,后端返回的数据里有一条边引用了不存在的节点 id,导致整张图展示不出来。排查时发现,连线插入时没有做引用校验,存进模型的是一个悬空边。这个问题后来在 createEdge 里加了显式校验,并且为数据导入单独增加了“自动清理无效边”的管道函数。
function createEdge(input: EdgeInput): DiagramEdge { const source = store.getNode(input.sourceNodeId); const target = store.getNode(input.targetNodeId); if (!source || !target) { throw new Error(`Invalid edge: source or target node not found`); } return { id: uuid(), ...input, points: [] }; }顺带一提,我用的是自增数字还是 UUID 也曾经纠结过,最终选择 UUID。自增 id 在本地画图没问题,但一旦涉及多人协作或跨端同步,UUID 几乎是唯一不会冲突的选项。
6. 从 diagram-design 到下一步:空间还很大
6.1 自动布局:是否值得做
第一版确定不做自动布局,理由是“手动拖拽优先”。但团队里有人提到:如果从后端直接拉取的图有上百个节点,纯手工排列图结构并不好。我后来调研了两个方案,一是 dagre 布局算法,适合 DAG(有向无环图),生成的拓扑图层次分明;二是 elk 布局器,布局效果更美观,但依赖较重。目前只接入了最简单的“网格排列”作为兜底,正式自动布局放在后续版本,毕竟从调用一个布局器到让它生成符合业务语义的排布效果,中间还有很大的调优工作量。
6.2 多人实时协作的前提
多人协作是另一个看起来诱人但实现成本极高的方向。如果用 Yjs 之类的 CRDT 方案,核心数据结构的下标定位、并发冲突、角色权限这些都需要重新设计。我的建议是:如果业务没有强需求,就不要在第一个版本里碰它。等单机版本的数据模型足够稳定,再考虑用实验分支去接入协同能力,而不是一上来就让协作需求绑架主架构。
6.3 项目收尾后的几点心得
写到这里,复盘一下我在这段时间里比较认可的几个决策:先做数据模型再做渲染层;坐标统一使用模型坐标;所有交互入口都汇总到命令机制;把“更新数据”和“更新派生数据”绑定在同一个事务里。从结果看,这套思路撑住了一个几千行代码规模的图编辑器项目,也让后来的每一步新增功能都基本平稳落地。
如果非要说一条给类似项目新手的建议,我会说:把时间花在打磨一条基础路径上,比横向堆叠十个半成品功能有用得多。一条路径走顺了,任何新需求落地时都会有更稳的地基。