从零构建图表设计引擎:Canvas渲染与布局算法核心实践
作为常年跟各种画图工具可视化平台打交道的开发者我其实挺理解为什么有人会专门为一个diagram-design项目写一篇长文。市面上的画图工具太多了从在线白板到桌面端专业软件看起来什么都能画。可真到自己团队要做一个垂直场景的图表设计功能时你会发现现成方案要么太通用、定制成本极高要么数据结构封闭、根本没法跟你自己的业务模型打通。我这个diagram-design项目就是在这种看着什么都有实际用起来差点意思的处境下起步的。它本质上是一个面向特定业务场景的图表设计引擎支持自定义节点类型、自动布局、批量连线、画布缩放平移以及节点数据的双向同步。如果你正打算自己做一版架构图编辑器、拓扑图编辑器或者想从零实现一个轻量级流程设计器这篇文章里踩过的坑、验证过的方案、甚至被我删掉的几版代码应该能让你少走不少弯路。项目本身不大但涉及的技术点很全图形数据结构设计、布局算法选型、Canvas 渲染性能、交互状态管理、数据序列化与导入导出。下面我把整个项目的设计和实现思路拆开讲清楚。1. 整体设计与思路拆解1.1 这个图表设计到底要解决什么问题一句话概括diagram-design的目标是提供一个可嵌入的图表设计画布用户能在上面拖拽节点、手动或自动连线、调整布局并且最终能把这套图数据导出成结构化数据JSON方便存储和后端联动。听起来跟画流程图差不多但业务场景其实差异很大。比如很多架构图工具默认的节点是矩形、连线是直线可是我们场景里的节点可能是云资源、微服务模块、存储单元连线也不只是从一个框指向另一个框它需要承载依赖关系、数据流向、调用关系等语义。这就要求数据模型必须区分节点类型和边类型不同类型的节点有不同的属性字段、颜色、图标甚至交互行为。另一个需求是自动布局。早期版本我们允许用户手动拖拽所有节点但实际用起来发现一旦节点数量超过三十个手动整理就非常痛苦。所以项目里必须内置布局算法至少支持分层布局适合层级分明的架构图和力导向布局适合关系杂乱的拓扑图。最后图表数据的序列化必须干净。设计好的图要能一键导出、导入、甚至生成一份可读的文本描述。所以数据模型从第一天起就要跟渲染层解耦不能画布上一坨坐标、一坨样式直接存进去完事。1.2 方案选型为什么不直接套用现成库这里需要先交代一个背景。项目启动前团队内部其实做过一轮技术调研候选方案包括基于某个成熟图形框架二次开发、直接集成开源流程绘制库以及自己从零写一个渲染层。调研结论是成熟框架功能强大但自定义节点类型和交互行为时侵入性太强几乎是跟框架的渲染管线绑定死了开源绘制库解决通用场景没问题但一旦要跟业务的数据模型强同步就要写大量适配层性能上还容易失控。所以最终决定自己实现核心也就是画布渲染和交互框架自研布局算法引入成熟库做计算但通过适配层接进来。这样既保证了渲染层完全可控又省去了重复造轮子的时间。技术栈上渲染我选了 Canvas 2D。相比 SVGCanvas 在节点数量超过一定规模后性能优势非常明显。节点本身是矩形或圆形图标直接用图片绘制涉及文字用fillText整个思路非常直接。交互层用事件委托 空间索引来命中节点而不是给每个节点绑定独立事件。这个设计在后面性能优化章节还会详细讲。数据模型采用经典的图结构nodes数组 edges数组每个节点有唯一id、坐标x/y、宽高width/height、类型type以及业务自定义字段data。每条边记录source和target节点 id边可以附加箭头和标签。整体设计参考了图数据库的惯用法简单直接。1.3 核心模块边界划分我把整个项目拆成了四个模块数据层负责维护节点和边的增删改查、批量更新、历史记录撤销重做、导入导出序列化。布局层输入图结构数据输出节点坐标。分层布局和力导向布局都走统一接口方便扩展新算法。渲染层负责将数据画到 Canvas 上包括节点、连线、锚点、选中态高亮以及画布缩放和平移的矩阵变换。交互层负责鼠标事件解析区分点击、拖拽、拉框选择、连线、缩放等操作并把操作结果回写到数据层。模块之间通过接口通信数据层不依赖渲染层渲染层不直接改数据。这样做的最大好处是方便测试——布局算法可以脱离图形界面单独跑单测数据层也可以直接通过脚本构造一批节点验证序列化逻辑。2. 核心数据模型与图形基础实现2.1 节点与边的数据结构设计节点数据结构我最终定成了这样interface DiagramNode { id: string; type: string; x: number; y: number; width: number; height: number; data?: Recordstring, any; }type字段非常重要它决定了节点的默认大小、颜色、图标和连线规则。比如一个服务节点和一个存储节点虽然底层都是矩形但视觉表现和可连接的边完全不一样。这块信息放在配置中心里而不是写死在渲染函数里。渲染层拿到type查配置表拿到样式和图标然后绘制。边的数据结构稍微复杂一点interface DiagramEdge { id: string; source: string; target: string; type: default | dashed | solid; label?: string; sourceAnchor?: string; targetAnchor?: string; }sourceAnchor和targetAnchor是边的锚点方向比如从节点右侧连到另一个节点左侧。这个设计在架构图里特别重要因为连接点在哪个方位直接决定连线路径的走向连错方向会让图很难看懂。数据层维护一个Map[nodeId] - node的索引边的操作则通过起点和终点 id 来做。因为所有节点都在同一个画布里id 要保证全局唯一我直接用 UUID 或者带前缀的递增数字来生成。2.2 画布坐标系、缩放与平移Canvas 画布的缩放和平移本质上是一个 2D 仿射变换。我用一个矩阵对象来管理视图变换interface Viewport { scale: number; offsetX: number; offsetY: number; }节点坐标存储的永远是逻辑坐标也就是不随缩放变化的坐标渲染的时候通过变换矩阵换算成屏幕坐标。缩放时以鼠标所在位置为锚点这个细节如果不处理用户体验会非常奇怪。实现逻辑是这样function zoomAt(mousePos: Vec2, deltaScale: number) { const worldPos screenToWorld(mousePos); viewport.scale * deltaScale; const newScreenPos worldToScreen(worldPos); viewport.offsetX mousePos.x - newScreenPos.x; viewport.offsetY mousePos.y - newScreenPos.y; }拉框选择也需要做坐标换算鼠标拖出来的矩形是在屏幕坐标里要选择逻辑坐标里的节点就得把框的四个角点都转换到世界坐标再做矩形相交判断。这个坑我踩过一次——直接拿屏幕坐标矩形跟世界坐标里的节点做相交结果缩放后就全选不中了。平移则直接修改offsetX/offsetY手势上支持鼠标拖拽空白区域平移画布也支持鼠标滚轮上下滑动时结合 Ctrl 缩放。2.3 节点命中检测与空间索引Canvas 上没法像 DOM 一样直接监听某个元素的点击所以命中检测依赖手动计算发生点击时根据当前视图矩阵换算鼠标的世界坐标再遍历所有节点做矩形包含判断。节点数量少的时候这个遍历没啥问题但数量到几百个之后每次鼠标移动都遍历一遍代价就起来了。我的方案是维护一个简单均分网格索引。画布按固定网格大小分区每个节点插入它所在的网格列表查询的时候只遍历鼠标位置所在网格里挂载的节点再做精确包含判断。实测下来节点数在五百左右时命中检测耗时可以压到 1ms 以内。网格索引的伪代码如下class SpatialGrid { constructor(cellSize) { this.cellSize cellSize; this.cells new Map(); } insert(node) { const minX Math.floor(node.x / this.cellSize); const minY Math.floor(node.y / this.cellSize); const maxX Math.floor((node.x node.width) / this.cellSize); const maxY Math.floor((node.y node.height) / this.cellSize); for (let i minX; i maxX; i) { for (let j minY; j maxY; j) { const key ${i},${j}; if (!this.cells.has(key)) this.cells.set(key, []); this.cells.get(key).push(node); } } } }节点移动时不需要频繁重建整棵索引只要从旧格子列表里移除、再插入新格子即可。这个优化虽然实现起来琐碎但对交互流畅度的提升是肉眼可见的。3. 布局算法与自动排列实现3.1 分层布局让架构图层次一目了然分层布局适用于节点之间存在明确层级关系的场景比如应用层 - 服务层 - 数据层。实现思路并不复杂对有向图做拓扑排序确定每个节点的层级。对同一层的节点分配 x 坐标让它们均匀分布。处理跨层边的交叉问题做一次节点排序优化。层级分配可以看作一个 BFS 的问题。从所有入度为 0 的根节点开始逐层往下走function computeLayers(graph) { const indegree new Map(); const adjacency new Map(); for (const node of graph.nodes) indegree.set(node.id, 0); for (const edge of graph.edges) { indegree.set(edge.target, indegree.get(edge.target) 1); if (!adjacency.has(edge.source)) adjacency.set(edge.source, []); adjacency.get(edge.source).push(edge.target); } const queue graph.nodes.filter(n indegree.get(n.id) 0); const layerOf new Map(); let maxLayer 0; while (queue.length) { const node queue.shift(); const layer layerOf.get(node.id) ?? 0; maxLayer Math.max(maxLayer, layer); for (const nextId of adjacency.get(node.id) ?? []) { layerOf.set(nextId, Math.max(layerOf.get(nextId) ?? 0, layer 1)); indegree.set(nextId, indegree.get(nextId) - 1); if (indegree.get(nextId) 0) { queue.push(graph.nodes.find(n n.id nextId)); } } } return layerOf; }注意这里的indegree减到 0 才入队是确保所有上游节点都处理完。但是实际图里如果存在环拓扑排序会失效所以接口里要提供强制指定层级的覆盖参数把有环的节点手动塞到某一层。同层节点分配 x 坐标时要综合考虑节点宽度和层内间距。y 坐标则由层高最大节点高度 纵向间距累加得到。最后的整体居中微调可以不做但数据导出的美观度会差一些。3.2 力导向布局处理杂乱关系图关系图或拓扑图通常没有清晰层级这时候力导向布局更合适。原理就一句话节点之间存在引力相连的节点相互吸引不相连的节点互相排斥最终迭代收敛到一个稳定状态。力导向布局的迭代公式物理模拟味道很重function tick() { for (const a of nodes) { for (const b of nodes) { if (a b) continue; const dx a.x - b.x; const dy a.y - b.y; const dist2 dx * dx dy * dy; const force 1 / Math.max(dist2, 0.01); a.vx (dx / dist) * force * repulsion; a.vy (dy / dist) * force * repulsion; } } for (const edge of edges) { const a nodeMap.get(edge.source); const b nodeMap.get(edge.target); const dx b.x - a.x; const dy b.y - a.y; a.vx dx * attraction; a.vy dy * attraction; b.vx - dx * attraction; b.vy - dy * attraction; } for (const node of nodes) { node.vx * damping; node.vy * damping; node.x node.vx; node.y node.vy; } }迭代几次后会慢慢稳定。实际项目里我用模拟退火的思想来处理一开始温度高、节点移动幅度大随迭代轮数增加逐步降低移动幅度这样能更快收敛。另外为避免节点叠成一团加了非常小的随机抖动来打破对称性。不过力导向布局纯粹看结果的话往往需要人工微调。我的建议是布局完自动执行一次碰撞检测 重叠消除的后处理把重叠节点推开否则导出的图根本没法看。我封装了统一的布局接口这样后续要加树形布局、环形布局都只需要多实现一个接口不需要改动交互层任何代码。项目里的小模块设计做到这个程度就够了。3.3 布局中的参数调节与避坑布局算法的参数对结果影响巨大。这里分享几个调参经验分层布局里同层间距不要小于节点宽度的 20%否则线会交叉到离谱。力导向布局的迭代次数控制在 300 到 500 轮多了反而容易把节点震荡飞。布局完成后强制把节点坐标四舍五入到整数否则导出的 JSON 数据又长又难看后端同学看吐。说到底布局算法永远不可能比你手动拖拽更完美它的价值是帮你把初始状态快速搭好让你在这个基础上做微调。4. 渲染实现与交互细节4.1 Canvas 分层渲染的策略渲染层我用了一个很实际的策略把画布拆成两层 Canvas底层画静态内容所有节点、边上层画临时内容拖拽中的连线、选框、节点高亮。每次重绘时底层只在节点或边真正变化时才重绘上层则每次鼠标事件都清空重绘。这个拆分背后的原因是性能。如果只有一个 Canvas拖动节点时会频繁触发整张画布重绘尽管 Canvas 重绘本身不算慢但节点多的时候几百个节点的fillRect、drawImage累计起来也够帧率下降的。底层 Canvas 需要配上离屏 Canvas 做缓冲也就是说真正的绘制先发生在离屏画布上绘制完成再用drawImage一次性贴到显示层。这样可以避免屏幕闪烁也让重绘边界范围更可控。节点绘制函数长这样function drawNode(ctx, node, theme) { ctx.save(); ctx.translate(node.x node.width / 2, node.y node.height / 2); ctx.fillStyle theme.fill; ctx.strokeStyle theme.stroke; ctx.lineWidth 2; roundRect(ctx, -node.width / 2, -node.height / 2, node.width, node.height, 4); ctx.fill(); ctx.stroke(); if (node.type service) { drawServiceIcon(ctx); } ctx.fillStyle theme.textColor; ctx.font 12px sans-serif; ctx.textAlign center; ctx.fillText(node.data?.title ?? node.id, 0, 4); ctx.restore(); }绘制选中的节点时需要额外画一圈蓝色边框和锚点。锚点分布在节点四周中点位置边长固定为 8px 的小方块鼠标悬停在锚点上时光标要变成十字形状表示可以拖线。4.2 连线与锚点交互的完整实现连线是图表设计里最麻烦的交互之一。用户需要从一个节点的锚点按住鼠标拖到另一个节点松手后生成一条边。还有更进阶的操作拖到空白处可以调出一个节点选择菜单直接在链路中插入新的中间节点。连线绘制的核心是起点固定、终点跟随鼠标。在鼠标移动过程中上层的 Canvas 实时绘制一条暂态的虚线并且根据当前鼠标位置实时计算它是否落在某个节点的锚点附近如果落在就高亮那个锚点松开生成边。这里有一个重要的视觉细节连线要从锚点出发不是从节点中心出发。所以 I 层需要先命中锚点再启动连线操作。锚点命中的实现比节点命中更精细。锚点区域很小所以要把判定范围放大到 12px 左右然后再精确判断鼠标是否在锚点矩形内部。同时要注意如果鼠标从节点 A 的锚点拖到节点 B 的锚点B 的锚点方向决定连线进入角度。边本身渲染成贝塞尔曲线或折线。直线虽然简单但交叉的时候很难看。我最终采用二次贝塞尔曲线通过起止点相对位置计算控制点function getEdgePath(edge, nodeMap) { const source nodeMap.get(edge.source); const target nodeMap.get(edge.target); const sPoint getAnchorPoint(source, edge.sourceAnchor); const tPoint getAnchorPoint(target, edge.targetAnchor); const dx Math.abs(sPoint.x - tPoint.x); const dy Math.abs(sPoint.y - tPoint.y); const offset Math.max(40, Math.min(160, (dx dy) * 0.4)); const cp1 { x: sPoint.x offset, y: sPoint.y }; const cp2 { x: tPoint.x - offset, y: tPoint.y }; return M ${sPoint.x} ${sPoint.y} C ${cp1.x} ${cp1.y} ${cp2.x} ${cp2.y} ${tPoint.x} ${tPoint.y}; }这个offset根据两点距离动态变化避免距离很近时曲线过于扭曲。箭头绘制在路径终点方向需要计算切线角度再旋转绘制。4.3 拖拽、多选和拉框选择的状态机交互层的核心是一个状态机。我把鼠标操作离散成几种状态空闲、拖拽节点、拉框选择、拖拽连线、平移画布、缩放画布。每种状态下mousedown、mousemove、mouseup的行为完全不同。状态划分逻辑mousedown落在节点上进入拖拽节点状态。mousedown落在节点外围但落在选框区域同时按住 Shift进入多选状态。mousedown落在锚点上进入拖拽连线状态。mousedown落在空白区域按住空格或鼠标中键进入平移画布状态。mousedown落在空白区域普通左键进入拉框选择状态。状态切换的核心代码骨架const stateMachine { current: idle, onMouseDown(e) { const hit hitTest(e); if (hit?.type node) { this.current dragging; } else if (hit?.type anchor) { this.current linking; } else if (e.middleButton) { this.current panning; } else { this.current boxSelecting; } }, onMouseMove(e) { switch (this.current) { case dragging: moveNode(e); break; case linking: updateTempEdge(e); break; case panning: updateViewport(e); break; case boxSelecting: updateSelectionBox(e); break; } }, onMouseUp(e) { switch (this.current) { case dragging: finishMove(); break; case linking: finishLink(e); break; case panning: break; case boxSelecting: applySelection(); break; } this.current idle; } };状态机的好处是逻辑边界清晰不容易出现拖完节点又触发了平移这种幽灵 bug。5. 性能优化与大数据量处理5.1 合理使用离屏渲染与脏矩形先说一个常见误区很多人一上来就搞脏矩形重绘也就是只重绘变化区域。但在 Canvas 里部分清屏和重绘其实不省事因为所有元素相互叠加极端情况下反而容易糊。我推荐的方式是层级重绘 全量重绘之间做平衡。实际操作中我把滚动、缩放这类视图变换和节点移动、边增删这类数据变化分开处理。视图变换只对底层 Canvas 做整体缩放平移变换然后全量绘制一次因为 Canvas 的transform是 GPU 加速的绘制成本可以接受。数据变化则尽可能用局部重绘计算变化节点的包围盒清空这个包围盒对应区域重绘包围盒内的节点和边。这样做的前提是节点不能太密也就是包围盒内含的节点数要可控。我实测下来当一屏节点数少于两百时全量重绘反而更简单可靠局部重绘的收益只有在节点数很多时才明显。5.2 虚拟化渲染一屏只画看得见的虚拟化是处理千万级节点时的终极方案。图数据本质上是无限画布一屏只展示很小的一个区域。因此渲染循环里我先按当前视口裁剪出所有可见节点然后只绘制这些节点。配合空间索引获取可见节点非常快把视口在世界坐标系里的范围换算成格子范围遍历这些格子收集可见节点。边的绘制则复杂一些需要判断边的两端是否至少有一个落在可见区域否则整条边裁掉。因为节点宽高往往在 80 到 200 像素之间而视口也就不超过几个屏幕大所以通过虚拟化一屏实际绘制的节点数被压缩到五十到一百个左右绘制一次的成本很低。5.3 高频操作下的合并渲染拖拽节点时鼠标 mousemove 事件触发频率极高尤其在高刷新率显示器上可达 144Hz。如果每次事件都立刻重绘反而会造成资源浪费。常用的做法是做渲染帧合并在同一个浏览器帧内无论触发多少次数据更新只执行一次重绘。我用requestAnimationFrame实现let isFrameScheduled false; function scheduleRender() { if (isFrameScheduled) return; isFrameScheduled true; requestAnimationFrame(() { isFrameScheduled false; render(); }); }数据层每次修改只需调用scheduleRender渲染层保证每帧最多重绘一次。这个优化对流畅度的提升是决定性的千万别省。6. 常见问题与排查技巧实录6.1 坐标偏移滚轮缩放后节点位置跑偏这是缩放锚点实现失误导致的。早期直接套用了整体缩放的思路未考虑鼠标的锚定效果导致缩放中心一直固定在画布原点。用户把鼠标放在某个节点上滚动滚轮节点的屏幕位置跟着缩放走了观感上是节点在飘。排查思路很直接在缩放前后分别记录鼠标指向的世界坐标保证它不变。这个修正逻辑在上面zoomAt 函数里已经体现。如果发现缩放后节点还是跑偏检查视图偏移量是否实时更新尤其要注意offsetX/offsetY的类型——浮点误差如果不断累积缩放几十次后也会明显偏移建议每隔一段时间做一次偏移量取整校准。6.2 连线后边消失锚点算错或数据未刷新连线事件结束后如果新生成的边不显示一般有两个原因一是edge数据没有写进数据层的数组里二是渲染层没有订阅数据更新事件。我当时遇到的是第二种渲染层读取边的来源时用了缓存的快照新增边后没有刷新快照导致重绘时看不到新边。后来把数据层改成事件发布渲染层监听change事件重新拉取数据问题就没了。还有一个小坑边的source和target节点可能已经被删除了渲染层在遍历边的时候调nodeMap.get(edge.source)拿到undefined然后直接访问.x抛异常导致整个画布白屏。所以渲染边之前加一道防御判断非常必要。6.3 同一节点被连线多次和高亮残留交互层如果没在mouseup时清空暂态连线状态会出现重复生成边的现象。排查时重点看状态机里的finishLink是否正确重置了current值以及暂态数据临时连线对象有没有被同步置空。高亮残留在 Canvas 里表现为节点的蓝色外框拖影——其实是因为上层 Canvas 没有完全清空。清屏时要注意用ctx.clearRect清的是画布的全部物理区域而不是逻辑区域。如果缩放倍数不是 1用世界坐标清屏会把大部分区域漏掉。正确做法是ctx.save()后重置ctx.setTransform(1, 0, 0, 1, 0, 0)再用物理尺寸清屏。6.4 导出的 JSON 过大或包含冗余字段很多图表工具导出的数据包含大量坐标和样式这个diagram-design项目一开始也是这样。后来我把数据模型改成了两份一份是紧凑的业务 JSON只包含节点类型、id 和业务数据另一份是渲染 JSON加上坐标和样式字段。导出时默认导出紧凑版需要恢复编辑时再导全量版。这个设计让前后端联调舒服了很多毕竟后端只关心业务关系不需要管某个节点宽了多少像素。7. 实操过程与核心环节实现记录7.1 从空目录到可拖拽节点的完整流程这个项目我按四个里程碑推进。首先是数据层 渲染层基础打通实现画布显示一批静态节点然后是拖拽 缩放 平移的交互闭环接着是连线功能和布局算法接入最后才是性能优化和数据导入导出。里程碑一里我建议先不写任何交互直接把一批假节点画到 Canvas 上验证坐标系和数据绑定。这个阶段最容易踩的坑是 Canvas 的devicePixelRatio适配不处理的话高清屏幕上画出来的图会模糊。处理方式简单粗暴function setupCanvas(canvas, width, height) { const dpr window.devicePixelRatio || 1; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }注意逻辑坐标和物理坐标的换算后续所有绘制都在缩放后的ctx上进行。7.2 布局模块与数据层的对接示例布局模块不直接修改数据层的节点数组而是返回一个新的坐标映射由数据层做批量更新。这样的好处是布局过程是可预览、可撤销的。调用流程function applyLayout(layoutType: layer | force) { const graph dataLayer.getGraphSnapshot(); const positions layoutRegistry.get(layoutType).compute(graph); dataLayer.batchUpdatePositions(positions); render(); }batchUpdatePositions内部只触达一次change事件避免几百个节点各自触发更新导致多次重绘。这个细节也是性能优化的关键。7.3 导入导出能力的实现要点导出 JSON 时经过一次深拷贝和字段过滤剔除掉运行时产生的临时字段比如_isDragging、_isSelected这些。恢复导入时要重新生成 id 索引因为节点之间的边引用了 id不能直接信外部数据要做完整性校验把指向不存在节点的边直接丢弃。如果图数据量大建议导出的 JSON 做一次压缩去掉所有多余空格。对深层嵌套的data字段做扁平化转换不仅体积小前端读取也快。8. 回顾与扩展建议8.1 这个项目的价值在哪里diagram-design作为自己从零搭建的一个图表设计系统最大的收获不是会画图了而是理解了图形类应用的架构模式数据模型如何设计才能同时满足渲染和业务需求交互层如何用状态机管理各种鼠标手势Canvas 在数据量变大之后如何通过分层、虚拟化、帧合并来保性能。这些能力是可迁移的。比如节点和边的基础模型直接就能用于组织架构图、网络拓扑编辑器、甚至简单的思维导图工具。布局算法模块化之后任何新算法都只是新增一个类。8.2 可以继续扩展的方向往产品方向走这个项目可以加多人实时协作编辑用 WebSocket 做操作转发配合 OT 或者 CRDT 解决冲突。往技术方向走可以做自动美化——比如节点对齐引导线、一键整理连线交叉点、按类型自动分区分组。往 AI 方向走甚至可以结合大语言模型根据一段文字描述自动生成一张架构图模型输出的结构化数据直接喂给现有的数据层就能渲染出一张可用图。我现在的项目已经支持导出 PNG 图片和 JSON 数据后续计划加入一键生成图描述文本的能力输出类似服务A 依赖 存储B存储B 提供数据给 服务C的自然语言描述便于辅助文档编写。8.3 给同样想自己写图表设计器的朋友几句真心话如果你想实现一个diagram-design这样的项目我的建议是一步步来。第一版不要追求酷炫效果能把节点拖到任意位置、拉出一条线、导出 JSON 再导入这个闭环已经是完整的产品。在此基础上再慢慢加自动布局、智能吸附、协作才走得稳。还有别在一开始就引入重型框架或者数据流库。核心代码保持框架无关用类型系统约束数据结构用事件解耦模块这些设计在后面维护时会拯救你无数次。