diagram-design 这个名字在我们内部代码仓库里躺了好几周,期间一直没人愿意接手。原因说起来也不复杂:从目录名看,谁都知道这是一个画图的模块,但真正打开代码就会发现,它要处理的不是某一种固定的图形——流程图偶尔会冒出来,关系网络图也得支持,拖拽、缩放、连线、导出图片一个都不能少。一句话,凡是和"把数据变成看得懂的图形"沾边的需求,最后都会被塞进这个模块里。
我接手那个版本的时候,代码已经膨胀到八千多行,大部分逻辑都耦合在几段巨型渲染函数里,改一个节点的颜色都提心吊胆。这篇文章不打算讲一个成熟开源图表库的完整设计,而是把我在重构 diagram-design 时做的关键判断、踩过的坑、最终沉淀下来的架构方案记录成文。内容包括数据模型怎么拆、布局算法怎么选、Canvas 渲染性能如何优化、拖拽连线和缩放背后那套坐标转换怎么处理。如果你的工作和"自研图表/编辑器/拓扑图"沾边,或者正准备评估第三方面向流程图的组件不够用、打算自己做一套绘图内核,这份经验应该能帮你少走一些弯路。
1. 为什么一个绘图模块会让整个团队头疼
我先把这个模块当初面临的需求摊开来说。diagram-design 不是一个单一的"画图工具",它更像一个微型的绘图运行时。业务侧在同一套交互框架里,需要渲染树形组织架构、拓扑关系图、业务流程编排图,偶尔还要支持类似网格布局的工位图。节点本身也不是一个矩形加一行文字那么简单,有的节点带缩略图,有的带状态徽标,有的会随着数据变化动态变大变小;边又有直线、贝塞尔曲线、正交折线,还分实线、虚线、带箭头不带箭头。
这些需求单看每一条都不难,难的是它们会同时出现。如果在核心代码里为某一种图写死布局和绘制逻辑,下一种图就会骑虎难下。更麻烦的是交互:用户可能随时拖拽一个节点、从端口拖出一条连线、框选多个节点做批量操作。这意味着绘图内核不仅要会"画",还要能管理元素之间的空间关系、命中检测和状态变更。当这些逻辑杂糅在一起时,模块就变成一座没人敢碰的屎山。
1.1 diagram-design 的真实需求边界
动手重构前我做的第一件事,是把需求收敛成三条边界。
第一,输入和输出必须结构化。外部系统传入的是纯 JSON 数据——节点列表、边列表、样式字段,绘制后的结果也必须能重新导出成结构化 JSON,而不是一张画完就没法反查的位图。第二,图类型不能写死。树、图、网格、流程编排都必须能被表达为"节点 + 边 + 布局器"的组合,新图类型意味着新布局,而不是重写内核。第三,用户交互是核心。节点可拖拽、可选中、可增删;连线可以在画布上手工拉出来;缩放和平移是底线能力,不能把缩放做成整张图按 Ctrl + 加减号那种网页缩放形式。
这三点听起来朴素,但想清楚后,整个代码结构就清晰了很多:我需要一个干净的数据模型、一组可插拔的布局引擎、一套能快速绘制的渲染层,以及一个能处理坐标变换和命中的交互层。
1.2 直接买现成库 vs 自研的取舍逻辑
团队里当时也有过争论——市面上开源图表库不少,为什么还要自己维护一个 diagram-design?我的判断是三个字:定位不匹配。通用流程图库通常擅长树形或流程图表达,但对"任意图 + 强自定义渲染 + 深度交互定制"的组合几乎都支持得不够彻底。有的在关系图布局上很强,但节点自定义渲染往往要做很多 hack;有的架构优雅,但性能在几千节点时明显下滑;企业级的图形方案价格并不低,还得掂量授权成本。
自研的好处在于所有细节都可以控制:布局算法自己能调,渲染管线能针对业务做优化,交互行为能做成产品需要的样子。代价也很清楚:开发周期长,坑要自己踩。我当时给团队的建议是,如果只是画几十个节点的静态流程示意,完全没必要自研;但如果要做的是一款"以图形为核心产品形态"的东西,绘图内核必须握在自己手里。diagram-design 走的就是后一条路,与其说它是个工具模块,不如说它是一个要长期演进的产品底座。
2. 技术选型:Canvas、SVG 和 WebGL 不是三选一
很多人一提绘图,开口就问"用 Canvas 还是用 SVG"。这个问题本身就把思路带偏了。diagram-design 要支撑的节点规模从几十到几千都有,交互习惯又贴近"拖拽、缩放、连线"这种图形编辑器操作,单一技术栈其实很难覆盖全部诉求。
| 技术方案 | 适合场景 | 主要短板 | 对 diagram-design 的实际影响 |
|---|---|---|---|
| SVG | 元素少、交互多、样式需要 CSS 控制 | 元素数量大时 DOM 节点膨胀,重排开销高 | 适合做 tooltip、浮层、少量可响应的辅助元素 |
| Canvas 2D | 中等规模图形、频繁更新、逐帧绘制 | 需要自己管理命中检测和局部重绘 | 主力渲染方案,承载节点、边、临时交互图形 |
| WebGL | 万级以上的图形、需要 GPU 加速 | 开发成本高,文本渲染和交互命中都很麻烦 | 普通图表场景用不上,预留为未来方向即可 |
2.1 三种渲染方案的能力边界对比
SVG 的优点是很直观,每个图形就是一个 DOM 节点,绑事件、改 CSS、插动画都非常自然,十来个元素的场景里写起来行云流水。但缺点也很致命:当元素规模上升到几百个节点加几百条边,DOM 节点数轻松上千,页面的滚动、重绘、事件绑定都会变得迟滞。Canvas 则完全不同,它没有"元素"的概念,只有像素。每帧都是把当前数据重新画一遍,几千个节点只要绘制算法写得够快,性能依然能稳定在六十帧。缺点是一切交互都要自己算:鼠标点在哪里、点中了哪个节点、拖动的命中区域长什么样,都必须自己实现。
WebGL 的上限最高,但普通业务图表达不到需要用 GPU 去做几万个图元的级别。而且 WebGL 下文本渲染非常折腾——纯 WebGL 画字符串要么贴纹理,要么用 SDF 字形,开发成本不是一般的高。所以当时我们的结论是:不用迷信某个单一方案,而是按层级组合。Canvas 画主场景,SVG 当作辅助层,专门放一些变化不频繁、又需要 DOM 能力的浮层。这样既有 Canvas 的高性能,又保住了 SVG 在局部交互上的方便。
2.2 我们最终选择了 Canvas 主渲染 + SVG 辅助层
具体落地上,diagram-design 使用了一块根 Canvas 作为主绘图画布,然后在它上面叠了一块透明的 SVG 层。主画布负责节点、边、背景网格这些高频元素;SVG 层只负责 tooltip、临时选框、确认弹层的锚点这类偶尔才出现的元素。为什么不让 SVG 层参与主渲染?因为一旦把高频元素放进 DOM,性能问题立刻回来。那为什么不全部依赖 Canvas?因为 tooltip 这类浮层如果用 Canvas 画,每次鼠标移动都要重绘那块区域,还要自己处理层级和文本换行,纯属给自己找麻烦。
这里还涉及到一个初期就容易被忽略的细节:设备像素比。高分屏下 Canvas 如果直接用 CSS 像素尺寸创建,图形会发虚;正确做法是把 Canvas 的 buffer 尺寸设为 CSS 尺寸乘以 devicePixelRatio,再通过 transform 把所有绘制坐标对齐回 CSS 像素。这个细节如果不在一开始就设计好,后期想补救会非常痛苦,因为所有鼠标坐标换算都要跟着改。我在这块踩过的坑留到后面专门展开。
3. 数据模型先于绘制逻辑:图、树、网格的统一抽象
写图形模块最容易犯的错误,是一上来就写 drawNode、drawEdge。看起来很直接,但画着画着就会发现:布局算法要改节点位置,渲染层要读节点位置,交互层要改节点位置,三者如果都直接操作某个"画布图形对象",最后一定会因为职责不清而打架。diagram-design 重构后确立了一条铁律:数据模型是唯一事实来源,渲染只是数据的一种投影。
3.1 节点和边的最小数据结构
我们最终把节点和边的数据结构收敛成了下面这组 TypeScript 接口。它不负责怎么画,只负责描述"存在什么"以及"最基本的外观长什么样"。
// 节点数据:位置、尺寸、类型、业务属性 interface NodeData { id: string; type: string; // 节点渲染器类型,如 'rect' | 'image' | 'group' x: number; // 世界坐标,布局算法负责写入 y: number; width: number; height: number; attrs: Record<string, unknown>; // 业务属性,如名称、状态、缩略图 URL } // 边数据:通过节点 id 关联,而不是存坐标副本 interface EdgeData { id: string; source: string; // 起点节点 id target: string; // 终点节点 id type: 'straight' | 'curve' | 'orth'; attrs: Record<string, unknown>; } // 图模型:一个名称 + 节点集 + 边集 interface GraphData { name: string; nodes: NodeData[]; edges: EdgeData[]; }这里最关键的决策是:边只存 source/target 两个节点 id,不存自身坐标。起因是拖拽节点时所有关联边都要重绘,如果边组件里各自缓存了一份坐标副本,就要同步更新一大堆对象;反之,边只在渲染时根据两端节点的当前坐标现算路径,任何一次节点位移都会自然反映到边上。性能上的担心可以通过"版本号 + 路径缓存"来解决:节点坐标变化时递增一个版本号,边的路径缓存发现版本号不匹配再重新计算。
3.2 为什么布局必须是独立插件
我第一次设计时差点把布局写进渲染器,后来发现这是个很隐蔽的坑。布局的职责是"决定节点往哪儿摆",渲染的职责是"按照摆好的位置画出来",交互的职责是"让用户在已画好的位置上操作"。三者放在一个类里会互相拖累:一边改布局参数,不时会牵扯到没必要的全画布重绘。
所以 diagram-design 把布局抽象成了一组可插拔引擎,输入是 GraphData,输出是节点坐标的更新。核心约定很小:
interface LayoutEngine { readonly id: string; // 执行布局,直接修改 graph.nodes 中的坐标字段 execute(graph: GraphData, options: LayoutOptions): void; }只要实现这个接口,树形布局、力导向布局、层级布局都能作为插件注册进来。调用方只需要按图类型选择引擎,而渲染层完全不关心布局是哪个算法算出来的。这种设计带来的好处是,后续产品想支持一种全新的"扇形布局""环形布局",只需要新增一个文件,不需要动渲染和交互的任何代码。
4. 布局算法:把杂乱数据摆成看得懂的图形
如果说数据模型是骨架,布局算法就是图表库的灵魂。同一批数据,布局器不同,呈现出来的图形含义完全不同。diagram-design 分别实现了三种最常用的布局器,它们的实现思路和调参经验都值得单独说一说。
4.1 树形布局:递归宽度分配的细节
树形布局用于组织架构、目录结构这类有明确父子关系的场景。经典错误是简单地按深度排行、按顺序排列,结果发现同一层节点文字长度不同时,节点互相重叠,连线和文字糊成一片。
正确的做法是先自底向上递归计算每个子树的占位宽度。每个节点的占位宽度等于它自身宽度和所有子节点占位宽度之和两者中的较大者,再加上固定间距。然后按层分配 X 坐标:父节点的 X 坐标取所有子节点 X 坐标的中点,Y 坐标按深度方向累加层级高度。可以用一段伪代码来说明:
function layoutTreeNode(node) { if (!node.children.length) { node.width = node.selfWidth; return; } let totalWidth = 0; node.children.forEach((child) => { layoutTreeNode(child); totalWidth += child.width + H_GAP; }); node.width = Math.max(node.selfWidth, totalWidth - H_GAP); // X 坐标:让父节点位于所有子节点的正中间 node.x = (node.children[0].x + node.children[node.children.length - 1].x) / 2; }实际实现时还有一个容易被忽略的细节:如果某些节点的文本特别长,子节点摆出来可能比父节点还宽,导致父节点不在视觉中心。所以"占位宽度"必须统一以子树宽度为准,而不是以节点自身宽度为准。树形布局还有个衍生问题——同层节点如果宽度差异极大,整体图形会显得歪歪扭扭。我们最终的策略是,在同层中采用"居中对齐 + 最大宽度对齐"的组合方式,宁愿牺牲一点紧凑度,也要保证视觉上的稳定感。
4.2 力导向布局:物理模型的调参心得
关系网络图、拓扑图一般用力导向布局。它的核心思想是把整个图当作一个物理系统:任意两个节点之间存在斥力,每条边相当于一根弹簧,存在拉力。系统迭代若干轮后趋于稳定,节点自然散开。实现上大致是每轮迭代中计算合力,按合力移动节点位置,然后通过一个"温度"参数控制位移量,温度随迭代递减。
力导向布局最大的坑是调参。斥力太强,图会无限膨胀;弹簧太强,图会压成一团;迭代温度衰减太快,布局停在某个别扭的局部极小值上。我一般以这样的参数作为起点:斥力系数 k 与节点数量 n 相关,建议取 k = 面积期望值 / n;弹簧自然长度设为一个与节点平均尺寸相关的值,比如 80 到 120 像素;初始温度设 200,每轮乘以 0.95 左右的冷却系数。这里不用严格"降温",而是要观察图上节点是否还在大范围运动——如果某轮位移量还在百像素级别,说明温度对于当前图来说太高,需要快速降温;如果位移量已经在 1 像素以下,可以提前终止。
引擎跑力导向时,不要一次性迭代几百轮再画图,而是放到 requestAnimationFrame 里,每个渲染帧迭代一到两轮,让用户能看到节点从杂乱到稳定的渐进过程。这个"动画感"不仅是视觉加分项,也能避免页面在计算时长时间无响应。
4.3 层级布局:分层与交叉最小化
流程编排、状态机这类图通常有明确的方向性,适合用层级布局。实现上拆成三步:先对有向图做分层,保证所有边尽量从高层指向低层;再对同一层的节点排序,减少边的交叉;最后把每一层的节点按该层最大节点高度分配 Y 坐标,X 坐标按排序依次展开。
分层算法可以用拓扑排序或最长路径法。最长路径法的思路更直观:一个节点的层级等于它所有前驱节点层级的最大值加一。这样能保证每条有向边的两端层级差至少为 1,不会出现"边从下层指回上层"的尴尬。层内排序最实用的策略是重心法:反复计算每个节点在边连接中的所有邻居的层级平均值,按这个平均值对同层节点排序,迭代几轮后边的交叉数通常能肉眼可见地下降。
这个布局器在 diagram-design 里的应用场景很多。业务侧画流程图时,用户希望关键路径在视觉上是"往下流"的,因此层级布局需要支持用户指定起始节点集合,从这些节点开始计算分层,而不是全图所有节点一起参与。这类"半指定"的能力往往是布局器好不好用的分水岭。
5. 渲染系统的分层与性能优化实战
布局算法把坐标算好了,接下来就是渲染层的事。Canvas 的渲染和我们熟悉的 DOM 渲染完全不同:它没有 diff 概念,每帧都是重新绘制。所以优化思路集中在 "怎么尽量少画、怎么把重复画的东西缓存起来"。
5.1 三层画布架构与离屏预渲染
diagram-design 的主渲染最终分成了三层 Canvas 叠加,从下往上依次是:背景层(静态网格、缩略后的大形状)、元素层(节点和边)、交互层(拖拽中的临时连线、选框、悬停高亮)。
分层的主要意义不是好看,而是让重绘区域可控。背景层几乎不变,可以整个预渲染到一块离屏 Canvas 上;元素层是被拖拽、增删最频繁的区域;交互层只存在于操作过程中,操作结束就清空。每一层都拥有独立的缩放和平移矩阵,绘制时通过 drawImage 把背景层快速拷贝到主画布对应位置,比重新画几百条网格线快得多。
背景网格预渲染还有一个好处:缩放时如果网格太密,可以动态切换画网格或直接画纯色背景,避免把大量时间浪费在用户根本看不清的线条上。比如缩放级别小于某个阈值时,网格线间距小于 10 像素,视觉上已经糊成一片,此时就不再绘制网格,而是填充一个背景色。
5.2 四叉树空间索引:从 O(n) 到 O(log n)
Canvas 没有内置"找一找哪个节点在某个位置"的能力,所有命中检测都要自己写。早期实现非常简单粗暴:遍历所有节点,逐个判断坐标是否在节点矩形内。节点数量几百个时毫无压力,但到了两三千节点,拖拽时每帧要做一次遍历,操作体验就会肉眼可见地掉帧。
解决方案是给节点坐标建一个空间索引。在 2D 场景里最合适的数据结构是四叉树:把画布区域递归四分,每个叶子节点里存着若干图形对象,查询一个区域时只需要递归地找出与该区域相交的子空间,提取候选集。diagram-design 实现的空间索引在每次布局结束或节点位置变化后重建,节点数量上千时,命中检测平均从遍历几千次降低到几十次,体感差距非常大。
这里要注意:四叉树的查询结果是"候选集",不是"精确结果"。候选集中可能包含一个矩形与查询区域不相交但恰好被划分在同一节点中的对象,所以每次拿到候选集后仍然要做精确的几何检测。四叉树的价值是快速排除大量不相关的对象,而不是替代精确检测。
5.3 局部刷新 vs 全量重绘的取舍
刚开始做性能优化时,我一度沉迷于"脏矩形"方案——只重绘发生变化的区域。后来发现 Canvas 的脏矩形优化比想象中麻烦得多:一个节点被拖走,它原来所在的位置和新的位置都要重绘;如果一条边跨越大半个画布,脏矩形会大到基本相当于全屏重绘;多个元素同时变化时,多个脏矩形的合并逻辑很容易出 BUG,还会因为 clearRect 的边界问题留下残影。
所以 diagram-design 最终采用的策略很简单:小范围操作(拖拽单个节点、悬停高亮)用局部重绘;大规模变化(框选、缩放、布局动画)直接全量重绘。全量重绘听起来吓人,但只要单帧绘制代码够高效,3000 个节点加 3000 条边在现代浏览器上也能稳定跑到 50 帧以上。与其花大力气维护脏区计算,不如把重点放在绘制本身的效率上,比如减少不必要的 shadowBlur、避免在热路径里创建临时对象。
6. 交互层:用户拖拽、缩放、连线背后的坐标魔法
Canvas 交互层的一个现实是:浏览器只会告诉我们鼠标在画布上的像素位置,不会告诉我们在"世界坐标"中的位置。而用户的拖拽、缩放,本质都是在世界坐标里操作数据。这套坐标转换逻辑是整个交互系统的基础,不弄明白的话会出现很多玄学 bug。
6.1 世界坐标与屏幕坐标的变换矩阵
diagram-design 维护了一个简单的 transform 对象,表示当前的平移距离和缩放倍数:
interface ViewTransform { zoom: number; // 当前缩放比例 panX: number; // 平移偏移(世界坐标到屏幕坐标) panY: number; }从世界坐标到屏幕坐标的公式是screenX = worldX * zoom + panX。反过来,鼠标事件拿到的是屏幕坐标,要转换为世界坐标时就用worldX = (screenX - panX) / zoom。所有命中检测、拖拽位移计算都在世界坐标中完成,这样不管用户把画布缩放成什么样,数据模型里存储的坐标值始终是稳定的。
缩放这个操作还有一个极其容易忽略的细节:以鼠标位置为缩放锚点时,不能只改 zoom,还要同步调整 pan 值,否则画面会以画布左上角为原点缩放,一缩放鼠标下的内容就飞走了。调整公式用下面的方法计算:
// 以鼠标所在屏幕位置为锚点缩放 const newPanX = mouseScreenX - (mouseScreenX - oldPanX) * (newZoom / oldZoom); const newPanY = mouseScreenY - (mouseScreenY - oldPanY) * (newZoom / oldZoom);这个公式推导起来很简单,核心思想是"鼠标座位下的世界坐标点,在缩放前后要保持在同一个屏幕位置"。交互系统里所有缩放逻辑最后都统一走这个函数,避免散落在各个事件处理器里各自实现导致行为不一致。
6.2 命中检测与四叉树的配合
有了坐标系,还要回答一个核心问题:鼠标点下去时,到底点中了什么?diagram-design 的处理流程是先按优先级顺序检查:交互层临时元素最优先,其次是节点,再次是边。节点命中很简单,在世界坐标里用矩形包含判断就行。边的命中则要稍微动点脑筋——需要判断"点到线段的距离是否小于阈值",而不是判断点是否严格落在线条上。
因为用户不可能会精确点到 1 像素宽的线,我们通常给边命中设置 6 到 8 像素的容忍距离。实现时先将贝塞尔曲线按精度切成若干直线段采样,然后逐段计算点到线段的最短距离。线段很短时距离公式不会很昂贵,但如果在四叉树查询中就已经筛掉大部分候选边,那就只有少数几条边需要进入这一步计算。
6.3 拖拽连线的临时边设计
流程图类产品里,最常见的核心交互是从一个节点的端口拖出一条线,连接到另一个节点的端口。早期实现是直接修改数据模型,往 edges 里添加一条新边,结果发现拖拽过程中每次数据变化都会引起整个图的重算,伴随明显的卡顿和闪烁。
后期改用了我个人很满意的一个设计:拖拽过程中并不修改数据模型,而是在交互层创建一条"临时边"。这条临时边的起点固定为源节点端口,终点跟随鼠标位置实时更新,每帧只重绘交互层那一小块区域。mouseup 时判断终点是否落在某个目标节点的有效区域内——如果是,就把临时边写入数据模型,触发正常的增量渲染和布局稳定性校验;如果不是,直接丢弃临时边,什么也不发生。
这个设计之所以好用,是因为它把 "交互预览" 和 "数据提交" 彻底分开了。用户看到的是实时反馈,而底层数据始终保持干净。这个思路后来也扩展到框选、悬停高亮、节点旋转预览等交互上,都取得了很好的效果。
7. 踩坑实录:箭头的角度、缩放的模糊和文本重叠
技术方案讲清楚后,我想把这些年在 diagram-design 上实际踩过的三个最有代表性的坑单独拎出来。这些坑都不深奥,但都足够磨人,而且一旦踩中,排查起来往往要花掉大半个工作日。
7.1 箭头绘制:为什么箭头总不在线的端点上
画带箭头的边,最直接的实现是:先用 moveTo/lineTo 画一条从起点到终点的线段,然后在终点位置画一个三角形。单看这段逻辑似乎没有问题,但实际画出来会发现箭头尖端总是不在线段的真正终点上,看起来像是箭头和线条之间断开了一小截。
问题出在 lineCap。Canvas 里线段默认 lineCap 是 butt,也就是线条精确结束于终点坐标。但当我们设置了 lineWidth 和 lineCap: round 或者 square 时,线条的视觉末端会向外延伸约半个线宽。如果箭头的起点刚好从终点坐标处开始画,线帽就会侵入箭头三角区域,造成视觉上的错位。
正确做法是,画箭头的时候先计算出线的方向角angle = Math.atan2(end.y - start.y, end.x - start.x),线段稍微缩短一点,终点往回缩一个 margin(一般取箭头长度的三分之一),箭头三角形的顶点再精确地从新的终点开始计算。这样即使 lineCap 设置了圆头,也不会和箭头重叠。这个细节如果不仔细看,放大两倍才看得清楚,但一旦发现就非常影响图形严谨性。
7.2 高分屏下的文字模糊与像素比处理
Diagram 类产品几乎一定会画文字——节点标题、边标签、tooltip。第一次在高分屏笔记本上测试时,所有文字都像隔了一层毛玻璃,完全没法看。我当时的第一反应是"Canvas 字体渲染有问题",后来排查发现是 Canvas 物理尺寸和 CSS 尺寸不一致导致的。
修复方案前面提过:把 Canvas buffer 尺寸设为 CSS 尺寸乘以 window.devicePixelRatio,同时调用ctx.setTransform(dpr, 0, 0, dpr, 0, 0),之后所有绘制逻辑继续用 CSS 像素坐标。这个方案在开始时就要定好,因为一旦所有坐标计算都写成物理像素,后续每个交互的回调都要跟着换算,代码会非常啰嗦。还注意到 resize 事件里要同时处理 buffer 尺寸的重新设置和 transform 重设,否则窗口换屏或拖动到另一个 DPI 的显示器时问题会复发。
7.3 标签避让:优先级排序 + AABB 碰撞
力导向布局跑完,图是"散"开了,但节点的文字标签还是经常互相覆盖。市面上的成熟方案有基于碰撞力的模拟退火,也有基于 label 候选池的贪心算法,但老实说,对小团队自研来说,上来就做复杂避让很容易做过头。
我最终采用的是很朴素的优先级排序 + AABB 碰撞检测:先给每个标签按重要程度打一个分数,比如主节点标签权重高于边标签;然后按分数从高到低依次放置标签,放置前检查它是否与已放置标签的矩形相交,如果相交就尝试向四个方向偏移,找到一个不重叠的位置;实在找不到就放弃绘制这个标签,但保留一个"展开详情"的入口。
这套方案在节点密度不是极端高的场景下效果完全够用,代码量也就百来行。如果未来节点密度极高,再考虑四叉树加速碰撞查询。我的原则是:先用最简单但可用的方案,跑通后再根据实际痛点迭代。
8. 让图表库能成长的骨架:扩展点与主题系统
前面讲的都是基础能力,但一个绘图模块真正能在项目里长期活下来、跟着业务一起演进的,是它有没有留下合适的扩展点。diagram-design 重构后最重要的两个扩展机制,一个是渲染器注册表,一个是主题 token 化。
8.1 渲染器注册表与自定义节点
早期代码里有一个巨大的 switch-case,每个节点类型对应一段绘制代码。业务要加一种新节点时,就要往这个 switch-case 里加分支,久而久之函数越来越长,各种分支间还互相影响,改一处崩三处。
重构后我们改成注册表模式:每种节点渲染器是一个独立模块,注册时声明自己处理的节点类型和绘制范围。渲染层拿到节点数据后,根据 node.type 从注册表取对应渲染器,调用它的绘制方法。新增节点类型时只需要新建一个文件,实现统一的渲染器接口,然后在入口处注册,完全不需要改动渲染核心。
interface NodeRenderer { // 计算节点的占位盒,供布局和命中检测使用 getBBox(node: NodeData): { width: number; height: number }; // 在指定上下文绘制节点 draw(ctx: CanvasRenderingContext2D, node: NodeData, theme: Theme): void; } const registry = new Map<string, NodeRenderer>(); registry.set('rect', rectRenderer); registry.set('image', imageRenderer);为了不破坏核心渲染循环的性能,注册表在查表时用 Map 而不是数组遍历,而且渲染器对象在注册后不可变,避免运行时反复校验类型。这个机制推广到边渲染器后,业务侧几乎每个月都能新增一种图形,而核心渲染代码已经很久没动过了。
8.2 主题 token 化与样式治理
绘图模块的样式治理,是另一个容易在后期付出惨痛代价的问题。最开始的版本里,节点背景色、边框线宽、文字大小直接硬编码在渲染函数里。产品方说要支持"深色模式"时,我打开代码才发现,光是一个节点的绘制函数里就有十几处颜色字面量,逐个替换的活做了整整两天,还得担心漏网之鱼。
后来我们确立了主题 token 体系:所有颜色、线宽、字号、间距、圆角半径都从主题对象中读取,渲染函数里不允许出现任何硬编码的视觉常量。主题 token 的命名遵循语义化原则,比如node.fill、edge.stroke、text.label.fontSize,而不是red、2px。这样一套深色主题就是一份完整 token 映射,切换主题时只需要替换主题对象,然后触发全量重绘即可。
主题对象本身还支持插件覆盖。业务侧如果某个节点类型需要特殊配色,可以在注册渲染器时额外传入一个局部 token 覆盖层。很多时候用户要的不是整个主题换掉,只是想让一类特殊节点更醒目,这个机制能很好地平衡全局统一和局部定制。
最后,关于 diagram-design 这个模块的演进,我自己感触最深的一点,是它彻底改变了我对"绘图"这件事的看法。以前我以为最大的难点会落在某个算法或者某个绘制 API 上,实际上真正决定这个模块生死的是边界划分:数据、布局、渲染、交互各管一段,互不越界。如果你也在维护一个类似的图形模块,建议从一开始就把"什么是数据、什么是外观、什么是操作"这三层分清楚。后面不管加多少种图形、接多少种交互,都会轻松很多。