1. 从零搭建 diagram-design:一个轻量级图表设计工具的技术选型与架构拆解第一次看到 diagram-design 这个项目名的时候,我脑子里蹦出来的第一个问题是:市面上已经有那么多成熟的图表工具了,为什么还要自己造一个?后来在实际动手做的过程中才慢慢想明白,通用工具解决的是"大多数人画大多数图"的问题,而 diagram-design 要解决的是"特定场景下快速产出结构清晰、风格统一的图表"这件事。这两者的差别,就像连锁快餐和私房菜的区别——前者什么都有,后者只做几道拿手菜,但每一道都做到位。这个项目本质上是一个面向开发者和技术写作者的图表设计工具,核心目标是让用户用最少的操作步骤,把脑子里的架构思路、流程逻辑、数据关系转化成可视化的图表。它适合谁用?我总结了三类人:第一类是经常写技术文档的工程师,需要快速画架构图和流程图;第二类是做技术分享的讲师,需要制作风格统一的演示图表;第三类是产品经理,需要把业务流程可视化后跟团队对齐。如果你属于这三类人中的任何一类,这个项目值得你花时间了解一下。我打算从技术选型、核心架构、渲染引擎、交互设计、样式系统、导出方案这几个维度,把 diagram-design 的完整实现思路拆开来讲。不是那种"第一步安装依赖、第二步运行命令"的流水账教程,而是把每个关键决策背后的"为什么"讲清楚。你看完之后,不仅能复现一个类似的工具,更重要的是能理解一个图表设计工具从想法到落地需要经历哪些关键节点。2. 技术选型:为什么最终选了 Canvas 而不是 SVG 或 DOM2.1 三种渲染方案的对比与取舍逻辑做图表工具,第一个绕不开的决策就是渲染方案。主流选择有三个:基于 DOM 的绝对定位、SVG 矢量渲染、Canvas 位图渲染。我一开始用的是 DOM 方案,因为最直观——每个节点就是一个 div,用 CSS 控制位置和样式,拖拽用鼠标事件就能搞定。但很快遇到了性能瓶颈:当图表节点超过 200 个的时候,拖拽开始明显卡顿,因为每次移动都要触发大量 DOM 的重排和重绘。换成 SVG 之后情况好了很多,矢量渲染天然适合图表这种几何图形密集的场景,而且 SVG 的每个元素都是 DOM 节点,事件绑定和样式控制都很方便。但 SVG 的问题在于,当节点数量继续增长到 500 以上时,浏览器的渲染压力依然很大,尤其是涉及复杂路径和渐变填充的时候。最终我选择了 Canvas 方案。Canvas 的优势在于它是立即模式渲染,所有绘制指令直接作用于像素缓冲区,没有 DOM 树的开销。实测下来,即使图表节点超过 1000 个,拖拽和缩放依然能保持 60fps 的流畅度。代价是事件处理需要自己实现命中检测,样式控制也不如 CSS 那么灵活,但这些都可以通过合理的架构设计来弥补。渲染方案性能上限事件处理样式控制导出质量适用场景DOM 绝对定位约 200 节点简单极灵活依赖截图简单流程图SVG 矢量渲染约 500 节点简单灵活矢量无损中等复杂度图表Canvas 位图渲染1000+ 节点需自实现需自实现需处理 DPR复杂图表工具2.2 Canvas 方案下的事件系统设计思路选了 Canvas 之后,第一个要解决的问题就是事件处理。DOM 和 SVG 里,你给每个元素绑定 click 事件就行了,浏览器帮你做命中检测。但 Canvas 是一整块画布,你需要自己判断鼠标点在了哪个图形上。我的做法是维护一个图形列表,每个图形对象包含它的几何描述(矩形、圆形、路径等)和包围盒信息。鼠标事件触发时,从后往前遍历图形列表(因为后绘制的图形在上层),用几何算法判断点是否在图形内部。对于矩形和圆形,判断很简单;对于复杂路径,用射线法或者包围盒加精确判断的两级检测来优化性能。这里有个容易踩的坑:Canvas 在高分屏上的坐标换算。如果你的 canvas 元素 CSS 尺寸是 800x600,但实际像素尺寸也是 800x600,在 Retina 屏幕上就会模糊。正确的做法是把 canvas 的 width 和 height 属性设置为 CSS 尺寸乘以 devicePixelRatio,然后用 ctx.scale() 缩放绘图上下文。但这样一来,鼠标事件的坐标也需要乘以 devicePixelRatio 才能和绘图坐标对齐。这个换算逻辑我封装成了一个工具函数,所有涉及坐标的地方都统一调用,避免散落在各处导致 bug。2.3 状态管理方案的轻量化选择图表工具的状态比一般应用要复杂:有图形数据(节点、连线)、有视图状态(缩放比例、平移偏移)、有交互状态(选中了哪些元素、正在拖拽哪个节点)、还有历史记录(撤销重做)。我一开始想用 Redux 这类成熟的状态管理库,但评估后发现有点杀鸡用牛角。最终我采用了一个轻量级的发布订阅模式:核心状态放在一个 Store 对象里,任何修改都通过 dispatch 方法触发,Store 内部对比新旧状态后通知订阅者。订阅者可以是渲染引擎(负责重绘)、属性面板(负责更新表单值)、图层面板(负责更新列表)。这种方案的好处是依赖少、可控性强,整个状态管理模块的代码量不到 300 行,维护起来很轻松。提示:状态管理不要过度设计。图表工具的状态虽然多,但关联关系相对清晰,一个简单的发布订阅模式足够应对。引入大型状态管理库反而会增加理解成本和打包体积。3. 图形系统的核心抽象:节点、连线与图元的关系建模3.1 节点模型的设计:从简单矩形到可扩展图元图表工具的核心是图形系统。我把所有可绘制的东西抽象成三个层次:图元(Shape)、节点(Node)、连线(Edge)。图元是最底层的几何描述,比如矩形、圆形、多边形、路径;节点是图元的容器,一个节点可以包含多个图元,比如一个带图标的矩形节点;连线是连接两个节点的有向或无向边。节点模型的设计直接决定了工具的扩展性。我见过一些图表工具,节点类型是硬编码的——矩形节点、圆形节点、菱形节点各写一套逻辑。这种做法的好处是简单直接,但缺点是每增加一种节点类型就要改一遍代码。我采用的是组合模式:节点本身只负责位置、尺寸、旋转角度这些通用属性,具体长什么样由它包含的图元列表决定。举个例子,一个"服务节点"可能由一个圆角矩形图元、一个图标图元、一个文本图元组成。当用户拖动这个节点时,节点更新自己的位置,然后通知所有子图元重新计算各自的绘制坐标。这种设计让新增节点类型变得非常简单——只需要定义一个新的图元组合,不需要改动核心逻辑。3.2 连线路由算法:直线、折线与曲线的自动选择连线看起来简单,实际上是最容易出问题的地方。两个节点之间的连线,如果只是简单地从中心点画一条直线,当节点移动时线会穿过节点本身,视觉上很混乱。我实现了三种路由策略,根据节点位置关系自动选择。直线路由适用于两个节点之间没有遮挡的情况,直接连接两个最近的边缘点。折线路由适用于需要绕开障碍的场景,经典的实现是正交路由——线只走水平或垂直方向,拐角处用圆角过渡。曲线路由用贝塞尔曲线连接两个节点,视觉上最柔和,适合表达非层级关系。自动选择策略的逻辑是这样的:先计算两个节点的包围盒,如果包围盒在水平或垂直方向上有重叠,用直线;如果没有重叠但中间有其他节点,用折线;如果两个节点距离较远且中间没有障碍,用曲线。这个策略不是完美的,但覆盖了 80% 的常见场景,剩下的 20% 用户可以通过手动调整连线的控制点来微调。3.3 锚点系统:让连线始终连接在合理的位置锚点是节点上预定义的连接位置。一个矩形节点通常有 8 个锚点:上下左右四个边的中点,加上四个角。当用户从节点 A 拖拽连线到节点 B 时,系统会自动选择距离最近的锚点对。锚点系统的关键在于动态计算。节点的位置和尺寸会变化,锚点的坐标也必须跟着变。我的做法是在节点模型中存储锚点的相对位置(比如"上边中点"表示为 {x: 0.5, y: 0}),渲染和命中检测时再根据节点的实际包围盒换算成绝对坐标。这样无论节点怎么缩放旋转,锚点始终在正确的位置上。还有一个细节:当多个连线连接到同一个锚点时,需要做偏移处理,否则所有线都叠在一起。我的方案是给每个锚点维护一个连接计数,第 n 个连接在锚点法线方向上偏移 n 乘以一个固定间距。这个间距通常是连线宽度的 2 到 3 倍,视觉上刚好能区分开。4. 交互设计:拖拽、缩放、框选背后的数学与工程细节4.1 拖拽交互的完整事件链路拖拽是图表工具最高频的交互。一个完整的拖拽过程涉及 mousedown、mousemove、mouseup 三个事件,但实际实现中要考虑的细节远不止这些。mousedown 触发时,首先要做命中检测,判断用户点在了哪个元素上。如果点在了节点上,记录起始位置和节点的初始坐标;如果点在了空白处,进入框选模式。这里有个细节:如果用户按住的是节点的边缘而不是内部,应该进入缩放模式而不是拖拽模式。所以命中检测需要区分"内部区域"和"边缘区域",边缘区域的宽度通常是 6 到 8 个像素。mousemove 触发时,计算鼠标的位移量,更新节点的位置。但这里有个性能优化点:如果每次 mousemove 都触发全量重绘,在节点多的时候会卡。我的做法是维护一个"脏区域"列表,只重绘受影响的区域。Canvas 的 clip 方法可以实现局部重绘,实测能提升 3 到 5 倍的性能。mouseup 触发时,结束拖拽状态,把这次操作记录到历史栈中。历史记录不能记录每一个 mousemove 的中间状态,否则撤销一次要按几百下。正确的做法是在 mousedown 时记录初始状态,mouseup 时记录最终状态,中间过程不记录。4.2 缩放与平移:视图变换矩阵的推导与应用图表的缩放和平移本质上是对视图做仿射变换。我用一个 3x3 的变换矩阵来表示当前视图状态,矩阵包含缩放因子、平移偏移和旋转角度。所有绘制操作前,先把变换矩阵应用到绘图上下文上,这样图形坐标不需要改变,改变的是整个画布的坐标系。缩放的中心点选择很关键。如果以画布左上角为中心缩放,用户会觉得图形在"跑"。正确的做法是以鼠标位置为中心缩放,这样鼠标指向的那个点在缩放前后保持不动。实现方法是:先计算鼠标在图形坐标系中的位置,缩放后重新计算这个点应该在的屏幕位置,两者的差值就是需要补偿的平移量。平移相对简单,但要注意边界处理。如果用户把图表拖出了可视区域,应该有一个"回弹"或者"限制"机制。我采用的是软限制:允许拖出一定范围,但超出后拖拽阻力逐渐增大,给用户一种"拉不动了"的感觉,而不是硬生生地卡住。4.3 框选与多选:从矩形碰撞检测到选择集管理框选功能看起来简单,实现起来有不少细节。用户按下鼠标后拖出一个矩形,所有与这个矩形相交的节点都被选中。碰撞检测用包围盒相交判断就够了,不需要精确的几何相交。选择集的管理有几个要点。第一,选择集是有序的,最后选中的元素在渲染时应该显示在最上层。第二,选择集变化时要触发属性面板更新,如果选中的是多个不同类型的节点,属性面板应该显示它们的公共属性。第三,选择集要支持反选、全选、按类型筛选等操作,这些通过快捷键和右键菜单实现。还有一个容易被忽略的点:框选时如果用户按住了 Shift 键,应该是"追加选择"而不是"替换选择"。这个交互细节在大多数设计工具里都是标配,但自己实现的时候很容易忘。5. 样式系统:让图表既好看又一致的配色与排版方案5.1 主题系统的设计:从硬编码颜色到可切换主题图表工具如果不做主题系统,最后一定会变成"每个节点颜色都不一样"的灾难现场。我在项目初期就引入了主题概念:所有颜色、字体、间距、圆角半径等视觉参数都从主题对象中读取,不在代码里硬编码。主题对象的结构是这样的:包含一个调色板(palette),定义了主色、辅助色、成功色、警告色、错误色等语义化颜色;包含一套排版规则(typography),定义了标题、正文、标注的字号和字重;还包含一组图形参数(shape),定义了圆角半径、边框宽度、阴影偏移等。切换主题时,只需要替换主题对象,然后触发全量重绘。实测下来,一个包含 500 个节点的图表,主题切换的重绘时间在 50ms 以内,用户感知不到延迟。5.2 配色方案的自动生成逻辑手动给每个节点选颜色太累了,我实现了一套自动配色逻辑。核心思路是基于 HSL 色彩空间做色相旋转:先确定一个基准色相,然后根据节点的类型或分组,在色相环上均匀取色。具体算法是这样的:假设有 n 种节点类型,基准色相为 H,饱和度和亮度固定,那么第 i 种类型的色相就是 H + (360 / n) * i。这样生成的配色方案在视觉上既有区分度又保持和谐。如果 n 比较大(超过 8 种),色相环上的颜色会太接近,这时候改为调整饱和度和亮度来增加区分度。还有一个细节:连线颜色不应该和节点颜色冲突。我的做法是连线统一使用中性灰,透明度根据连线的重要程度调整。重要的连线透明度高一些,次要的连线透明度低一些,形成视觉层次。5.3 文本排版:自动换行、对齐与溢出处理节点里的文本排版是个麻烦事。文本太长会溢出节点边界,太短又显得空旷。我实现了自动换行算法:根据节点的可用宽度和字体大小,计算每行能放多少个字符,然后按词边界切分文本。中文和英文的换行规则不一样。英文按空格切分单词,中文可以在任意字符处换行(除了标点符号前)。我写了一个简单的分词器,根据字符的 Unicode 范围判断是中文还是英文,然后应用不同的换行策略。文本对齐支持左对齐、居中、右对齐三种模式。垂直方向也支持顶部对齐、居中、底部对齐。这些对齐方式在节点尺寸变化时需要重新计算文本位置,所以我把文本布局逻辑封装成了一个独立的函数,输入是文本内容、可用区域、对齐方式,输出是每一行的绘制坐标。注意:文本测量在 Canvas 中是通过 measureText 方法实现的,但这个方法的性能开销不小。如果每次重绘都测量所有文本,节点多的时候会明显卡顿。我的优化方案是缓存测量结果,只有文本内容或字体变化时才重新测量。6. 导出与持久化:从画布到 PNG、SVG 和 JSON 的完整链路6.1 PNG 导出的清晰度问题与解决方案Canvas 导出 PNG 最常遇到的问题就是模糊。原因很简单:Canvas 的默认像素尺寸是 CSS 尺寸,在导出时如果直接调用 toDataURL,得到的图片分辨率就是屏幕分辨率,放到文档里一放大就糊了。解决方案是导出一个高分辨率的离屏 Canvas。具体做法是创建一个新的 Canvas 元素,尺寸设置为目标导出尺寸(比如 2 倍或 3 倍于原始尺寸),然后把绘图上下文缩放相应的倍数,再重新执行一遍绘制逻辑。这样得到的图片在放大后依然清晰。还有一个细节:导出时应该只包含图表内容,不包含网格背景、选中框、锚点这些辅助元素。我的做法是在渲染引擎中维护一个"导出模式"标志,导出时跳过辅助元素的绘制。6.2 SVG 导出的矢量重建思路PNG 是位图,放大有限制。如果需要无损缩放,就得导出 SVG。但 Canvas 的绘制指令不能直接转成 SVG,需要重新构建 SVG 的 DOM 树。我的做法是遍历图形列表,根据每个图形的类型生成对应的 SVG 元素。矩形生成 rect,圆形生成 circle,路径生成 path,文本生成 text。样式属性从主题对象中读取,转换成 SVG 的 presentation attribute 或内联样式。这个过程中最麻烦的是文本。Canvas 的文本换行是手动计算的,SVG 的文本换行需要依赖 foreignObject 或者手动插入 tspan。我选择了 tspan 方案,把每一行文本作为一个 tspan 元素,通过 dy 属性控制行间距。这样导出的 SVG 在大多数浏览器和设计工具中都能正确显示。6.3 JSON 序列化:图表数据的版本兼容与迁移图表的持久化格式我选了 JSON,因为可读性好、易于调试、跨平台兼容。JSON 结构包含三个顶层字段:version 表示数据格式版本,nodes 是节点数组,edges 是连线数组。版本字段很重要。工具在迭代过程中,数据结构难免会变化。有了版本号,加载旧数据时可以执行迁移逻辑。比如 v1 版本的节点没有 rotation 字段,加载时自动补上默认值 0。这种向前兼容的设计让用户不用担心工具升级后旧文件打不开。序列化时要注意循环引用的问题。节点和连线之间是双向引用的——节点知道它连接了哪些边,边知道它连接了哪些节点。直接 JSON.stringify 会栈溢出。我的做法是序列化时只保留 ID 引用,反序列化时再根据 ID 重建引用关系。导出格式适用场景优点缺点PNG文档插图、演示文稿兼容性好、文件小放大模糊、不可编辑SVG网页嵌入、矢量编辑无损缩放、可编辑文件较大、字体依赖JSON数据备份、团队协作可读性好、可版本控制需要工具才能查看7. 实操中踩过的坑与性能优化经验7.1 高频重绘导致的卡顿:从全量重绘到脏矩形更新项目做到中期,当图表节点超过 300 个的时候,拖拽开始明显掉帧。用性能面板一分析,发现 90% 的时间花在了重绘上。每次 mousemove 都触发全量重绘,300 个节点加上连线,一帧要画上千个图形。优化方案是脏矩形更新。核心思路是只重绘发生变化的区域。拖拽一个节点时,受影响的区域是这个节点的旧位置和新位置的并集,加上与这个节点相连的所有连线的包围盒。其他区域的内容不变,不需要重绘。实现上,我在渲染引擎中维护一个 dirtyRects 数组。任何修改操作都会往这个数组里添加受影响的矩形区域。渲染时,先清除这些区域,然后设置 clip 路径,只在这些区域内绘制。实测下来,拖拽场景的性能提升了 5 倍以上。7.2 内存泄漏的排查:事件监听与对象引用的清理Canvas 应用的内存泄漏往往比较隐蔽,因为不像 DOM 那样有明显的节点树。我遇到过一次内存持续增长的问题,排查了很久才发现是事件监听没有正确移除。具体场景是这样的:每次打开属性面板时,都会给面板上的输入框绑定 change 事件。关闭面板时,面板的 DOM 被移除了,但事件监听还挂在输入框上,输入框又被闭包引用着,导致整条引用链都无法被垃圾回收。修复方案是在组件销毁时显式移除所有事件监听。我封装了一个 EventManager 类,所有事件绑定都通过它来注册,销毁时调用它的 dispose 方法统一清理。这个习惯养成之后,内存泄漏的问题基本杜绝了。7.3 撤销重做的实现细节:命令模式与状态快照的取舍撤销重做是图表工具的标配功能,但实现方式有两种:命令模式和状态快照。命令模式记录每个操作(移动节点、修改颜色、删除连线),撤销时执行反向操作;状态快照记录每次操作后的完整状态,撤销时直接恢复到上一个快照。我最终选择了状态快照方案,原因是实现简单、不容易出错。命令模式虽然内存占用小,但每种操作都要写正向和反向两套逻辑,容易漏掉边界情况。状态快照只需要在每次操作后深拷贝一份状态存入历史栈,撤销时直接替换当前状态。当然,状态快照的内存开销是个问题。我的优化是限制历史栈的长度(默认 50 步),并且对快照做压缩——只存储与上一个快照的差异部分。这样即使图表很大,历史记录的内存占用也在可控范围内。7.4 跨浏览器兼容性:那些只有实测才能发现的差异Canvas 的 API 在主流浏览器上基本一致,但细节上仍有差异。我遇到过的几个坑:Safari 上 measureText 返回的宽度和 Chrome 有 1 到 2 像素的偏差,导致文本换行位置不一致;Firefox 上某些情况下 clearRect 不能完全清除阴影效果,需要额外调用一次;移动端浏览器上 touch 事件和 mouse 事件的触发顺序不一致,需要做兼容处理。这些差异没有文档可查,只能靠实测。我的做法是在项目里维护一个兼容性测试页面,列出所有已知的浏览器差异和对应的处理方案。每次修改渲染逻辑后,都在这个页面上跑一遍,确保没有引入新的兼容性问题。8. 这个项目后续还能怎么扩展diagram-design 目前实现的是一个图表工具的核心骨架,但可扩展的方向还有很多。我最近在尝试的是协作功能——多个用户同时编辑同一张图表,通过 WebSocket 同步操作。技术难点在于冲突解决,两个用户同时移动同一个节点时,需要有一个策略来决定最终位置。我目前用的是"最后写入优先"策略,简单但不够优雅,后续可能会引入 OT 或 CRDT 算法。另一个方向是模板系统。用户可以把常用的图表结构保存为模板,下次直接套用。模板本质上是一组预定义的节点和连线,加上参数化的占位符。这个功能对于经常画同类图表的用户来说能大幅提升效率。还有一个我觉得很有价值的方向是代码生成。图表画好之后,能不能直接生成对应的代码?比如画了一个微服务架构图,能不能生成 Docker Compose 配置?画了一个流程图,能不能生成伪代码?这个方向的技术挑战不小,但想象空间很大。我在实际使用这个工具的过程中最大的体会是:图表工具的核心竞争力不在于功能多,而在于"画图的过程是否顺畅"。用户脑子里有一个结构,从想法到图表的路径越短,工具的价值就越大。所以后续的迭代方向应该始终围绕"减少操作步骤、降低认知负担"来做,而不是盲目堆功能。
1. 从零搭建 diagram-design:一个轻量级图表设计工具的技术选型与架构拆解第一次看到 diagram-design 这个项目名的时候,我脑子里蹦出来的第一个问题是:市面上已经有那么多成熟的图表工具了,为什么还要自己造一个?后来在实际动手做的过程中才慢慢想明白,通用工具解决的是"大多数人画大多数图"的问题,而 diagram-design 要解决的是"特定场景下快速产出结构清晰、风格统一的图表"这件事。这两者的差别,就像连锁快餐和私房菜的区别——前者什么都有,后者只做几道拿手菜,但每一道都做到位。这个项目本质上是一个面向开发者和技术写作者的图表设计工具,核心目标是让用户用最少的操作步骤,把脑子里的架构思路、流程逻辑、数据关系转化成可视化的图表。它适合谁用?我总结了三类人:第一类是经常写技术文档的工程师,需要快速画架构图和流程图;第二类是做技术分享的讲师,需要制作风格统一的演示图表;第三类是产品经理,需要把业务流程可视化后跟团队对齐。如果你属于这三类人中的任何一类,这个项目值得你花时间了解一下。我打算从技术选型、核心架构、渲染引擎、交互设计、样式系统、导出方案这几个维度,把 diagram-design 的完整实现思路拆开来讲。不是那种"第一步安装依赖、第二步运行命令"的流水账教程,而是把每个关键决策背后的"为什么"讲清楚。你看完之后,不仅能复现一个类似的工具,更重要的是能理解一个图表设计工具从想法到落地需要经历哪些关键节点。2. 技术选型:为什么最终选了 Canvas 而不是 SVG 或 DOM2.1 三种渲染方案的对比与取舍逻辑做图表工具,第一个绕不开的决策就是渲染方案。主流选择有三个:基于 DOM 的绝对定位、SVG 矢量渲染、Canvas 位图渲染。我一开始用的是 DOM 方案,因为最直观——每个节点就是一个 div,用 CSS 控制位置和样式,拖拽用鼠标事件就能搞定。但很快遇到了性能瓶颈:当图表节点超过 200 个的时候,拖拽开始明显卡顿,因为每次移动都要触发大量 DOM 的重排和重绘。换成 SVG 之后情况好了很多,矢量渲染天然适合图表这种几何图形密集的场景,而且 SVG 的每个元素都是 DOM 节点,事件绑定和样式控制都很方便。但 SVG 的问题在于,当节点数量继续增长到 500 以上时,浏览器的渲染压力依然很大,尤其是涉及复杂路径和渐变填充的时候。最终我选择了 Canvas 方案。Canvas 的优势在于它是立即模式渲染,所有绘制指令直接作用于像素缓冲区,没有 DOM 树的开销。实测下来,即使图表节点超过 1000 个,拖拽和缩放依然能保持 60fps 的流畅度。代价是事件处理需要自己实现命中检测,样式控制也不如 CSS 那么灵活,但这些都可以通过合理的架构设计来弥补。渲染方案性能上限事件处理样式控制导出质量适用场景DOM 绝对定位约 200 节点简单极灵活依赖截图简单流程图SVG 矢量渲染约 500 节点简单灵活矢量无损中等复杂度图表Canvas 位图渲染1000+ 节点需自实现需自实现需处理 DPR复杂图表工具2.2 Canvas 方案下的事件系统设计思路选了 Canvas 之后,第一个要解决的问题就是事件处理。DOM 和 SVG 里,你给每个元素绑定 click 事件就行了,浏览器帮你做命中检测。但 Canvas 是一整块画布,你需要自己判断鼠标点在了哪个图形上。我的做法是维护一个图形列表,每个图形对象包含它的几何描述(矩形、圆形、路径等)和包围盒信息。鼠标事件触发时,从后往前遍历图形列表(因为后绘制的图形在上层),用几何算法判断点是否在图形内部。对于矩形和圆形,判断很简单;对于复杂路径,用射线法或者包围盒加精确判断的两级检测来优化性能。这里有个容易踩的坑:Canvas 在高分屏上的坐标换算。如果你的 canvas 元素 CSS 尺寸是 800x600,但实际像素尺寸也是 800x600,在 Retina 屏幕上就会模糊。正确的做法是把 canvas 的 width 和 height 属性设置为 CSS 尺寸乘以 devicePixelRatio,然后用 ctx.scale() 缩放绘图上下文。但这样一来,鼠标事件的坐标也需要乘以 devicePixelRatio 才能和绘图坐标对齐。这个换算逻辑我封装成了一个工具函数,所有涉及坐标的地方都统一调用,避免散落在各处导致 bug。2.3 状态管理方案的轻量化选择图表工具的状态比一般应用要复杂:有图形数据(节点、连线)、有视图状态(缩放比例、平移偏移)、有交互状态(选中了哪些元素、正在拖拽哪个节点)、还有历史记录(撤销重做)。我一开始想用 Redux 这类成熟的状态管理库,但评估后发现有点杀鸡用牛角。最终我采用了一个轻量级的发布订阅模式:核心状态放在一个 Store 对象里,任何修改都通过 dispatch 方法触发,Store 内部对比新旧状态后通知订阅者。订阅者可以是渲染引擎(负责重绘)、属性面板(负责更新表单值)、图层面板(负责更新列表)。这种方案的好处是依赖少、可控性强,整个状态管理模块的代码量不到 300 行,维护起来很轻松。提示:状态管理不要过度设计。图表工具的状态虽然多,但关联关系相对清晰,一个简单的发布订阅模式足够应对。引入大型状态管理库反而会增加理解成本和打包体积。3. 图形系统的核心抽象:节点、连线与图元的关系建模3.1 节点模型的设计:从简单矩形到可扩展图元图表工具的核心是图形系统。我把所有可绘制的东西抽象成三个层次:图元(Shape)、节点(Node)、连线(Edge)。图元是最底层的几何描述,比如矩形、圆形、多边形、路径;节点是图元的容器,一个节点可以包含多个图元,比如一个带图标的矩形节点;连线是连接两个节点的有向或无向边。节点模型的设计直接决定了工具的扩展性。我见过一些图表工具,节点类型是硬编码的——矩形节点、圆形节点、菱形节点各写一套逻辑。这种做法的好处是简单直接,但缺点是每增加一种节点类型就要改一遍代码。我采用的是组合模式:节点本身只负责位置、尺寸、旋转角度这些通用属性,具体长什么样由它包含的图元列表决定。举个例子,一个"服务节点"可能由一个圆角矩形图元、一个图标图元、一个文本图元组成。当用户拖动这个节点时,节点更新自己的位置,然后通知所有子图元重新计算各自的绘制坐标。这种设计让新增节点类型变得非常简单——只需要定义一个新的图元组合,不需要改动核心逻辑。3.2 连线路由算法:直线、折线与曲线的自动选择连线看起来简单,实际上是最容易出问题的地方。两个节点之间的连线,如果只是简单地从中心点画一条直线,当节点移动时线会穿过节点本身,视觉上很混乱。我实现了三种路由策略,根据节点位置关系自动选择。直线路由适用于两个节点之间没有遮挡的情况,直接连接两个最近的边缘点。折线路由适用于需要绕开障碍的场景,经典的实现是正交路由——线只走水平或垂直方向,拐角处用圆角过渡。曲线路由用贝塞尔曲线连接两个节点,视觉上最柔和,适合表达非层级关系。自动选择策略的逻辑是这样的:先计算两个节点的包围盒,如果包围盒在水平或垂直方向上有重叠,用直线;如果没有重叠但中间有其他节点,用折线;如果两个节点距离较远且中间没有障碍,用曲线。这个策略不是完美的,但覆盖了 80% 的常见场景,剩下的 20% 用户可以通过手动调整连线的控制点来微调。3.3 锚点系统:让连线始终连接在合理的位置锚点是节点上预定义的连接位置。一个矩形节点通常有 8 个锚点:上下左右四个边的中点,加上四个角。当用户从节点 A 拖拽连线到节点 B 时,系统会自动选择距离最近的锚点对。锚点系统的关键在于动态计算。节点的位置和尺寸会变化,锚点的坐标也必须跟着变。我的做法是在节点模型中存储锚点的相对位置(比如"上边中点"表示为 {x: 0.5, y: 0}),渲染和命中检测时再根据节点的实际包围盒换算成绝对坐标。这样无论节点怎么缩放旋转,锚点始终在正确的位置上。还有一个细节:当多个连线连接到同一个锚点时,需要做偏移处理,否则所有线都叠在一起。我的方案是给每个锚点维护一个连接计数,第 n 个连接在锚点法线方向上偏移 n 乘以一个固定间距。这个间距通常是连线宽度的 2 到 3 倍,视觉上刚好能区分开。4. 交互设计:拖拽、缩放、框选背后的数学与工程细节4.1 拖拽交互的完整事件链路拖拽是图表工具最高频的交互。一个完整的拖拽过程涉及 mousedown、mousemove、mouseup 三个事件,但实际实现中要考虑的细节远不止这些。mousedown 触发时,首先要做命中检测,判断用户点在了哪个元素上。如果点在了节点上,记录起始位置和节点的初始坐标;如果点在了空白处,进入框选模式。这里有个细节:如果用户按住的是节点的边缘而不是内部,应该进入缩放模式而不是拖拽模式。所以命中检测需要区分"内部区域"和"边缘区域",边缘区域的宽度通常是 6 到 8 个像素。mousemove 触发时,计算鼠标的位移量,更新节点的位置。但这里有个性能优化点:如果每次 mousemove 都触发全量重绘,在节点多的时候会卡。我的做法是维护一个"脏区域"列表,只重绘受影响的区域。Canvas 的 clip 方法可以实现局部重绘,实测能提升 3 到 5 倍的性能。mouseup 触发时,结束拖拽状态,把这次操作记录到历史栈中。历史记录不能记录每一个 mousemove 的中间状态,否则撤销一次要按几百下。正确的做法是在 mousedown 时记录初始状态,mouseup 时记录最终状态,中间过程不记录。4.2 缩放与平移:视图变换矩阵的推导与应用图表的缩放和平移本质上是对视图做仿射变换。我用一个 3x3 的变换矩阵来表示当前视图状态,矩阵包含缩放因子、平移偏移和旋转角度。所有绘制操作前,先把变换矩阵应用到绘图上下文上,这样图形坐标不需要改变,改变的是整个画布的坐标系。缩放的中心点选择很关键。如果以画布左上角为中心缩放,用户会觉得图形在"跑"。正确的做法是以鼠标位置为中心缩放,这样鼠标指向的那个点在缩放前后保持不动。实现方法是:先计算鼠标在图形坐标系中的位置,缩放后重新计算这个点应该在的屏幕位置,两者的差值就是需要补偿的平移量。平移相对简单,但要注意边界处理。如果用户把图表拖出了可视区域,应该有一个"回弹"或者"限制"机制。我采用的是软限制:允许拖出一定范围,但超出后拖拽阻力逐渐增大,给用户一种"拉不动了"的感觉,而不是硬生生地卡住。4.3 框选与多选:从矩形碰撞检测到选择集管理框选功能看起来简单,实现起来有不少细节。用户按下鼠标后拖出一个矩形,所有与这个矩形相交的节点都被选中。碰撞检测用包围盒相交判断就够了,不需要精确的几何相交。选择集的管理有几个要点。第一,选择集是有序的,最后选中的元素在渲染时应该显示在最上层。第二,选择集变化时要触发属性面板更新,如果选中的是多个不同类型的节点,属性面板应该显示它们的公共属性。第三,选择集要支持反选、全选、按类型筛选等操作,这些通过快捷键和右键菜单实现。还有一个容易被忽略的点:框选时如果用户按住了 Shift 键,应该是"追加选择"而不是"替换选择"。这个交互细节在大多数设计工具里都是标配,但自己实现的时候很容易忘。5. 样式系统:让图表既好看又一致的配色与排版方案5.1 主题系统的设计:从硬编码颜色到可切换主题图表工具如果不做主题系统,最后一定会变成"每个节点颜色都不一样"的灾难现场。我在项目初期就引入了主题概念:所有颜色、字体、间距、圆角半径等视觉参数都从主题对象中读取,不在代码里硬编码。主题对象的结构是这样的:包含一个调色板(palette),定义了主色、辅助色、成功色、警告色、错误色等语义化颜色;包含一套排版规则(typography),定义了标题、正文、标注的字号和字重;还包含一组图形参数(shape),定义了圆角半径、边框宽度、阴影偏移等。切换主题时,只需要替换主题对象,然后触发全量重绘。实测下来,一个包含 500 个节点的图表,主题切换的重绘时间在 50ms 以内,用户感知不到延迟。5.2 配色方案的自动生成逻辑手动给每个节点选颜色太累了,我实现了一套自动配色逻辑。核心思路是基于 HSL 色彩空间做色相旋转:先确定一个基准色相,然后根据节点的类型或分组,在色相环上均匀取色。具体算法是这样的:假设有 n 种节点类型,基准色相为 H,饱和度和亮度固定,那么第 i 种类型的色相就是 H + (360 / n) * i。这样生成的配色方案在视觉上既有区分度又保持和谐。如果 n 比较大(超过 8 种),色相环上的颜色会太接近,这时候改为调整饱和度和亮度来增加区分度。还有一个细节:连线颜色不应该和节点颜色冲突。我的做法是连线统一使用中性灰,透明度根据连线的重要程度调整。重要的连线透明度高一些,次要的连线透明度低一些,形成视觉层次。5.3 文本排版:自动换行、对齐与溢出处理节点里的文本排版是个麻烦事。文本太长会溢出节点边界,太短又显得空旷。我实现了自动换行算法:根据节点的可用宽度和字体大小,计算每行能放多少个字符,然后按词边界切分文本。中文和英文的换行规则不一样。英文按空格切分单词,中文可以在任意字符处换行(除了标点符号前)。我写了一个简单的分词器,根据字符的 Unicode 范围判断是中文还是英文,然后应用不同的换行策略。文本对齐支持左对齐、居中、右对齐三种模式。垂直方向也支持顶部对齐、居中、底部对齐。这些对齐方式在节点尺寸变化时需要重新计算文本位置,所以我把文本布局逻辑封装成了一个独立的函数,输入是文本内容、可用区域、对齐方式,输出是每一行的绘制坐标。注意:文本测量在 Canvas 中是通过 measureText 方法实现的,但这个方法的性能开销不小。如果每次重绘都测量所有文本,节点多的时候会明显卡顿。我的优化方案是缓存测量结果,只有文本内容或字体变化时才重新测量。6. 导出与持久化:从画布到 PNG、SVG 和 JSON 的完整链路6.1 PNG 导出的清晰度问题与解决方案Canvas 导出 PNG 最常遇到的问题就是模糊。原因很简单:Canvas 的默认像素尺寸是 CSS 尺寸,在导出时如果直接调用 toDataURL,得到的图片分辨率就是屏幕分辨率,放到文档里一放大就糊了。解决方案是导出一个高分辨率的离屏 Canvas。具体做法是创建一个新的 Canvas 元素,尺寸设置为目标导出尺寸(比如 2 倍或 3 倍于原始尺寸),然后把绘图上下文缩放相应的倍数,再重新执行一遍绘制逻辑。这样得到的图片在放大后依然清晰。还有一个细节:导出时应该只包含图表内容,不包含网格背景、选中框、锚点这些辅助元素。我的做法是在渲染引擎中维护一个"导出模式"标志,导出时跳过辅助元素的绘制。6.2 SVG 导出的矢量重建思路PNG 是位图,放大有限制。如果需要无损缩放,就得导出 SVG。但 Canvas 的绘制指令不能直接转成 SVG,需要重新构建 SVG 的 DOM 树。我的做法是遍历图形列表,根据每个图形的类型生成对应的 SVG 元素。矩形生成 rect,圆形生成 circle,路径生成 path,文本生成 text。样式属性从主题对象中读取,转换成 SVG 的 presentation attribute 或内联样式。这个过程中最麻烦的是文本。Canvas 的文本换行是手动计算的,SVG 的文本换行需要依赖 foreignObject 或者手动插入 tspan。我选择了 tspan 方案,把每一行文本作为一个 tspan 元素,通过 dy 属性控制行间距。这样导出的 SVG 在大多数浏览器和设计工具中都能正确显示。6.3 JSON 序列化:图表数据的版本兼容与迁移图表的持久化格式我选了 JSON,因为可读性好、易于调试、跨平台兼容。JSON 结构包含三个顶层字段:version 表示数据格式版本,nodes 是节点数组,edges 是连线数组。版本字段很重要。工具在迭代过程中,数据结构难免会变化。有了版本号,加载旧数据时可以执行迁移逻辑。比如 v1 版本的节点没有 rotation 字段,加载时自动补上默认值 0。这种向前兼容的设计让用户不用担心工具升级后旧文件打不开。序列化时要注意循环引用的问题。节点和连线之间是双向引用的——节点知道它连接了哪些边,边知道它连接了哪些节点。直接 JSON.stringify 会栈溢出。我的做法是序列化时只保留 ID 引用,反序列化时再根据 ID 重建引用关系。导出格式适用场景优点缺点PNG文档插图、演示文稿兼容性好、文件小放大模糊、不可编辑SVG网页嵌入、矢量编辑无损缩放、可编辑文件较大、字体依赖JSON数据备份、团队协作可读性好、可版本控制需要工具才能查看7. 实操中踩过的坑与性能优化经验7.1 高频重绘导致的卡顿:从全量重绘到脏矩形更新项目做到中期,当图表节点超过 300 个的时候,拖拽开始明显掉帧。用性能面板一分析,发现 90% 的时间花在了重绘上。每次 mousemove 都触发全量重绘,300 个节点加上连线,一帧要画上千个图形。优化方案是脏矩形更新。核心思路是只重绘发生变化的区域。拖拽一个节点时,受影响的区域是这个节点的旧位置和新位置的并集,加上与这个节点相连的所有连线的包围盒。其他区域的内容不变,不需要重绘。实现上,我在渲染引擎中维护一个 dirtyRects 数组。任何修改操作都会往这个数组里添加受影响的矩形区域。渲染时,先清除这些区域,然后设置 clip 路径,只在这些区域内绘制。实测下来,拖拽场景的性能提升了 5 倍以上。7.2 内存泄漏的排查:事件监听与对象引用的清理Canvas 应用的内存泄漏往往比较隐蔽,因为不像 DOM 那样有明显的节点树。我遇到过一次内存持续增长的问题,排查了很久才发现是事件监听没有正确移除。具体场景是这样的:每次打开属性面板时,都会给面板上的输入框绑定 change 事件。关闭面板时,面板的 DOM 被移除了,但事件监听还挂在输入框上,输入框又被闭包引用着,导致整条引用链都无法被垃圾回收。修复方案是在组件销毁时显式移除所有事件监听。我封装了一个 EventManager 类,所有事件绑定都通过它来注册,销毁时调用它的 dispose 方法统一清理。这个习惯养成之后,内存泄漏的问题基本杜绝了。7.3 撤销重做的实现细节:命令模式与状态快照的取舍撤销重做是图表工具的标配功能,但实现方式有两种:命令模式和状态快照。命令模式记录每个操作(移动节点、修改颜色、删除连线),撤销时执行反向操作;状态快照记录每次操作后的完整状态,撤销时直接恢复到上一个快照。我最终选择了状态快照方案,原因是实现简单、不容易出错。命令模式虽然内存占用小,但每种操作都要写正向和反向两套逻辑,容易漏掉边界情况。状态快照只需要在每次操作后深拷贝一份状态存入历史栈,撤销时直接替换当前状态。当然,状态快照的内存开销是个问题。我的优化是限制历史栈的长度(默认 50 步),并且对快照做压缩——只存储与上一个快照的差异部分。这样即使图表很大,历史记录的内存占用也在可控范围内。7.4 跨浏览器兼容性:那些只有实测才能发现的差异Canvas 的 API 在主流浏览器上基本一致,但细节上仍有差异。我遇到过的几个坑:Safari 上 measureText 返回的宽度和 Chrome 有 1 到 2 像素的偏差,导致文本换行位置不一致;Firefox 上某些情况下 clearRect 不能完全清除阴影效果,需要额外调用一次;移动端浏览器上 touch 事件和 mouse 事件的触发顺序不一致,需要做兼容处理。这些差异没有文档可查,只能靠实测。我的做法是在项目里维护一个兼容性测试页面,列出所有已知的浏览器差异和对应的处理方案。每次修改渲染逻辑后,都在这个页面上跑一遍,确保没有引入新的兼容性问题。8. 这个项目后续还能怎么扩展diagram-design 目前实现的是一个图表工具的核心骨架,但可扩展的方向还有很多。我最近在尝试的是协作功能——多个用户同时编辑同一张图表,通过 WebSocket 同步操作。技术难点在于冲突解决,两个用户同时移动同一个节点时,需要有一个策略来决定最终位置。我目前用的是"最后写入优先"策略,简单但不够优雅,后续可能会引入 OT 或 CRDT 算法。另一个方向是模板系统。用户可以把常用的图表结构保存为模板,下次直接套用。模板本质上是一组预定义的节点和连线,加上参数化的占位符。这个功能对于经常画同类图表的用户来说能大幅提升效率。还有一个我觉得很有价值的方向是代码生成。图表画好之后,能不能直接生成对应的代码?比如画了一个微服务架构图,能不能生成 Docker Compose 配置?画了一个流程图,能不能生成伪代码?这个方向的技术挑战不小,但想象空间很大。我在实际使用这个工具的过程中最大的体会是:图表工具的核心竞争力不在于功能多,而在于"画图的过程是否顺畅"。用户脑子里有一个结构,从想法到图表的路径越短,工具的价值就越大。所以后续的迭代方向应该始终围绕"减少操作步骤、降低认知负担"来做,而不是盲目堆功能。