
做diagram-design这个项目其实源自一个很实际的需求业务侧每次画架构图都要去各种在线工具里拼节点、拉线、导出截图改一次需求就得重画一遍效率低到让人抓狂。后来我干脆自己动手写了一个基于Web的图表设计器把节点拖拽、连线、缩放平移、导入导出这些能力全部打通用了大概不到两周的业余时间做出了一个勉强能用的版本又花了几周迭代到现在比较稳定的状态。整个过程踩了不少坑也收获了一些很有价值的经验趁着项目告一段落我把从设计框架到具体实现的完整过程拆开讲讲希望能给正在做类似可视化设计器的朋友一些参考。这个方向适合哪些人如果你正在做低代码平台、流程编排工具、架构图展示、思维导图或任意需要节点连线交互场景的前端项目这篇文章里的数据模型设计、坐标系统、连线交互和性能优化方案基本都能直接拿过去用。我会尽量把每一步的为什么也讲清楚而不是只丢一堆代码。1. 项目背景与整体设计思路1.1 为什么坚持从零做一个diagram-design市面上并不是没有可用的图表编辑器方案。有开源图编辑引擎有基于图形库封装的方案各有各的优势。但等到真正接入业务时你会发现一个尴尬的事编辑器本身的交互方式很固定想改成适合自己业务的交互逻辑反而比从零写一个更费劲。比如我需要节点支持自定义业务字段双击节点弹出一个完全自定义的业务表单很多现成库虽然能扩展但扩展点是它们定的侵入性很强升级一次框架版本就经常要跟着改一堆配置。还有一个因素是我对代码体积和可控性有要求。编辑器只用到节点、连线、锚点这几个核心基础能力并不需要图编辑引擎附带的高级排版、自动布局甚至微流程执行能力。与其引一个大而全的依赖不如把核心交互逻辑掌握在自己手里。后期任何功能都是增量开发出了问题也不需要在别人封装了几层的源码里排查。当然完全不引第三方图形库是不现实的。浏览器层面画东西绕不开原生能力所以我只保留了一层极其薄的渲染基础把大量的业务交互放在自己维护的逻辑层里。这个取舍在后面扩功能的时候确实省了大量的事。1.2 技术选型渲染层选Canvas还是SVG这是diagram-design里很容易起分歧的第一个问题。我的结论是节点数量大用Canvas节点数量少但交互复杂用SVGdiagram-design最终选择了Canvas。原因很直白业务里一个完整的架构图动辄几百上千个节点如果用SVG每个节点都是一堆DOM元素加上连线的path、锚点圆圈一次全量渲染浏览器会直接卡到无法操作。Canvas整个图画在一个画布上节点数量对渲染层的压力增长是平缓的更符合这个项目的实际场景。代价也很明显Canvas模式下所有节点都是一个画布上的像素浏览器没有原生的命中检测也拿不到每个节点的DOM事件。所以必须自己维护一套逻辑层数据 - 画布图形的对象映射并手动实现坐标命中、点击选区、拖拽目标是哪个节点等逻辑。这套映射在SVG里是白送的在Canvas里全部要自己写这也是项目初期工作量最大的部分。技术栈方面我选了Vue 3加TypeScript画布部分用封装好的Canvas 2D接口。选择TypeScript不是跟风而是图数据模型里有节点、边、锚点、端口、分组等多种类型类型系统能拦住大量低级错误。比如连线端口类型写错、坐标属性遗漏在编译期就能发现而不用等到运行的时候画出一个奇怪图形再回头排查。1.3 整体架构数据层、渲染层、交互层三层分离项目做到中期我越来越觉得一个清晰的架构比具体的图形渲染技巧更重要。diagram-design整体拆成了三个互不越界的逻辑层数据层保存完整的图结构数据包括节点列表、边列表、画布级配置缩放比例、偏移量、背景网格。这一层完全不知道画布怎么画、鼠标在哪个位置。渲染层接收数据层的图结构把每个节点、每条边、每个锚点绘制到Canvas上。这一层只做根据数据画图不关心节点的业务含义。交互层监听鼠标、键盘、滚轮事件根据事件修改数据层的数据再触发渲染层重绘。比如拖拽节点交互层修改节点坐标渲染层重新绘制数据层只负责存当前结果。三层分离最重要的好处是交互逻辑可以独立测试渲染层可以单独优化数据层单纯做序列化和反序列化。比如后来我需要给diagram-design加一个撤销重做功能只需要在数据层做历史快照完全不碰渲染和交互代码。如果把逻辑揉在一起这个功能基本要推倒重来。2. 核心功能拆解与实现要点2.1 图数据模型怎么定义才合理图编辑器中最基础的数据结构就是节点Node和边Edge。但只定义这两个还远远不够实际写的时候你会很快意识到节点不是孤立的点它有尺寸、位置、样式还可能需要多个连接点边也不是一根简单的线它可能带有箭头、转折点、标签。我在diagram-design里用的核心数据结构长这样interface DiagramNode { id: string; type: string; // 节点类型如rect、circle、group x: number; // 节点左上角x坐标 y: number; width: number; height: number; zIndex: number; // 层级 anchors: Anchor[]; // 锚点连接点数组 data?: Recordstring, unknown; // 业务自定义数据 style?: NodeStyle; // 填充色、边框、圆角等 } interface DiagramEdge { id: string; sourceNodeId: string; targetNodeId: string; sourceAnchorId?: string; // 可选缺省则自动找最近锚点 targetAnchorId?: string; path?: Point[]; // 路径控制点可空 style?: EdgeStyle; }节点的坐标我统一用左上角作为基准点而不是中心点。这个选择在拖拽时计算更直接鼠标相对节点的偏移量好算但画连接线时要算锚点绝对坐标就需要把左上角坐标加上锚点在节点内的相对偏移。很多第一次写图编辑器的人在这里会混一会儿用左上角一会儿用中心点导致节点一移动连线就跑偏。我的建议是节点定位统一用左上角锚点统一用相对节点左上角的偏移只有真正绘制或计算连线端点时才转成绝对坐标。边不直接存起点坐标、终点坐标而是存起点节点加锚点、终点节点加锚点。这样做有个好处节点移动时边自动跟着走不需要重新计算端点。边上带的一组可选控制点是为了支持折线、曲线路径否则一条复杂连线在节点挪动后可能变得很丑。2.2 节点渲染与自由拖拽的实现节点的绘制本身没什么技术含量无非是fillRect、strokeRect或根据类型画圆、画菱形。真正有难度的是命中检测和拖拽。Canvas的命中检测思路是这样的鼠标点击画布时拿到一个画布坐标然后遍历所有节点判断这个坐标是否落在某个节点的矩形范围内。注意从后往前遍历保证最上层的节点先被命中。节点数量大的时候逐一遍历所有节点会有点痛所以我在数据层维护了一个简单的空间索引把画布划分成若干格子每个节点记录它覆盖了哪些格子命中检测时先查鼠标点在哪个格子再只检查这个格子上的节点。牺牲一点内存来换点击响应速度在千级节点场景下实测效果很明显。拖拽的核心是按下、移动、松开三件套。按下时记录鼠标在节点内的偏移量移动时用当前鼠标坐标减去偏移量得到新的节点左上角坐标松开时做一个简单的防误触判断如果总位移小于3像素视为点击而不是拖拽。还有一个小点很容易漏拖拽过程中只是修改了数据层如果每帧都全量重绘整个画布节点多了会有明显的掉帧感。我在实现里只重绘了被拖拽节点的旧位置区域 新位置区域其他区域不动视觉上就是局部刷新性能提升非常明显。2.3 连线锚点与路径生成连线是diagram-design里比节点更容易让新手崩溃的部分。一条边从源节点连到目标节点首先要确定从源节点的哪个锚点出发。锚点一般是均布在节点边框上的几个固定位置比如上下左右四个锚点。用户拖线时最常见的问题是鼠标离节点很近但锚点一个都捕获不到。这里需要做吸附逻辑当鼠标位置距离某个锚点小于一个阈值我用的8像素就自动把鼠标吸附到锚点上。路径生成也有讲究。一条连线如果只是源锚点到目标锚点的直线图一复杂就会交叉得没法看。我实现了一个简化的自动转折逻辑先判断源锚点和目标锚点是否在同一个方向上比如都是右侧锚点那就用一条水平方向的贝塞尔曲线路径短而且视觉上最舒服。如果方向正交一个向右、一个向下我生成的是三段折线或平滑的二次贝塞尔曲线。如果两个节点有重叠区域连线会自动加一个绕行控制点避免线从节点中间穿过。判断控制点方向的依据是锚点所属的边方向向量。比如源锚点在右侧出线方向就是水平向右目标锚点在左侧入线方向就是水平向左。这两个方向向量的延长线如果相交交点就是控制点附近的位置我把它作为贝塞尔曲线的中间点。绘制时我用Canvas的quadraticCurveTo画二次贝塞尔曲线线宽、颜色、箭头用小三角形或菱形都走style配置。数据模型里的path字段存的是自动计算出的控制点节点移动时边会重新计算控制点保证曲线始终平滑。2.4 画布平移与缩放的正确姿势平移和缩放看似简单但它牵扯到整张画布的坐标系统。diagram-design里我维护了两个画布级变量offsetX/offsetY画布视口的偏移量和scale缩放比例。所有坐标系都依据这两个变量换算鼠标在Canvas上的位置换算成逻辑坐标logicalX (mouseX - offsetX) / scale绘制内容时的换算drawX node.x * scale offsetX注意缩放时还要处理滚轮中心点的问题。用户习惯把鼠标停在哪里画面就围绕哪里放大而不是围绕画布原点放大。做法是缩放前后保持鼠标所在的逻辑坐标不变然后重新计算offset。具体公式是newOffsetX mouseX - (mouseX - oldOffsetX) * (newScale / oldScale) newOffsetY mouseY - (mouseY - oldOffsetY) * (newScale / oldScale)这个公式很容易写错我第一次实现时直接设了offset offset * newScale / oldScale结果缩放时画面总是往角落跑。后来才意识到必须以鼠标位置为基准来计算。还有平移手势。我实现的平移有两种按住空格键再拖拽类似很多设计工具以及中键拖拽。平移操作本身是修改offset但注意别和节点的拖拽冲突。事件处理上我加了一个当前模式的状态机比如在canvas上按下时先判断是节点命中还是空白区域空白区域配合空格才进入平移模式否则就是框选模式。3. 实操过程与关键代码实现3.1 最小可运行Demo先跑通画布再谈花样如果你也想从零写一个diagram-design我的建议是先用30分钟搭一个最小的可运行版本再考虑加高级功能。最小版本包含一个Canvas画布、能画一个正方形节点、能拖拽它移动、能画一条直线边。这个Demo虽然简陋但它会让整个技术链路先跑通后续所有功能都是在这个基础上增量加。核心初始化代码大概是这样const canvas document.getElementById(diagram-canvas) as HTMLCanvasElement; const ctx canvas.getContext(2d)!; let nodes: DiagramNode[] []; let edges: DiagramEdge[] []; let offsetX 0, offsetY 0, scale 1; function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制网格背景 drawGrid(ctx, offsetX, offsetY, scale); // 绘制边先画边让边在节点下层 for (const edge of edges) drawEdge(ctx, edge, offsetX, offsetY, scale); // 绘制节点 for (const node of nodes) drawNode(ctx, node, offsetX, offsetY, scale); }注意先画边后画节点否则连线会盖住节点边框看起来像线从节点上穿过去。这个顺序问题很小但很多初学者没意识到画出来的图总是很脏。拖拽的基础逻辑可以这么挂canvas.addEventListener(mousedown, (e) { const pos screenToLogical(e); const hitNode hitTest(pos); if (hitNode) { dragState { nodeId: hitNode.id, offsetX: pos.x - hitNode.x, offsetY: pos.y - hitNode.y }; } }); canvas.addEventListener(mousemove, (e) { if (!dragState) return; const pos screenToLogical(e); setNodePosition(dragState.nodeId, pos.x - dragState.offsetX, pos.y - dragState.offsetY); render(); // 最小版本先全量重绘后续再优化 }); canvas.addEventListener(mouseup, () { dragState null; });screenToLogical是坐标换算的核心函数前面提到过就是把屏幕坐标转成逻辑坐标。最小版本里全量重绘是没问题的几百个节点也撑得住。性能优化放到后面再考虑先把功能跑通。3.2 从节点拖拽到框选多选交互的完整实现很多场景下用户需要同时选中多个节点一起移动。diagram-design里我实现了两种多选方式按住Shift依次点击以及右键区域框选。框选的实现思路是鼠标按下时如果按在空白区域记录起点。鼠标移动时动态画一个半透明的蓝色矩形选区其实就是在Canvas上临时多画一个矩形。鼠标松开时遍历所有节点判断节点的中心点或边界是否落在选区矩形内我用的边界矩形是否相交按需选择。命中的节点加入选中集合节点周围画一圈高亮边框。选中集合要放在数据模型里作为画布的一个临时状态。移动的时候所有选中节点一起改坐标。这里有个容易忽略的坐标换算如果画布有缩放框选的矩形范围也要除以scale再和节点坐标比较否则画小框会选中一大片节点。框选的视觉反馈要细心一点。我用的半透明蓝是rgba(59, 130, 246, 0.15)边框用实线加深蓝色。颜色不重要但透明度很低、边框不能太粗否则大面积框选的时候会遮住节点内容。这个小细节是我自己调了好几版才觉得顺眼的。3.3 导入导出与序列化的完整链路diagram-design的编辑结果一定要能被保存、被加载否则就是一个一次性画板。序列化格式我选用JSON因为图数据结构就是嵌套对象JSON天然匹配。整体导出结构{ version: 1, canvas: { width: 1920, height: 1080, offsetX: 0, offsetY: 0, scale: 1 }, nodes: [ { id: node_1, type: rect, x: 100, y: 100, width: 160, height: 80, anchors: [...], data: {} } ], edges: [ { id: edge_1, sourceNodeId: node_1, targetNodeId: node_2, sourceAnchorId: anchor_right, targetAnchorId: anchor_left } ] }导入时要注意两个问题。第一是节点ID的校验确保边的sourceNodeId和targetNodeId确实存在于节点列表否则加载一个残缺JSON就会画出一堆指向空对象的野线。第二是版本兼容我特意在JSON里加了一个version字段。后续数据结构一旦变动可以根据version做迁移而不是直接解析失败。这个习惯让我避免了用户之前存的图打不开的惨剧。除了JSON我还做了图片导出。Canvas的canvas.toDataURL()可以直接生成PNG但问题是导出内容需要包含当前视口之外的内容。我先把画布临时扩展到全图尺寸重新绘制一遍再把画布尺寸改回来。Canvas尺寸改动时会自动清空画布所以导出后必须主动重新渲染。这个坑我试过一次导出后页面变黑折腾了半天才想起来是画布尺寸改动导致内容丢失。3.4 性能优化几千个节点也不掉帧的细节当节点的数量到达千级时Canvas全量重绘的帧率会明显下降。我做了三个层面的优化效果是diagram-design在4000个节点以下能保持流畅交互第一层是渲染裁剪。只绘制当前视口内可见的区域视口外的节点直接跳过。计算方式是用逻辑坐标判断节点矩形和视口矩形也是逻辑坐标的矩形是否有交集。这个优化的理论依据很简单屏幕上能看到的区域顶多几千个像素宽大多数节点根本不需要画。但裁剪一定要放在循环的最前面否则白白浪费遍历时间。第二层是局部重绘。拖拽和缩放时全量重绘掉帧明显我改成维护一个dirtyRects数组。每次交互改动了某个区域就把这个区域加入脏矩形列表渲染时只清空并重绘这些区域。因为Canvas的clearRect很便宜而填充路径相对贵先clearRect小区域再绘制对应区域的节点整体性能提升非常可观。第三层是防抖和节流。拖拽过程中mousemove事件触发频率非常高每帧都执行完整的命中检测和渲染没有意义。我用了requestAnimationFrame作为渲染入口如果一帧内发生了多次鼠标移动只渲染最后的状态。实测下来这一点把CPU占用直接降了一截。毫秒级别的优化没有意义但这三件事加起来才让千级节点交互变成一个可行的方案而不是一堆代码能跑但卡成PPT。4. 常见问题与排查技巧实录4.1 canvas坐标换算的offset陷阱我在diagram-design开发阶段遇到最隐蔽的一个问题是Canvas在页面中的坐标和设备像素比不一致。Canvas元素的width/height和CSS里显示的宽度高度可能不同尤其是在高分屏上浏览器会自动做devicePixelRatio的缩放。如果直接用e.clientX - canvas.getBoundingClientRect().left这种写法去算鼠标坐标拿到的是CSS像素坐标但Canvas内部的逻辑像素是乘以DPR之后的结果就是鼠标永远点不到节点上差一两倍甚至更歪。正确做法是在初始化时把Canvas的绘图尺寸放大const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; ctx.scale(dpr, dpr);然后事件计算坐标时除以CSS宽度对应的位置让逻辑坐标保持在CSS像素空间。同时还有一层额外的适配因为画布本身还有平移缩放所以从屏幕坐标到逻辑坐标需要做两次变换先减去Canvas的偏移再除scale。这里我给你一个自查清单当出现鼠标点击位置和图形位置对不上时先检查DPR缩放有没有做再检查offset的基准点是不是节点左上角最后检查Canvas本身有没有被CSS拉伸。90%的坐标错乱都逃不过这三点。4.2 拖拽卡顿的罪魁祸首拖拽掉帧是很多人会问的问题。如果你在节点上加了阴影、圆角、渐变这些复杂样式Canvas的shadowBlur尤其贵一多就会拖拽掉帧。我第一次给节点加阴影时100个节点就卡得不能看后来把阴影改成预渲染的Sprite图或者干脆去掉阴影只保留很淡的边框立即就流畅多了。另一个拖拽卡顿的原因是每次拖拽都触发全量重绘。解决方法是局部重绘前面提到过。还有一个小坑是缩放时重绘全部节点导致的卡顿思路是把重绘范围限制在视口内。最后的建议是要学会用浏览器的Performance面板。不要凭感觉猜性能瓶颈在哪里录一段交互状态看渲染时间花在哪个函数上再做针对性优化。很多我以为是逻辑问题的掉帧最后发现是事件监听绑得太频繁导致的函数调用次数爆炸。4.3 连线吸附失灵的排查思路连线吸附的Bug是用户拖线到节点附近但怎么都没有吸附效果。排查顺序一般是先看Anchor是否在正确位置。锚点数据存的是相对节点的偏移绘制时要用node.x anchor.offsetX换算成绝对坐标。如果只有节点移动后锚点不同步更新大概率是渲染时忘了加node.x和node.y。这个Bug我印象很深Debug时断点打上去发现锚点坐标永远固定在一个点上。再看吸附阈值。我设的吸附距离是8像素但要注意这个8像素是逻辑坐标的8像素还是屏幕像素的8像素。如果画布缩放到了0.5倍屏幕上的8像素只等于逻辑坐标的4像素用户会觉得吸附范围太小。正确做法是把阈值除以scale再做判断让用户在任意缩放下都有一致的吸附手感。最后检查命中节点判断有没有被遮挡。如果节点上方有其他层级的节点鼠标点在一堆重叠节点中间吸附逻辑可能选错了节点。这种情况我通过给节点增加最小的热区优先级来解决取最上层的那个节点作为吸附目标。4.4 撤销重做怎么实现才不崩撤销重做是设计工具的核心体验但实现不当会让整个编辑器状态崩溃。我采用的是操作记录快照的方式在每次操作完成时注意是操作完成不是每个鼠标移动帧把整个数据模型序列化成一个JSON字符串压入历史栈。撤销时弹出一个快照恢复重做时反向弹出。这里有两个关键点。第一个是什么时候入栈。拖拽过程中如果每个帧都入栈撤销栈会爆炸。我在鼠标松开事件里判断这次交互是否真正改变了状态有变化才入栈。第二个是快照的存储体积。一个上千节点的图JSON序列化后可能有几百KB如果存几十个快照内存几十MB还能接受。但如果支持超大图就要改成增量快照或者命令模式了。我的实现用了命令模式把每个操作封装成execute和undo两个方法。比如移动节点这一个操作入栈时存的是所有被移动节点的旧位置和新位置undo就是恢复旧位置不需要序列化整个图。这样不管多大规模的数据撤销重做的内存开销都是恒定的对大图支持好很多。5. 扩展方向与踩坑总结5.1 后续值得做的几个方向diagram-design这个项目的基础框架搭稳后有很多方向值得继续投入自动布局算法目前节点位置完全靠用户手摆如果你面对的是流程图或泳道图按拓扑排序做分层布局用户就只需要关心节点内容不需要关心摆位。连线样式编辑增加更多种类的连线比如直线、折线、曲线、正交线支持从一个连线的控制点拖动改成折线拐点。小地图导航当图特别大的时候角落放一个缩略小地图拖动小地图上的矩形快速定位视口这是大型图编辑器的必需品。多人协作编辑图数据天然是JSON接入协作框架后可以做成多人在线编辑但要注意并发时的节点移动冲突解决这是很复杂的工程。5.2 踩坑清单给后来者的七条忠告我把这个项目里最想告诉后来者的问题集中列一下避免你在同样的地方浪费时间节点坐标基准统一用左上角锚点用相对偏移别混用。绘制顺序先边后节点不然连线穿节点边框很难看。序列化JSON时务必校验边的端点ID必须有版本号。缩放时围绕鼠标位置更新offset别只简单等比缩放。高频事件用requestAnimationFrame合并渲染别在事件回调里直接画。节点数量大以后第一步做视口裁剪第二部做局部重绘第三步再考虑更复杂的手段。阴影在Canvas里很贵能不用就不用非要用就离线渲染一次缓存。我在实际开发中最深的一个体会是可视化设计器看起来简单做起来是交互细节的修罗场。很多问题在静态界面的开发中几乎不会遇到但一旦涉及大量鼠标操作、坐标变换、状态同步细节就全部暴露出来。如果你也是在做一个类似项目别被刚开始的顺利迷惑那些藏在拖拽、缩放、连线背后的边界情况会一个接一个地冒出来提前把架构拆清楚能救你很多时间。最后再分享一个小技巧给自己的编辑器准备一个固定的压力测试图用代码生成随机节点和随机连线来灌数据。每次改动完一个功能先跑一遍压测图很多崩溃和卡顿都能提前发现否则你很有可能在给用户演示的现场发现画布卡死。这个习惯救了我好多次强烈建议你也试试。