开头就不用打招呼了直接说项目。去年下半年我和团队的核心前端成员启动了一个内部项目代号叫diagram-design。起因很实际我们维护的一套运维监控类后台系统需要频繁产出网络拓扑图、模块依赖图、流程图界面要求跟整体设计风格完全统一。现有的开源图编辑方案要么数据格式锁死、要么二次开发成本高还有的私有化部署条件苛刻于是决定自己做一套可嵌入、可定制、可序列化的图表设计器。今天记录的就是这个项目从设计到落地的完整过程包括方案选型、核心实现、掉坑记录和最终的取舍建议希望对正在做可视化编辑器的前端开发者有用也欢迎你们拿图元系统、连线路由、撤销重做这些模块做参考。1. 项目定位与整体方案选型1.1 为什么放着现成的不拆非要自研先说说当时的调研结论这是决策的基础不然容易被说成重复造轮子。我们调研了市面上一批现成的图编辑方案发现两个核心问题一是格式封闭大部分现成方案有自己定义的一整套数据存储结构节点的自定义属性、样式扩展、业务字段都得在它的体系内缝缝补补遇到审批流、监控拓扑这类业务模型时序列化层的适配成本反而比自研更高二是渲染风格和交互细节难统一后台系统用的是组件库的主题变量要求图表上的节点、连线和整体色彩体系完全一致第三方库封装层级深改一套主题比写一个渲染层还费劲。当然不是所有场景都适合自研。如果需求就停留在画个流程图、导出图片这种通用场景直接用成熟方案性价比更高。diagram-design立项的前提是三个硬性要求私有化部署、深度定制节点类型、数据模型完全可控。满足这三点自研才有足够的空间和必要性。结论是先做最小可用的编辑器内核而不是一步到位做在线协同、多人实时编辑这些重功能。1.2 渲染方案选型SVG、Canvas还是DOM这里是我踩过的第一个坑区域也是很多新开图编辑项目最容易纠结的点。网上大量文章会直接告诉你节点多就用Canvas节点少就用SVG听起来没错但实际操作下来远没有那么简单。我做了个对比测试把三种方案的优劣势列在一个表里维度SVGCanvasDOM CSS渲染性能中等节点复杂度高时下降明显高适合大量节点低节点多时重排严重事件处理天然支持逐元素事件绑定需要手动命中检测天然支持但层级管理繁琐节点内容复杂度高内部可嵌HTML低文字排版需自绘最高完全复用CSS坐标变换调用系统级变换 API需自行维护视口变换需自行维护且受CSS影响序列化输出可直接导出需二次绘制才能导出依赖DOM快照diagram-design最终选择的方案是节点层用 SVG 渲染连线层也画在 SVG 里但用了一个独立的g容器维护。这样做的理由很直接我们的节点数量通常在几十到几百这个量级硬件渲染性能差异根本体现不出来反而是 SVG 的原生事件模型帮了大忙click、pointerdown、pointerup可以直接绑定在节点元素上不需要像 Canvas 那样自己做坐标换算、矩形命中、元素拾取这一套完整的事件系统。如果你做的是超过千级节点的图编辑工具我才建议主渲染层切到 Canvas。不过即便用 Canvas也会遇到文字抗锯齿、锚点拾取、命中检测这些新的棘手问题团队人力不够的话慎选。1.3 技术栈与目录结构规划技术栈没什么好纠结的TypeScript 几乎是不二选择原因大家都懂图元数据结构复杂类型定义清楚比什么都强。运行时框架选的是前端主流的组件化框架这里不具体点名因为核心引擎的设计跟框架关系不大。状态管理用轻量级方案即可不需要重型全局状态库因为diagram-design的核心设计原则是单一数据源 派生视图所有操作修改的都是同一个 Model视图只做渲染。目录结构我贴出来这套划分从项目初期就定下来了后续所有迭代都没破坏过diagram-design/ ├── packages/ │ ├── core/ # 核心模型Node、Edge、Viewport、Document │ ├── renderer/ # SVG渲染层节点渲染、连线渲染、自定义节点注册 │ ├── interaction/ # 交互层拖拽、框选、缩放、连线创建 │ ├── commands/ # 命令模式实现撤销、重做、批量操作 │ └── serializer/ # JSON序列化、版本迁移 ├── examples/ # 业务场景示例 └── tests/ # 单元测试与交互测试这套分层后来被证明非常抗造核心模型完全不知道渲染层存在所以后续换渲染引擎或者加协同逻辑时底层数据模型不用大动。1.4 数据模型设计先把Schema定明白做图编辑器的第一件事我建议不是写渲染而是把数据模型设计好。渲染只是状态的一种投射模型才是整个编辑器的地基。diagram-design的根数据结构就三块节点、连线、视口。节点模型里必须包含唯一ID、坐标、尺寸、类型名称、业务属性区。坐标我统一用中心点而不是左上角因为后面做旋转、缩放和节点对齐时中心点计算比左上角计算退避了很多边界条件。连线模型里需要记录起点节点、终点节点、绑定锚点以及路由计算出的折点坐标注意连线本身不能存死坐标否则节点拖动后连线就是一条僵死的线必须存关系、实时算路径。视口就一个缩放比例和画布偏移量所有渲染层的坐标转换都基于它。数据模型上一旦定了这三个信息编辑器最重要的主体功能就锁定了后面加什么都只需要在这三个结构上做扩展。2. 核心功能拆解与关键实现2.1 自定义节点注册体系diagram-design的中文项目名可以叫图元设计器但设计图的重点是图元也就是节点类型。不同业务场景的节点长得完全不同监控拓扑里的服务器是一个带圆角的矩形加上状态灯审批流里的审批节点是泳道样式数据库建模里是带字段列表的表结构。如果每个节点都写在编辑器内核里那这个内核就变成了一个永远改不完的业务筐。所以第一件事情是实现一个节点注册表。类似于装了一本字典业务方只需要把节点名称和对应的渲染函数或组件传进来就可以在画布里使用这种节点。每次渲染前渲染器从注册表里取出对应的渲染实现把节点的数据模型和视口变换传给它让它生成对应的 SVG 片段。注册系统另外一个关键能力是每种节点可以有自己的属性配置面板。比如审批节点有审批方式属性服务器节点有CPU核数内存大小属性。属性面板通过节点注册表联动实现了数据和视图的解耦。2.2 拖拽移动与命中检测拖拽移动看起来是最基础的功能但细节决定了手感。先讲命中检测。当用户在画布上按下去时第一件事是判断按到了什么按中节点就进入节点拖拽按中连线就进入连线拖拽按中空白就进入多选框选。SVG 方案里命中检测天然好用元素就挂在 SVG DOM 上监听事件时event.target直接就能拿到命中的元素然后通过元素上的>// 屏幕坐标转换为画布坐标 function toCanvasPoint(clientX: number, clientY: number): Point { return { x: (clientX - viewport.panX) / viewport.zoom, y: (clientY - viewport.panY) / viewport.zoom, }; } // 拖拽移动阶段 function handleNodeDrag(event: PointerEvent) { const current toCanvasPoint(event.clientX, event.clientY); // 用当前画布坐标减去按下时记录的画布坐标 const dx current.x - dragStart.canvasPoint.x; const dy current.y - dragStart.canvasPoint.y; dragStart.node.x dragStart.nodeOriginX dx; dragStart.node.y dragStart.nodeOriginY dy; render(); }这里有个很重要的响应式节奏问题。每次pointermove都触发一次渲染会显得过于灵敏尤其当页面里还有其他DOM元素时会有掉帧感。diagram-design的处理方式是统一走requestAnimationFrame调度把同一帧内的多次移动合并成一次渲染。这是我建议你在一开始就写进架构里的因为后面叠加、性能优化时没有调度层会非常痛苦。2.3 视口缩放与平移缩放和平移是图编辑器的标配核心难点在以鼠标为中心缩放也就是鼠标停在图上某个点时缩放后那个点依然保持在鼠标所指的位置否则用户在缩放时很快就看不到自己想看的那块内容了。这个公式推导起来不复杂但很容易写错。正确做法是先把鼠标的屏幕坐标转成画布坐标缩放后重新计算偏移量function zoomAt(factor: number, clientX: number, clientY: number) { // 缩放前的画布坐标 const before toCanvasPoint(clientX, clientY); // 更新缩放比例限制最大最小值 viewport.zoom clamp(viewport.zoom * factor, 0.1, 8); // 缩放后让画布坐标 before 依然落在鼠标位置 viewport.panX clientX - before.x * viewport.zoom; viewport.panY clientY - before.y * viewport.zoom; render(); }真正麻烦的是缩放时拖拽的配合。如果节点拖拽的位移量没有除以缩放比例出现的问题是缩小到0.5倍时鼠标拖了50像素节点却移动了100像素看起来就像节点在鼠标上漂移。这个问题我在4.1小节会专门展开因为它的排查过程很典型。交互手势方面桌面端是滚轮缩放加空白处拖拽平移移动端是双指捏合缩放。双指手势的基准缩放基于两指距离的比例算而不是用一个固定系数体验会更线性。2.4 连线与锚点路由连线是图编辑器里比节点更考验算法功力的模块。一个正规的连线系统中连接的不是节点本身而是节点的锚点也就是节点边缘上可以和其他节点发生连接的点。diagram-design里锚点共有八个分布在四边和四角数据里记录了连接的是哪个锚点这样连线端点就会固定吸附在锚点位置而不是跟着鼠标点哪里算哪里。连线路径计算是全文第一个值得单独写算法的部分。最基础的是直线连线坐标就是起点锚点和终点锚点连一条line但真实业务里几乎不会用直线因为连线会穿过节点视觉效果很乱。我们实现的默认路由是正交折线也就是先水平走一段再垂直走一段再水平走一段的 Z 字形路径。具体计算规则是当终点在起点右下方时第一段先向右到中间点再向下再向右方向不同时对称调整。这个算法的核心代码是寻找中间点function getOrthogonalPath( start: Point, end: Point, offset 40 ): Array{ x: number; y: number } { const midX (start.x end.x) / 2; if (Math.abs(start.x - end.x) 10) { // 几乎垂直直接走竖线 return [start, end]; } // 先横向到中点再纵向到终点 return [ start, { x: midX, y: start.y }, { x: midX, y: end.y }, end, ]; }连线的命中检测也是个细节活尤其是折线线段很细用户用鼠标精确点中一条线其实挺难的。diagram-design的命中处理原则是所见线段的命中范围要有额外的加宽余量。实现方式是画两遍先画一条粗的透明命中线stroke-width设置为16像素的透明色再在上面画一条细的可见线这样用户点击时点到的是透明粗线视觉上看到的却是细线命中难度大大降低。后来我们调整为直接给可见线加宽描边再加半透明效果效果更直观。2.5 多选框选与快捷键体系多选能力用到的碰撞检测算法是 AABB轴对齐包围盒检测思路很成熟把框选矩形的坐标和每个节点的包围盒坐标做相交判断。SVG 方案里还有一个随时可用的偷懒路径直接用getBoundingClientRect拿节点在屏幕坐标系的包围盒然后和框选矩形也是屏幕坐标判断相交省去画布坐标换算的复杂度我当时实测下来两者性能没有可感知的差异。有了多选之后快捷键体系就显得顺理成章了。复制粘贴、删除、方向键微调、撤销重做这四组必须实现。特别注意一点粘贴时要判断画布视口的位置把粘贴出来的节点放在当前可见区域的中心不要放在复制时的旧坐标否则用户缩放平移之后粘贴的节点会出现在视野之外第一反应是粘贴失败其实是粘贴成功了但看不见。3. 实操过程与核心环节实现3.1 从零搭建最小可用的原型这个项目的第一版原型我花了大概两周时间这是刻意控制的范围。先不追求完美只做四件事能画一个节点、能拖拽移动、能缩放画布、能连一条线。这个原型存在的目的不是交付业务功能而是验证三个问号SVG 事件方案能否支撑核心操作、坐标变换公式是否稳定、数据模型是否能承载后续业务扩展。原型阶段的工程结构可以简化核心文件就三个model.ts数据定义、renderer.ts渲染逻辑、interaction.ts事件处理。先把这三个文件写出来整个项目的骨架感就有了。值得提醒的是原型阶段我就用了 TypeScript 严格模式。很多人写原型图快就偷懒用any把错误全吞掉后面数据模型一变就全盘崩盘。原型期的类型就是项目定义期的文档这里偷的懒将来都要加倍偿还。3.2 状态管理与操作历史的实现图编辑器的状态管理会和普通前端页面有明显差异。普通前端页面有大量表单和临时状态但图编辑器的状态就是一张图模型而且这张图可以被一系列操作持续修改。最简单的状态管理方案就是直接修改模型每次修改后触发渲染这个方案早期足够用。但一旦要加撤销重做就会暴露风险。diagram-design的撤销重做实现选择了快照方式而不是命令模式。命令模式要求每个操作都实现redo()/undo()两个方法优点是可以精准控制每个操作的逆操作缺点是业务操作一旦变多就会失控每加一个新功能都要写一对命令类。快照方式简单粗暴每次操作执行前把模型深拷贝一份推到撤销栈需要撤销时把栈顶快照恢复出来。很多人会质疑快照方式浪费内存确实会但对于中小型图节点几百个、连线几百条一份 JSON 快照往往只有几十KB栈容量限制在50份内总内存占用完全可以接受。我做过压测50份快照在常用设备上占用的是一两兆内存左右换来了新增操作零成本接入撤销的巨大开发效率提升。interface Snapshot { content: string; // JSON.stringify后的模型 label: string; // 操作说明展示在历史面板用 } class HistoryManager { private undoStack: Snapshot[] []; private redoStack: Snapshot[] []; private limit 50; push(snapshot: Snapshot) { this.undoStack.push(snapshot); if (this.undoStack.length this.limit) { this.undoStack.shift(); } this.redoStack []; } undo(): Snapshot | undefined { const latest this.undoStack.pop(); if (latest) this.redoStack.push(latest); return latest; } }我这里把快照实时序列化成 JSON 字符串而不是存对象引用这是刻意的。对象引用会被后续操作修改导致快照失真序列化成字符串则是真正的不变数据。性能问题也不用担心JSON 序列化几百个节点在常数毫秒内就能完成完全不会卡顿。3.3 渲染循环与节点组件注册渲染层我采用的是无状态渲染设计。每次渲染时直接给渲染函数传入当前模型函数返回一份全新的 SVG 元素树。简单说模型一变整个渲染层刷新不做局部 DOM diff。这个方案在某些纯前端场景下会被质疑性能但实测下来完全没问题因为 SVG 节点数量不多重新生成和挂载的开销远低于一次网络请求。节点渲染的设计模式用了注册制核心渲染函数只认识节点类型具体长什么样由已注册的渲染器决定interface NodeRenderer { render(node: DiagramNode, ctx: RenderContext): VNode; } const nodeRendererRegistry new Mapstring, NodeRenderer(); function registerNodeRenderer(type: string, renderer: NodeRenderer) { nodeRendererRegistry.set(type, renderer); } function renderNodes(nodes: DiagramNode[], ctx: RenderContext) { return nodes.map((node) { const renderer nodeRendererRegistry.get(node.type); if (!renderer) { return renderFallback(node); } return renderer.render(node, ctx); }); }得益于这个设计核心包完全不需要知道审批节点长什么样、服务器节点长什么样业务方通过注册接口就能扩展自己的节点类型这也是项目能保持干净边界的关键。3.4 序列化、导入导出与版本兼容图编辑器的业务价值最终体现在序列化上。用户在画布里辛苦拖拽的节点关系必须能够稳定保存、传输、恢复才算真正可用。diagram-design的序列化格式就是直接的 JSON包含格式版本号、节点数组、连线数组、视口信息四部分。格式版本号是我特别强调的一个字段也是一个真实教训。项目上线后第一版 JSON 里节点样式字段叫style后来接口规范调整要求改成styles如果解析老数据时没有按版本号做逻辑分支所有存量图都会直接崩溃。有了版本号每次数据结构变更就可以写一个迁移函数从 v1 导到 v2保证旧数据永远能打开。导出的交互层面支持复制整张图为图片做法是拿到当前所有 SVG 元素拼成一个独立的svg标签然后用图片加载并绘制到 canvas 上再用 canvas 导出为 PNG。这里有一个细节导出图片的分辨率要按当前缩放比例放大若干倍否则在高分屏上刷新出来的图片是模糊的。实测下来把导出 scale 设为window.devicePixelRatio基本能保证观感清晰。4. 常见问题与排查技巧实录4.1 缩放后拖拽位移漂移这个是最多做图编辑器的新手会踩的坑现象非常典型画布缩小到0.5倍时鼠标把节点往右拖了100像素节点却移动了200像素看起来像节点长了翅膀在逃。问题根源就是我在2.2小节提到的坐标系混乱。拖拽过程中如果监听pointermove事件时直接拿event.movementX这种屏幕坐标增量去做节点位移没除以当前的缩放比例位移量就被错误放大了。正确写法是每次计算都把屏幕坐标统一转成画布坐标。排查技巧是加一套控制台日志开关。在拖拽期间打印按下时的画布坐标、当前画布坐标、位移量以及最终节点坐标一比就能看出是哪个环节的坐标单位不一致。4.2 缩放卡顿与命中困难得当的优化顺序是先小后大先解决单帧内的计算压力再考虑渲染压力。我遇到的第一个性能问题是缩放时频繁重排。缩放画布会带动所有节点和连线重绘如果每移动1像素就重绘一次会显著掉帧。解决策略是事件节流加requestAnimationFrame合并把同一帧内的多次缩放操作合并为一次重绘。第二个问题是连线命中困难。连线线条很细视觉宽度通常只有1到2像素但透明命中层宽达16像素确保用户轻松点中。如果直接加宽可见线条会影响图面美观所以透明命中层才是又要好看又要好点的常规解法。4.3 导出图片字体和清晰度问题导出图片时最容易出现字体变了的问题本质上是 SVG 内嵌文字依赖系统字体不同机器上渲染结果不一致。彻底解法是给导出的 SVG 内嵌字体但字体文件较大需要权衡导出体积。实际项目中我用了一个折中方案把需要导出的文字尽量转成path路径或者用系统自带的安全字体列表替换确保目标环境可以稳定显示。清晰度问题的通用解法我在3.4小节讲过了就是导出 scale 按devicePixelRatio放大再结合高清屏的实际场景通常设置成2到3就能满足大部分需求。4.4 撤销栈内存膨胀与越级暗坑快照方案的内存消耗问题在节点特别多的项目里是真实存在的。我曾经在一张千级节点的监控拓扑图上做压测一次操作生成的快照可能达到几百KB50份快照轻松突破几十MB长会话操作后明显变卡。解决思路是给HistoryManager增加自适应限制节点数量越大允许存储的快照数量越少。实测下来1000个节点时把快照数量限制在10份内存占用和撤销体验基本达到平衡。这里还有一个暗坑批量操作会生成多份快照比如一次删除20个节点如果每删一个就推一份快照用户撤销时按删除的顺序一步步回退体验非常破碎。正确做法是批量操作合并为一份快照在一个操作链条开始前记录一次模型快照链条中的中间状态不记录。这样用户撤销一次整批操作全部回退符合直觉。4.5 移动端手势冲突与坐标异常移动端图编辑器的坑主要在两个场景。一个场景是单指拖动画布时系统默认手势会拦截触摸事件导致画布移动体验怪异。解法是设置touch-action: none明确告诉浏览器这个元素不处理默认手势由我来自行处理。另一个场景是双指捏合缩放时两指的触点坐标会不断变化直接用clientX/clientY做缩放基准会有些许抖动更好的是用两指中心点和距离比例计算并且只监听距离变化不叠加位置偏移。5. 一些经验之谈项目做到这个阶段我最大的一条体会是图编辑器的复杂度不是写出来的而是改出来的。一开始就把数据模型定义清晰只做单一的模型变更入口所有操作都能回溯、能测试后面任何功能迭代都是在这个模型上增加能力而不是推翻重来。第二个经验是不要过度设计。初期没必要做完整的命令模式、复杂的增量渲染甚至不需要做撤销重做框架先让一个节点可以拖拽移动起来这个编辑器就立住了一半。很多团队上来就铺开做流程图、拓扑图、UML图全部支持结果半年后连一个稳定可拖拽的案例都跑不通。最后一个实用技巧给项目写一份协议文档。把坐标单位、锚点规则、渲染层级、序列化版本约定记下来不只自己看也给接手的同事看。这个文档在后续做协同编辑、导出对接、业务方接入的时候省下来的沟通成本比当初写文档的时间多得多。diagram-design还在迭代后面计划做多选组的对齐分布、离线存储和操作日志回放这些都会在现有骨架上继续长出来。如果你也在做类似的图编辑器项目欢迎把这份记录当作一份工程地图少跳几个坑就算有用。