去年年中我给自己定了一个有点疯狂的目标:不依赖任何现成的图形编辑组件,从零写一个叫 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: Map<string, NodeModel>; edges: Map<string, EdgeModel>; viewport: ViewportModel; } interface NodeModel { id: string; type: string; // 节点类型标识 x: number; // 世界坐标左上角 y: number; width: number; height: number; zIndex: number; style: NodeStyle; // 展示样式 data: Record<string, 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 绘制部分,而是“数据模型 + 历史栈 + 事件流”这套地基。图形编辑器的复杂度七成都藏在交互状态和撤销语义里,把这两块理顺了,后面的扩展都是水磨工夫。后续我计划先补自动布局和只读预览模式,再把导出能力包装成后端服务。如果你也在规划一个类似的画布工具,建议一页纸写好功能边界,先用最简单的模型跑通主流程,再回头磨交互手感。