做前端这么多年,画图这件事一直是个绕不开的坎。不管是给客户演示业务流程,还是给团队画架构图,又是在产品里嵌入一张可交互的拓扑图,最后你都会发现所有人都在重复造同一个轮子。去年年中我们内部启动了一个叫 diagram-design 的项目,目标很直接:沉淀一套让任何前端都能快速接入的图表设计能力,把流程图、架构图、关系图这类最常见的图形场景统一封装。这篇文章就是我在整个项目推进过程中的完整复盘,包含方案选型、核心实现、性能调优和真正踩过的坑,希望能给正在做类似事情的同行提供参考。
这东西适合谁看?两类人:一类是准备自研或已经在自研图形编辑器的前端工程师,另一类是团队里负责基础组件库、需要为业务方提供可视化能力的人。如果你只是画原型图,那可以直接关掉;如果你需要在自己的产品里嵌入可交互图形,或者被"又要画图了"反复折磨过,这篇文章应该能帮你省掉不少探索时间。
1. 项目启动:diagram-design 要解决的核心问题
1.1 为什么没有直接选现成的图表库
立项之前我们做过一轮比较完整的调研。市面上的方案其实不少,主流的图编辑框架、可视化引擎、React 生态里的流程库,每个都有自己的生态。按道理说直接拿来用是成本最低的路径,但经过两周的试用和原型验证,我们发现有几个问题绕不过去。
第一是定制成本。我们的核心场景是业务架构图和数据流图,不是简单的节点连线。这类图对自定义节点、动态端口、条件样式要求很高,现成库虽然能用扩展机制做二次开发,但改到底层渲染逻辑的时候,学习成本和维护成本都会翻倍。第二是体积和依赖。好几个成熟方案都带着比较重的运行时和样式体系,对我们需要嵌入到多端小程序的场景来说,打包体积很难接受。第三,也是最重要的一点,团队需要一个长期可控的底层。业务方今天要组态图,明天要思维导图,后天要鱼骨图,如果底层不是自己的,每一次新需求的响应速度都会受制于上游。
所以最终的决定是:做一个轻量、框架无关的图形设计内核,只做最底层的模型管理和渲染调度,上层的交互和 UI 全部由业务方自己实现。diagram-design 这个项目就是在这个背景下立项的。这个定位听起来很保守,但恰恰是它让我们在后续半年里能够同时支撑六个不同业务场景而不需要频繁改内核。
1.2 功能边界先划清楚
立项之后我们做的第一件事不是写代码,而是把边界画清楚。一个图形编辑器的能力范围实在太大了,从节点拖拽、连线、框选、多选、撤销重做,到缩略图、小地图、网格对齐、自适应布局,全做出来可以写一本厚厚的书。我们的策略是"核心收敛、上层开放"。
核心层只做五件事:图数据模型的建立与维护、节点的渲染与更新、连线的路径计算、视口的变换(平移缩放)、以及事件系统的发布订阅。其他的,比如属性面板、工具栏、右键菜单,全部通过接口暴露给业务方。这个决策在后面帮了大忙,因为不同业务对 UI 的审美和交互习惯完全不一致,强制统一反而是灾难。比如有的业务方需要画布自带缩略图导航,有的完全不需要;有的喜欢双击进入节点编辑,有的习惯右侧属性面板。这些差异化需求如果塞进内核,代码会迅速腐化。
2. 底层架构与关键技术选型
2.1 渲染引擎:Canvas 还是 SVG
这是整个项目里争论最久的一个技术选型。我们最终选择了 Canvas 作为主渲染引擎。原因很务实:核心场景中单图节点数量经常到几千甚至上万,SVG 在这种量级下 DOM 节点会直接把浏览器拖垮。但这里要说明一下,我们不是全盘否定 SVG,而是在架构上预留了"双渲染器"的可能。
具体做法是定义了一个统一的 Renderer 接口,包含 render、update、destroy 三个方法,底层用 Canvas 实现了一套,未来如果某个场景需要 SVG 或者 WebGL,只需要再实现一个 Renderer 即可。Canvas 的缺点也不是没有,最大的痛点是可访问性差、事件命中需要自己做坐标换算。针对这个问题,我们实现了一套独立于渲染层的几何命中检测,基于节点包围盒和路径采样点来做拾取,实际用下来在高频交互场景下比 DOM 事件更可控,因为你完全掌握命中优先级和命中区域的判定逻辑,不受浏览器事件冒泡顺序的影响。
2.2 图数据模型怎么设计
数据模型是 diagram-design 的基石,这块设计得好不好,直接决定后面所有功能的开发效率。我们参照了图论的经典定义,把模型分成三层:Graph(图)、Node(节点)、Edge(边)。
Graph 层保存全局信息,包括 id、type、宽高、视口状态、样式主题等元数据。Node 层是核心,除了基础的位置 x、y,尺寸 width、height 外,我们还加了 data 字段用来挂载业务自定义数据,以及 ports 字段表示连接点。Edge 层记录 source 和 target 的引用,不直接存坐标,而是通过 sourcePort 和 targetPort 来确定连线的起点和终点,这样在节点移动时连线能自动更新。
这里有个重要的经验:所有坐标都采用逻辑坐标,而不是渲染像素坐标。视口变换(缩放和平移)单独维护一个 transform 对象,渲染时再把逻辑坐标乘上这套变换。这个设计让很多功能变得简单。适应画布只需要重置 transform 并做一次全图包围盒计算;保存和恢复画布状态也只需要序列化 transform。如果一开始就把坐标和渲染混在一起,后面做缩放、导出图片、跨端同步这些功能全部要返工。
2.3 事件系统的发布订阅设计
模型层和渲染层解耦之后,事件系统就成了连接两者的桥梁。我们的设计很朴素,就是一个 EventEmitter,所有模型变更都通过事件广播出去。graph 上任何一个节点的增删改都会触发对应的 change 事件,renderer 监听这些事件之后决定重绘哪一部分。
事件粒度这个细节值得说一说。一开始我们只设计了 graph:change 一个粗粒度事件,任何变化都触发全图重绘,小图无所谓,节点一多性能立刻崩。后来拆成了 node:added、node:removed、node:moved、edge:added、edge:removed、viewport:changed 这些细粒度事件,renderer 根据事件类型决定是增量重绘还是全量重绘。对于拖动节点这种高频事件,只更新受影响的节点和相连边,体验差距非常大。
2.4 布局算法与自动排版思路
自动布局这块我们最初想直接引入现成的图布局库,但考虑到核心层要轻量,最终选择自己实现一个简化版的层级布局。原理不复杂:对节点做拓扑排序,按层级分配到不同的列或者行,同层节点按顺序均匀分布,然后根据边的走向做交叉最小化调整。对于 DAG 图的场景,这个算法已经能覆盖绝大多数需求。
当然自动布局不是万能的。当节点数量超过两百、或者存在大量跨层级连线时,自动布局出来的效果很难让人满意,这时候需要允许业务方手动覆盖布局结果。我们的做法是:布局算法跑完之后,如果用户手动拖动过某个节点,就把该节点的坐标标记为 manual,后续再触发自动布局时跳过这些节点。这个"半自动"的思路在实践中非常实用,避免了"一布局全乱套"的尴尬。另外布局算法接收一个 direction 参数,支持从上到下、从左到右两种主方向,配合节点的组合分组能力,基本能满足业务方常见的架构图排布需求。
3. 实操过程:搭建 diagram-design 核心模块
3.1 项目结构与 Core API
落地的时候我们按功能拆成了几个独立模块,目录结构大致是这样的:
diagram-design/ ├── core/ # 图模型、事件 │ ├── graph.js │ ├── node.js │ ├── edge.js │ └── event-emitter.js ├── render/ # 渲染器 │ ├── canvas-renderer.js │ └── renderer-interface.js ├── layout/ # 布局算法 │ ├── dagre-layout.js │ └── manual-layout.js ├── interaction/ # 交互 │ ├── drag.js │ ├── connect.js │ └── viewport.js ├── theme/ # 主题 │ └── default-theme.js └── index.js核心 API 长这样,我们刻意保持得很少,方便记忆:
import { Graph, CanvasRenderer } from 'diagram-design' const graph = new Graph() graph.addNode({ id: 'node-1', x: 100, y: 100, width: 120, height: 48, type: 'rect', data: { label: '订单服务' } }) graph.addEdge({ id: 'edge-1', source: 'node-1', target: 'node-2', sourcePort: 'right', targetPort: 'left' }) const renderer = new CanvasRenderer({ container: document.getElementById('app'), graph }) renderer.render()graph 对象负责增删改查,操作后自动触发 change 事件,renderer 监听事件并执行重绘。这个思路本质上就是一个发布订阅模式,好处是模型和渲染完全解耦,单元测试可以只测 model 层,不需要任何 DOM 环境。
3.2 节点渲染与自定义节点
节点的渲染分两个层次:内置基础形状和自定义节点。基础形状就是 rect、circle、diamond 这类,用一个统一的 drawShape 函数根据节点 type 分发到具体绘制逻辑。自定义节点采用函数注入的方式,业务方传入一个 renderNode 函数,接收节点数据和当前的绘制上下文,自行完成绘制。
renderer.registerNodeType('service', (ctx, node, theme) => { // 绘制圆角矩形 ctx.beginPath() ctx.roundRect(node.x, node.y, node.width, node.height, 6) ctx.fillStyle = theme.nodeFill ctx.fill() // 绘制标题 ctx.fillStyle = theme.textColor ctx.font = '12px sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(node.data.label, node.x + node.width / 2, node.y + node.height / 2) })这里有个很容易踩的坑:Canvas 2D 的 textBaseline 默认是 alphabetic,很多人画文字总觉得位置偏了,其实是基线对齐的问题。我们的经验是统一设置 textBaseline = 'middle',再配合 textAlign = 'center',可以少调很多像素。另一个细节是 roundRect 方法在部分低版本浏览器不支持,生产环境建议准备一个 polyfill 或者自己画圆弧,别默认所有用户环境都是最新浏览器。
3.3 交互能力:拖拽、连线与视口变换
交互模块是整个项目里代码量最大的一块。拖拽的核心不难,就是监听 pointerdown、pointermove、pointerup 三个事件,在 pointermove 里根据位移更新节点坐标。但有几个细节非常重要。
第一,必须用 pointer 事件而不是 mouse 事件,这样天然支持触屏,不需要单独维护一套 touch 逻辑。第二,拖拽过程中要临时禁用文本选择,给画布容器加上 user-select: none,否则快速拖动时浏览器会出现选中状态。第三,节点移动之后要触发相关连线的重绘,这个通过 graph 内部的边缓存自动完成,只需要在节点变更事件里找到关联边并标记脏数据。
连线交互稍微复杂一点。从端口触发拖拽,拖拽过程中实时画一条临时曲线跟随鼠标,鼠标悬停在目标节点上时高亮目标端口,松开后调用 graph.addEdge。为了体验好,临时曲线每一帧都要重绘,性能敏感点在于不要触发全图刷新,而是只重绘 overlay 层。我们在 renderer 里单独画了一个 overlay canvas,专门处理这类临时绘制。
视口变换(平移和缩放)同样走 overlay 的思路。平移过程中先移动整个 canvas 的 transform,重绘时把逻辑坐标统一变换。缩放则固定以鼠标位置为锚点,这里有一段很经典的换算公式:
const scale = nextScale / currentScale const dx = mouseX - originX const dy = mouseY - originY originX = mouseX - dx * scale originY = mouseY - dy * scale currentScale = nextScale其中 originX、originY 是视口左上角的逻辑坐标。这个公式看似简单,但第一次实现的时候很容易搞反正负号,建议配合一个十字参考线来调试,比单纯 console.log 直观得多。
3.4 主题系统与样式管理
主题系统我们用了一个非常轻量的方案:一份全局默认主题对象加节点级样式覆盖。默认主题包含节点的填充色、描边色、文字颜色、字号,连线的颜色和线宽,以及网格背景的颜色和间距。节点级覆盖更简单,节点对象上如果有独立的 style 字段,渲染时用 Object.assign 合并默认主题和节点样式。
有个细节值得提:连线的样式不应该仅仅由线本身决定,还要考虑连接的两端。比如源节点处于高亮状态时,所有连出去的边都应该被高亮,这是业务里非常常见的联动诉求。我们的做法是渲染边之前先查两端的节点状态,再决定边的最终样式。这个逻辑放在 renderer 的 styleResolver 里,避免业务方在每条边上去手写条件判断。
提示:主题对象不要设计成深嵌套结构,尽量扁平化。层级越深,覆盖规则就越难写,后期维护成本成倍上升。我们最终把主题压到两层:theme 和 node.style,覆盖逻辑一目了然。
4. 性能优化与踩坑实录
4.1 大数据量节点的渲染卡顿排查
项目上线后第一个性能问题来自一张两千节点的数据流图。现象是拖动节点时明显掉帧,FPS 掉到 20 左右。排查过程一步步来:先确认问题不在事件层,pointermove 的频率正常;然后用 performance profile 看绘制耗时,定位到 fillText 占了接近 60% 的绘制时间。节点大多都有文字标签,两千个节点就是两千次 fillText,成本很高。
解决方案是分层渲染加脏矩形优化。我们把节点分成图形层和文字层两个 canvas,图形层只有拖拽结束或节点增删时才重绘,文字层在拖动期间可以直接跳过重绘。这个优化把拖拽帧率拉回到 55 以上,肉眼已经感知不到卡顿。之后还做了视口剔除,只渲染视口范围内的节点,实测五千节点的场景也能稳定在 30 帧以上。
还有一个容易被忽视的点:Canvas 的高 DPI 适配。如果不按 devicePixelRatio 放大画布的实际像素,高分屏上图形和文字会发虚,业务方第一反应就是"这库渲染质量不行"。适配方法很简单,canvas.width 设置为客户区宽度乘以 dpr,style.width 保持逻辑尺寸,然后统一调用 ctx.scale(dpr, dpr)。
4.2 连线路径规划的那些坑
连线路径规划是另一个让人头秃的模块。最初我们直接用贝塞尔曲线连接两个端口,效果在简单场景下还行,但节点一多、布局一复杂,曲线就会穿过其他节点,非常难看。后来改成了折线路径,利用 A* 算法在网格上寻路,然后再对路径做平滑处理。
A* 寻路的计算代价不小,尤其是网格分辨率高的时候。为了控制性能,我们限制寻路只发生在节点密集区域,并且缓存了路径结果,只要源节点、目标节点和障碍物集合不变,就不重新计算。还有一个经验:不要对整张图做全局寻路,只对端口方向不一致的边做拐角处理,同方向直连的边直接画直线,能省下大量计算。
路径计算还涉及正交边的美化问题。纯 A* 出来的折线往往会出现多余的拐弯,我们用了一个简单的后处理:遍历路径点,如果三个连续点可以合并成一条直线而不穿过障碍物,就删掉中间点。这个贪心策略在大多数场景下效果不错,代码也简单。
4.3 缩放平移的坐标系换算问题整理
坐标换算是新手最容易懵的地方。我们项目里有三种坐标系:逻辑坐标是图本身的坐标,视口坐标是经过 transform 变换的坐标,屏幕坐标是加上 canvas 在页面中的偏移。事件回调里拿到的是屏幕坐标,要转成逻辑坐标再交给模型层:
function screenToLogic(sx, sy) { const rect = canvas.getBoundingClientRect() const mx = sx - rect.left - originX const my = sy - rect.top - originY return { x: mx / currentScale, y: my / currentScale } }我在这上面栽过的跟头是:忘了考虑页面滚动导致的 rect 偏移,结果页面滚动后所有点击命中都错位。后来把所有监听事件都放在 canvas 容器上,并且每次都实时获取 getBoundingClientRect,才彻底解决。如果页面里还有其他 iframe 或者内嵌滚动容器,要特别注意事件目标,必要时用事件委托统一处理。
我把排查时最常遇到的几个问题整理成一张速查表,方便大家对照:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 拖拽时节点抖动 | 事件坐标没转成逻辑坐标 | 检查 screenToLogic 是否生效 |
| 滚动页面后点击错位 | 未考虑 canvas 的页面偏移 | 实时获取 getBoundingClientRect |
| 连线穿过其他节点 | 缺少避障路径规划 | 引入 A* 网格寻路或改用正交折线 |
| 节点多了以后文字模糊 | 高 DPI 屏幕未做缩放补偿 | 按 devicePixelRatio 放大 canvas 实际像素 |
| 缩放时中心点跑偏 | 锚点换算公式正负号错误 | 用十字参考线定位调试 |
| 撤销重做状态丢失 | 模型变更没有统一走命令模式 | 所有写操作通过 command 封装 |
5. 项目落地过程中的沉淀与建议
5.1 测试策略:模型层优先
diagram-design 的测试策略是模型层全覆盖、渲染层冒烟、交互层走关键路径。模型层的增删改查、序列化反序列化、事件触发,这些逻辑不依赖任何浏览器环境,用 Node 自带的 test runner 就能跑,性价比极高。渲染层我们不追求覆盖度,因为 Canvas 绘制难以断言像素,只做调用不报错级别的冒烟测试。交互层则选中拖拽、连线、缩放三个核心路径,用 Playwright 写端到端用例,确保日常迭代不破坏主流程。
这个策略执行下来,最直接的收益是重构成本大幅降低。有一段时间我们调整了内部数据结构的字段命名,模型层测试第一时间暴露了所有引用点,半小时就完成了迁移,业务方完全无感知。如果测试全压在视觉层,这种重构根本不敢做。
5.2 文档和示例的投入是值得的
这个项目的文档我们花了不少精力,后来证明是非常正确的投入。除了 API 参考,我们还维护了一个 playground 页面,里面放了十几个真实业务场景的示例:架构图、流程图、ER 图、组态图、小型拓扑图。业务方接入时几乎都是先打开 playground 找一个最接近的场景,复制代码改一改就上手了。对比之前先看文档再猜 API 的方式,接入时间至少缩短了一半。
有一点要提醒:示例代码不要写得太精炼。我们早期为了让示例看起来简洁,用了很多隐式的全局状态和魔法字符串,结果业务方复制过去根本跑不起来。后来所有示例都改成"完整可运行"的代码,虽然行数多了,但实际帮助大得多。
5.3 给后来者的三条建议
第一,不要一开始就做编辑器。很多人做图形库第一步就想做拖拽、框选、右键菜单,恨不得就是个迷你版的绘图工具。我的建议是先做渲染加数据模型的最小闭环,能画出静态图,再把交互逐个加上。交互层的复杂度远大于渲染层,过早开工只会反复推翻。
第二,数据结构要早点稳定下来。JSON schema 一旦定了,后面所有功能都会围绕它展开,改 schema 的代价是连锁性的。建议在第一天就写清楚节点、边、端口、视口这些核心类型,并且用 TypeScript 类型定义固化下来。类型定义本身也是最好的设计文档。
第三,性能优化要留到有真实数据之后再做。我们早期花了很多时间做各种优化,结果真实场景的数据量远低于预期,很多优化是白做的。等业务方真的接入了几千节点的图,再针对性地做分层渲染、视口剔除,反而更快。
最后再分享一个小经验:给 Canvas 做高 DPI 适配时,别只把 canvas.width 乘以 devicePixelRatio,记得把 style.width 保持为逻辑尺寸,然后在开始时统一调用 ctx.scale(dpr, dpr)。这个坑几乎每个做 Canvas 的人都会碰到一次,但网上的资料又常常一句话带过,我在这里多强调一遍,希望你能少走一次弯路。diagram-design 做到现在,最让我满意的反而不是某个炫酷功能,而是这套"核心收敛、上层开放"的架构让团队在半年内接入了六个业务场景,没有一次需要改动内核。如果你也在做类似的方向,不妨按这个思路试试。