
去年年中我给自己定了一个有点疯狂的目标不依赖任何现成的图形编辑组件从零写一个叫 diagram-design 的在线图表设计器。起因很现实——业务侧一直缺一个能高度定制的拓扑编排画布通用工具要么改不动要么引进来之后反而被框架绑死索性自己动手。做出来之后我现在日常用它画架构图、网络拓扑、带自定义表单的工作流图已经稳定跑了好几个内部场景。这篇内容就把这个项目里我认为最值得讲的部分——自研决策、数据模型、事件交互、性能优化和排查记录——一次性讲清楚适合打算自研画布、或者在评估“自研还是用现成库”的开发者参考。1. 先想清楚自研图表设计器“diagram-design”到底要解决什么问题1.1 为什么现成的画布工具越用越难受我在决定自研之前其实先试过两三条路直接基于现成的开源画布组件做二次封装再在它外面套业务逻辑。刚开始觉得挺省事节点能拖、能连线、能保存原型阶段看起来什么都有。但等到真正对接业务数据问题就一个接一个冒出来了。第一个问题是节点类型高度定制化。业务里的节点不是一个简单的矩形加一行文字它需要内嵌一组动态表单、图标、状态徽标甚至要根据后端返回的 schema 渲染不同类型的输入控件。通用画布组件的节点可以扩展但是扩展一次要翻源码、重写渲染器成本极高而且版本升级一次就要重新适配一次。第二个问题是连线的业务校验。比如某些类型的节点不允许作为连线的目标某些连线只能从特定锚点出发。这些规则写在通用库外面可以拦住一部分但要做到“拖拽连线过程中实时高亮合法目标、置灰非法目标”就得深入库内部的拖拽事件流很多库根本不开放这个层面的定制能力。第三个问题是数据模型绑定太弱。业务需要画布上的每个节点对应后端的实体用户改完图要能同步回服务端。通用画布保存的是一套私有 JSON 结构每次导入导出都要做一层极痛苦的对象映射图稍微大一点字段对不齐就出诡异 Bug。所以结论很直接现成库很适合原型验证但一旦你的节点形态、校验逻辑、交互模式跟业务强绑定改到后期往往不如自己从头写。临界点大概出现在“你发现要改的地方已经超过库源码的三分之一”的时候。自研的好处是数据流完全可控代码量也不一定比一个重度封装的适配层大多少。1.2 MVP 范围与功能边界一页纸说清做什么、不做什么很多自研项目一上来就想着做大而全结果半年过去一个能用的版本都没有。diagram-design 的第一版我刻意把范围压得很小只有一个原则能跑通“编辑 → 保存 → 恢复”的闭环就算成功。第一版明确要做的是节点拖拽创建、选中、删除、复制、属性面板改样式和业务数据连线锚点连接、正交折线、选中删除、双击折线新增拐点画布滚轮缩放、拖拽平移、框选、快捷键数据JSON 导入导出、PNG 导出明确不做的是自动布局、多人协同、图层管理、模板市场表格、图表、时序图等专业化图形组件自动布局听起来很香但它是算法和交互细节的双重深坑一旦陷进去主线就推不动。协同则牵扯 CRDT 或 OT 算法放到第二期更合理。实践下来这个“短痛”策略非常有效MVP 花了两周半就成形后面大部分时间都在打磨交互和性能。1.3 架构核心思想单一数据源加投影式渲染diagram-design 的内部结构其实只有两层数据层和渲染层。数据层是一个 Store保存整张图的模型渲染层只是把模型“投影”到 Canvas 上本身不持有任何业务状态。这样做最大的收益是撤销重做很好写。任何操作都要先改模型改完模型再触发重绘渲染永远跟模型一致。想实现历史记录时只需要对模型变更做命令封装不需要去读画布状态。后续如果要加协同直接把 Store 里的变更同步出去就行渲染层完全不用动。整个交互流程可以概括成一条直线鼠标和键盘事件进入事件处理器处理器把意图翻译成对 Store 的命令调用Store 更新模型后通知渲染器重绘。这条链路保证了我不会在代码里到处散落状态排查问题的时候只需要问一个问题模型数据是对的还是错的。2. 技术选型实录Canvas、SVG 还是直接用现成库2.1 Canvas 和 SVG 之间我为什么押 Canvas技术选型是整个项目里最容易被低估的决策。很多人觉得画图工具用 SVG 天经地义因为它自带事件绑定、命中检测、文字选择实现起来非常直观。问题在于节点规模上来之后。SVG 的每个图形都是一个 DOM 节点200 个节点的图光 DOM 节点加上样式计算就够让交互变得黏手。我自己实测过一个 500 节点的图用 SVG 渲染拖拽时重排和重绘的代价非常明显尤其在低端笔记本上几乎不可用。Canvas 是命令式绘制500 个简单的矩形加文字画一遍也就几毫秒高频拖拽时表现稳定得多。当然 Canvas 的缺点也明显事件命中检测要自己写文字不能被用户直接选中高 DPI 下还有一堆模糊问题。但这些都可以用代码解决而 SVG 的性能瓶颈是物理规律代码救不回来。补充一个观点选 Canvas 不等于彻底放弃 SVG。diagram-design 导出静态图的时候反而优先走 SVG因为矢量结构可以被其他工具二次编辑。两条技术路线在这个项目里各干各的活不冲突。2.2 现成库选型时我踩过的三个坑这个项目一开始并不是“非自研不可”我在选型上花了不少时间最后是被三个坑劝退的。第一个坑是定制深度和开放度不成正比。有些库表面看起来扩展点很多真正深入进去发现核心渲染器是闭死的。想改选中框的样式、想改连线箭头的形状、想在节点上加一个自定义角标全都得翻到渲染逻辑里去改改完还要面对下次升级的合并冲突。第二个坑是依赖重量和学习成本。一个画布组件带一整套状态管理器和插件体系接进来之后你的技术栈就被它牵着走。团队里每个人都要学它的数据流和事件机制出了问题社区不一定能回答你的业务场景。而自研方案里这套逻辑都是自己的坏在哪、为什么坏心里有数。第三个坑是许可以及变更风险。有的开源库协议对商用场景有额外限制法务看了直接不批有的项目版本之间 API 说变就变线上图渲染结果跟历史版本不一致升级一次就要回归一次。综合下来自研虽然是笨办法但它是成本曲线最可控的那条路。2.3 渲染层结构为什么我把画布拆成两层实际开发的时候我没有用一张 Canvas 画所有内容而是叠了两层画布。底层专门画背景网格、连线、连线文字这部分在绝大多数交互里是不变的顶层画节点、选中框、正在拖拽的连线这部分每次鼠标移动都可能需要更新。分层的直接收益是重绘面积大幅缩小。拖拽节点时只需要重绘顶层底层的网格和连线原封不动。如果只有一张画布每次都要把网格重新画一遍几百节点的场景下白白浪费性能。坐标变换上也做了统一。两层画布共享同一个 viewport 对象里面只有三个字段x、y 和 scale。所有绘制都在 draw 函数开头用 ctx.translate 和 ctx.scale 一次性完成业务代码里全部使用“世界坐标”不感知屏幕缩放。这样一来逻辑层干净渲染层也容易维护。3. 从零实现一个图表设计器数据模型、事件流与核心算法3.1 核心数据模型Node、Edge、Graph 的设计数据模型是画布的地基diagram-design 里最稳定的部分就是这一层。我把它定义成 TypeScript 接口全项目共用同一套类型。interface GraphModel { id: string; nodes: Mapstring, NodeModel; edges: Mapstring, EdgeModel; viewport: ViewportModel; } interface NodeModel { id: string; type: string; // 节点类型标识 x: number; // 世界坐标左上角 y: number; width: number; height: number; zIndex: number; style: NodeStyle; // 展示样式 data: Recordstring, unknown; // 业务数据 } interface EdgeModel { id: string; source: string; // 源节点 id sourceAnchor: AnchorPosition; // 源锚点 target: string; // 目标节点 id targetAnchor: AnchorPosition; waypoints: Point[]; // 中间拐点 style: EdgeStyle; }节点集合用 Map 而不是数组原因很简单删除节点和查找节点的频率极高Map 的查找和删除都是 O(1)数组则需要线性扫描。节点位置字段统一用左上角坐标这一点看起来是小事实际上决定了后面所有锚点计算的口径。如果你一半代码用中心点、一半代码用左上角最后一定会在某个边缘 case 里错位。style 和 data 必须拆开。style 是纯展示信息改了只需要重绘data 是业务数据改了可能要同步后端。两者混在一起撤销重做和数据校验都会变得极其痛苦。3.2 命中检测与拖拽状态机先分清楚点、线、面Canvas 不提供事件命中所有点击交互都要自己做命中检测。diagram-design 的检测优先级是节点优先于锚点锚点优先于连线连线优先于空白。实现上是一个简单的 hitTest 函数。遍历节点时按 zIndex 倒序先取节点矩形做相交测试再遍历锚点锚点是个 8 像素半径的圆在 hover 状态可以适当放大提示最后遍历连线计算鼠标点到折线的最近距离小于 6 像素就算命中。拖拽逻辑用了一个非常朴素的状态机type DragState | idle | pressed | dragging-node | dragging-edge | panning; let dragState: DragState idle; let pendingDrag: { kind: node | pan; id?: string } | null null; let startX 0; let startY 0; function handleMouseDown(e: MouseEvent) { const worldPos viewport.screenToWorld(e.clientX, e.clientY); const hit hitTest(worldPos); if (hit?.type node) { pendingDrag { kind: node, id: hit.id }; dragState pressed; } else { pendingDrag { kind: pan }; dragState pressed; } startX e.clientX; startY e.clientY; } function handleMouseMove(e: MouseEvent) { if (dragState pressed) { const dx e.clientX - startX; const dy e.clientY - startY; if (Math.hypot(dx, dy) 3) return; // 阈值防止误触 dragState pendingDrag!.kind node ? dragging-node : panning; } // 根据状态更新节点坐标或 viewport }那个 3 像素阈值非常关键。鼠标按下后轻微抖动是常态如果没有阈值单击和拖拽永远分不开后续的 click 事件也会被污染。3.3 缩放、平移与坐标换算锚点计算不再漂移图形编辑器最重要的是坐标口径统一。diagram-design 里区分了世界坐标和屏幕坐标世界坐标是模型数据的真实位置屏幕坐标是用户看到的像素位置。两者通过 viewport 换算worldToScreen (wx: number, wy: number) ({ x: wx * viewport.scale viewport.x, y: wy * viewport.scale viewport.y, }); screenToWorld (sx: number, sy: number) ({ x: (sx - viewport.x) / viewport.scale, y: (sy - viewport.y) / viewport.scale, });滚轮缩放时实现“以鼠标位置为中心缩放”需要两步先记录鼠标落在哪个世界坐标上再把新视口偏移设置成让那个点保持不动。function zoomAt(screenX: number, screenY: number, factor: number) { const nextScale clamp(viewport.scale * factor, 0.2, 4); const wx (screenX - viewport.x) / viewport.scale; const wy (screenY - viewport.y) / viewport.scale; viewport.scale nextScale; viewport.x screenX - wx * nextScale; viewport.y screenY - wy * nextScale; }所有锚点计算、连线生成、命中检测都只认世界坐标屏幕像素只是在渲染和事件入口处才出现一次。这个约定守住之后缩放状态下连锚点拉线就再也没出现过漂移。3.4 正交连线的半自动折线算法全自动避障是伪需求画拓扑图最常用的连线是正交折线横平竖直看着清爽。diagram-design 用了一个“L 型优先 用户可编辑拐点”的方案没有做全局 A* 避障。自动生成折线时分三步先让线从源锚点沿锚点方向走固定长度比如 24px再从目标锚点反向走固定长度最后把中间剩下的距离用一条水平或垂直线段连起来。源锚点和目标锚点方向组合不同中间可能是 0 个、1 个、2 个拐点。为什么不做全自动避障因为视觉结果很难预期。拖一下节点线突然绕过好几个节点走一条“聪明但诡异”的路线用户反而会懵。折线可以手动编辑拐点双击线段的中间位置就能新增一个拐点拖动拐点调整路径。这种“半自动”方案在工程上代价小交互行为也可控。3.5 撤销重做命令模式与历史栈撤销重做是图形编辑器里最容易写烂的部分。diagram-design 用的是命令模式每一步操作都封装成一个 Command 对象对象里有 undo 和 redo 两个函数。移动节点时并不会在每一次 mousemove 里都生成一条命令而是在鼠标落下时记录起始坐标、鼠标松开时记录最终坐标整个拖拽过程只生成一条 MoveNodeCommand。连续移动多个节点时还会把这批节点合并到一条命令里避免撤销列表被刷爆。class MoveNodeCommand { constructor( private nodeId: string, private oldPos: Point, private newPos: Point ) {} undo (graph: GraphModel) setNodePos(graph, this.nodeId, this.oldPos); redo (graph: GraphModel) setNodePos(graph, this.nodeId, this.newPos); }历史栈有两根undoStack 和 redoStack。执行命令时推入 undoStack清空 redoStack撤销时从 undoStack 弹出来执行 undo再推入 redoStack。栈深度限制在 100 条防止长时间使用时内存无限增长。这个方案的关键原则是命令只记录变更的最小增量不记录整张图的快照。每次撤销都只更新受影响的元素然后重绘画布效率高很多。3.6 框选、快捷键与自动滚动框选实现比想象中简单。鼠标按下在空白处拖动时记录拖出的屏幕矩形然后遍历所有节点判断节点矩形和框选矩形是否相交。这里我特意选择在屏幕坐标下比较而不是转成世界坐标因为框选是视角操作跟缩放倍数无关。快捷键走一个集中管理的事件表删除、复制、粘贴、撤销、重做都放在这里。复制粘贴的逻辑是深拷贝选中的节点粘贴时整体向右下方偏移 16px保证能看到新节点和原节点的区别。自动滚动是个细节但影响很大。拖动节点到画布边缘时如果视口不跟着动用户就得停下来手动平移交互体验会断。实现上用一个定时器检查鼠标位置是否落在画布边缘 8% 的范围内是就按方向调整 viewport用 requestAnimationFrame 做平滑滚动。4. 渲染性能优化从 100 个节点到 500 个节点4.1 图层分离与脏标记性能优化的第一步不是调算法而是减少无意义的绘制。diagram-design 将画布拆成两层后拖拽节点时只需要重绘顶层节点层底层连线层基本不动。其次是脏标记。无论是一次命令执行还是连续多次属性修改都不立刻调用 draw而是把一个 dirty 标志置为 true然后在 requestAnimationFrame 回调里统一执行一次重绘。这样同一帧内发生的多次 mousemove 只会触发一次绘制CPU 占用大大下降。4.2 离屏缓存与 zIndex 排序节点如果只是简单矩形直接实时绘制更快没必要做缓存。但业务节点里有图标、文字、阴影多组元素每帧全部重画代价不小。针对这类复杂节点diagram-design 把渲染结果先画到一个离屏 Canvas 上然后每次绘制时用 drawImage 把它贴到主画布。移动节点时缓存可以复用只有节点尺寸或样式变化时才重新生成缓存。绘制顺序上先按 zIndex 排序把所有节点画完再把选中的节点放到最上层统一画一次高亮效果。这样高亮不会被其他节点遮挡视觉层次更清晰。4.3 高 DPI 屏幕的防模糊细节Canvas 在高分屏下默认会发糊因为画布物理像素比 CSS 像素多必须手动按 devicePixelRatio 缩放画布尺寸。function resizeCanvas( canvas: HTMLCanvasElement, cssWidth: number, cssHeight: number, dpr: number ) { canvas.width Math.round(cssWidth * dpr); canvas.height Math.round(cssHeight * dpr); canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d)!; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }我在开发时踩过一个坑从外接显示器拔掉后 dpr 变化画布没有重新 resize所有图形都糊掉。最终的解法是在 resize 事件里重新读取 window.devicePixelRatio变化时就重设画布尺寸并触发一次重绘。5. 数据持久化与图片导出5.1 JSON 导入导出版本化设计必须做画布的保存格式直接决定后续扩展的可能性。diagram-design 导出时用一个包装结构{ version: 1, graph: { id: graph-xxx, nodes: [...], edges: [...], viewport: { x: 0, y: 0, scale: 1 } } }version 字段一开始看起来多余但等你在后续版本里加了字段、改了结构就知道它多重要。旧数据导入时可以用 version 做分支迁移而不是直接解析失败。导入时还要做一层 schema 校验。节点 id、边 id、锚点类型、坐标数值必须合法只要有一项不对就拒绝导入并给出明确报错。开发期我还遇过 data 字段里藏了循环引用JSON.stringify 直接抛异常后来导出前统一做了一次去环处理。5.2 导出 PNG 与 SVG各自适用场景不同导出 PNG 时我通常会在导出用画布上先铺一层白色背景再 drawImage 原画布最后 toBlob 下载。不要嫌多这一步透明背景的 PNG 放到白底文档里经常看不清内容。function exportPNG(canvas: HTMLCanvasElement, scale 2) { const clone document.createElement(canvas); clone.width canvas.width * scale; clone.height canvas.height * scale; const ctx clone.getContext(2d)!; ctx.fillStyle #ffffff; ctx.fillRect(0, 0, clone.width, clone.height); ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, clone.width, clone.height); clone.toBlob((blob) { download(blob, diagram.png); }, image/png); }导出 SVG 则是基于数据模型做一次全新的矢量渲染不是把 Canvas 像素包进 SVG 容器。好处是矢量图可无限放大能继续在别的工具里编辑。代价是 SVG 渲染函数要单独维护一份跟 Canvas 渲染逻辑天然分离后续改样式时要记得同步两边。6. 开发期中遇到的典型问题与排查建议6.1 click 和 drag 混淆拖完节点总是多余触发一下点击症状是每次拖拽完节点属性面板都会跟着闪一下像被点击了一次。原因在于 click 事件在 mouseup 之后触发并不会因为你中途移动了鼠标就自动取消。解决办法是记录按下和松开时的坐标差超过 5px 就判定为拖拽用一个标志位在后续 click 回调里直接忽略或者彻底不用 click所有“点选”逻辑统一走 mouseup 处理。我选了后者逻辑更统一。6.2 鼠标拖出画布导致状态丢失症状是按住节点快速拖到浏览器窗口之外再松手节点会“粘”在鼠标上怎么点都松不开。原因很简单mouseup 事件绑定在 canvas 上鼠标离开 canvas 之后不会再触发。修复方式是把 mousemove 和 mouseup 都绑定到 document 上而不是 canvas 元素本身。同时要在拖拽开始时阻止默认行为否则拖动过程中浏览器会选中画布附近的文本体验非常割裂。6.3 撤销栈内存爆炸症状是长时间编辑后浏览器内存涨了几百兆。排查发现每条命令里都保存了一份整图快照图越大内存涨得越快。修复有两个方向一是命令只记录最小增量比如移动节点只存起始坐标和结束坐标二是给历史栈加长度上限超过 100 条就丢弃最旧的命令。连续同类操作一定要合并移动节点整个拖拽过程只产生一条命令而不是每个 mousemove 都记录一次。6.4 缩放后锚点连线的位置错位症状是放大两倍之后从锚点拉出的线端点跟鼠标位置差了半个身位。问题出在某处代码把屏幕坐标直接当世界坐标用了缩放越大偏差越大。修复方案是全局约定“数据层只认世界坐标”渲染层只做投影事件入口统一先用 screenToWorld 转换。另外我额外加了一组坐标换算的单元测试专门覆盖缩放、平移、旋转这些边角场景后续改代码时心里踏实很多。6.5 其他细节别存 Canvas 对象、窗口大小变化要防抖开发中还有一些零碎但很考验耐心的细节。比如不要在数据模型里保存 Canvas 实例或其他 DOM 对象模型应该永远是纯 JSON 可序列化的结构否则将来做协同或换渲染方式时会被这些脏对象卡住。窗口 resize 事件一定要做防抖。画布容器的尺寸变化后要重新计算 CSS 尺寸、重设 canvas 宽高、再触发一次全量重绘。如果不防抖拖动窗口边框时可能每帧触发几十次 resize直接卡死画面。个人实际体验下来diagram-design 这个项目最让我满意的不是 Canvas 绘制部分而是“数据模型 历史栈 事件流”这套地基。图形编辑器的复杂度七成都藏在交互状态和撤销语义里把这两块理顺了后面的扩展都是水磨工夫。后续我计划先补自动布局和只读预览模式再把导出能力包装成后端服务。如果你也在规划一个类似的画布工具建议一页纸写好功能边界先用最简单的模型跑通主流程再回头磨交互手感。