有人说,程序员之间的审美差距,比人和狗之间的差距还大。这话虽然有点夸张,但在面对一堆乱七八糟的线条、方块和箭头时,我确实经常觉得,写代码的难度远没有把逻辑讲清楚难。这两年我陆陆续续做了好几个带图形化展示需求的项目,从最开始手写死数据、硬调坐标,到后来沉淀出一套相对顺手的图表设计系统,踩过的坑攒了满满一箩筐。这篇东西就是把我在 diagram-design 这个方向上的摸索过程、关键设计决策和翻车记录整理出来,希望能给正在被流程图、架构图、时序图折磨的同行们一点参考。
这套东西不是什么高深框架,核心就是解决一个问题:怎么把一份可能长得乱七八糟的节点和连线数据,稳定、好看、可交互地渲染到浏览器里,并且让业务方能够通过拖拽、缩放、框选这些基本操作自己把图画出来。它直接对标的场景包括:低代码平台里的流程编排、运维场景的拓扑图展示、内部工具里的数据血缘分析,甚至是你给客户演示用的架构图编辑器。如果你正准备从零搭一个类似的图编辑能力,或者你已经在用现成库但总觉得哪里不对劲,这篇内容应该能帮你少走不少弯路。
1. 先把需求问清楚:你要的到底是“画图”还是“编辑图”
很多人一上来就急着选技术方案,看这个库支持什么、那个库 star 多不多,其实最该先想明白的是:你的产品到底需要用户怎么跟这张图互动。
1.1 只读展示和可编辑交互,技术选型完全是两条路
我见过最典型的翻车现场,是有人拿着一个只读渲染的需求去调研流程图编辑器,结果被一大堆复杂概念绕晕,最后选了个重型方案,开发周期翻了三倍。反过来,也有人拿着真正需要自由编辑的需求,图省事找了个纯展示库,写到一半发现拖拽、连线、撤销这些能力根本补不齐,只能推倒重来。
先说只读展示。这种场景下,图是后端算好或者静态配置好的,前端只需要把节点和边渲染出来,支持一下缩放平移、点击查看详情就够了。这种需求,选型空间很大,性能压力主要在渲染大量节点时的流畅度上。如果节点数量在几百个以内,用 DOM 方案配合绝对定位完全够用,开发效率极高,样式也好写。如果数量上到几千甚至上万,那就得考虑 Canvas 渲染了,或者用带虚拟列表的 DOM 方案。
再说可编辑场景。用户要能拖拽新增节点、从节点边缘拉出连线、框选多个节点整体移动、撤销重做、保存草稿……这些需求意味着你需要的不只是一个渲染引擎,而是一个完整的图编辑框架。这个门槛比只读展示高了一个量级,因为你要自己维护一套画布上的状态管理,处理各种命中检测、坐标转换、拖拽冲突。
1.2 交互边界决定了实现成本
有一个问题特别值得提前想清楚:用户需要在画布上自由摆放节点,还是只需要在固定轨道上连线?前者意味着你可以借鉴市面上成熟图编辑器的主流交互,让用户随意拖动;后者则意味着你可能要自己实现一套约束布局逻辑,工作量会显著增加。
比如做审批流,节点通常是按顺序从上往下排的,用户拖拽的其实是流程顺序而不是像素位置;做架构图,节点位置必须精确到像素,因为要对应到机房或者服务器机柜的物理布局。这两种场景对“编辑”二字的理解完全不同,前者更像是操作一棵树,后者才真正是在操作一张图。如果一开始没把这个概念对齐,后面产品经理和开发之间会反复拉扯,互相觉得对方脑子有毛病。
我自己比较推荐的做法是,需求阶段就画一张简单的表格,把“用户能不能拖节点”“能不能自己连线”“能不能删线”“能不能缩放”“要不要撤销”“要不要对齐辅助线”这些交互点全部列出来,逐项和产品确认。这个动作看起来繁琐,但能省掉后面大量的返工。
1.3 一个反直觉的结论:自研并不比集成现成库更费劲
很多人一听“自研图编辑器”就觉得是天大的工程,其实得看场景。如果只是渲染静态图,偶尔加一点点击展开的小交互,那确实没必要自研,直接用现成的图形库或者图表库就行。但一旦进入多人协作、复杂编辑、定制交互这种深水区,现成开源编辑器反而会成为你的天花板。
你去看那些流行的图编辑框架,做得确实很强大,但它们的架构是为通用场景设计的。你想加一个特殊的节点形态、自定义一种连线的拐弯规则、实现一套特有的校验逻辑,就得去读它的源码、顺着它的扩展机制写插件,甚至可能要改它的内核。这种情况下,你付出的学习成本和改造成本,已经接近甚至超过从零写一个了。
当然,我不建议所有场景都来自研。核心的判断标准是:你的领域逻辑是否深度绑定在图的节点和边上?如果是,自研一个贴合自家业务的数据模型和渲染层,长期来看是划算的;如果只是把图当成一个通用展示组件,那还是用现成方案更稳妥。我后来的做法是,封装一层自己的数据模型,渲染层单独抽象,这样既保留了对业务的控制力,又不至于从零开始画线条。
2. 数据模型是地基:节点、连线、画布的三层结构
图编辑器的核心其实不是渲染,而是数据模型。数据模型设计得好不好,直接决定你后面写交互逻辑的时候是如履平地还是如履薄冰。我重构过一次数据模型,那感觉就像把一栋盖到一半的房子拆了重打地基,疼是真疼,但盖完之后是真的稳。
2.1 节点数据怎么设计才不挖坑
先看一个最基础的节点数据结构长什么样:
interface DiagramNode { id: string; // 全局唯一,稳定不变 type: string; // 节点类型,比如 start、task、decision x: number; // 画布坐标,不是屏幕坐标 y: number; width: number; // 节点尺寸 height: number; data: Record<string, any>; // 业务自定义字段 style?: Record<string, any>; // 样式覆盖项 }这里有两个容易踩坑的点。第一个是 id 的稳定性。很多人测试的时候用自增数字或者时间戳当 id,一刷新就变,导致保存、对比、协同都出问题。我的建议是后端生成或者用哈希算法生成一个稳定字符串,比如结合节点类型的语义化 ID。第二个是 x、y 的含义。我强烈建议统一用画布坐标,而不是 DOM 坐标或者屏幕坐标。画布坐标是逻辑上的坐标,跟缩放级别无关;屏幕坐标是用户肉眼看到的坐标。如果混用,只要你一支持缩放,各种位置计算就会乱套。
2.2 连线不只是“起点到终点”
连线的数据模型,坑比节点还多。最简单粗暴的连线模型就是记录 source 和 target 两个节点 ID,渲染的时候从起点中心画一条直线到终点中心。这个模型应付三五个节点的演示还行,稍微复杂一点的场景就露馅了。
真实场景里,连线需要支持:
- 连线从节点的某个具体锚点出发,比如从右侧边缘的第二个连接点出发;
- 连线中途经过若干转折点,也就是所谓的拐点;
- 同一对节点之间可能有多条连线,需要自动偏移避免重叠;
- 连线要支持选中、删除、修改样式,甚至要承载一些业务数据,比如流程的流转条件。
所以我采用的连线模型长这样:
interface DiagramEdge { id: string; source: string; // 起点节点 id target: string; // 终点节点 id sourcePort?: string; // 起点锚点 id targetPort?: string; // 终点锚点 id points?: Point[]; // 拐点数组,可选 label?: string; // 连线上显示的文字 data?: Record<string, any>; style?: Record<string, any>; }这里 sourcePort 和 targetPort 是很容易被忽略但非常关键的字段。如果图里的节点都是圆角矩形,一个节点只有四个连接点(上下左右),那确实不需要锚点概念。但如果节点是一个复杂的多端口形状,比如一个代表服务器的节点有多个网口,连线必须从具体的网口出发,锚点就是必要的。我后来做的一个网络拓扑图项目,节点上有几十个端口,如果没有锚点机制,连线就永远只能从节点中心出来,那画面想想就尴尬。
拐点数组的设计也值得多说一句。很多图编辑器为了省事,连线只存起点和终点,渲染的时候用自动路由算法算出路径。自动路由在简单场景下确实好用,但在复杂业务场景下,用户就是希望手动拖出一个拐点,让连线绕过某个区域,这种需求自动路由往往满足不了。所以我在数据模型里留了 points 字段,如果用户手动拖过拐点,就按拐点渲染;如果没有,就交给默认路由逻辑。这也是一个“看起来简单,做起来要考虑周全”的设计点。
2.3 画布数据:缩放、平移、视口
第三个数据是画布本身的状态。画布状态包括缩放比例、平移偏移量、当前视口范围。这个状态看似简单,但要知道什么是“脏数据”——当我们拖拽一个节点时,节点坐标更新了,画布状态要不要跟着变?答案是不需要。节点坐标属于图数据,缩放比例属于视图状态。两者应该分开存储、分开更新,否则每次拖动节点都触发整张图的保存逻辑,性能会很难看,而且很容易把视图状态误存到业务数据里。
我把画布状态设计成这样:
interface ViewportState { scale: number; // 缩放比例,比如 0.5 - 2 translateX: number; // 平移偏移 translateY: number; viewportWidth: number; // 可视区域尺寸 viewportHeight: number; }这个状态主要服务于渲染层的坐标转换,不属于图数据。保存图的时候,只需要保存节点和连线数组,不需要保存视口状态。除非你有“保存当前视角”的产品需求,比如分享一个链接打开后定位到某个区域,那才需要把视口信息也存下来。
2.4 坐标转换:画布坐标与屏幕坐标的数学关系
坐标转换是整个图编辑器里最基础也最容易出错的逻辑。核心公式其实非常简单,但很多人栽在“忘记把坐标转换封装成统一函数”上。
先说正向转换:把画布坐标转换为屏幕坐标。假设画布的缩放比例为scale,平移偏移量为(translateX, translateY),那么画布上的点(canvasX, canvasY)对应的屏幕坐标是:
screenX = canvasX * scale + translateX screenY = canvasY * scale + translateY反向转换,也就是把鼠标所在的屏幕坐标转换为画布坐标,则是:
canvasX = (screenX - translateX) / scale canvasY = (screenY - translateY) / scale看上去就是两个线性公式,但魔鬼在细节里。
第一个细节:mouse 事件里拿到的坐标需要先减去画布容器的偏移量,才是容器内的屏幕坐标。如果你的画布嵌在页面的某个位置,这一点尤其容易忽略。
第二个细节:拖拽节点的时候,鼠标移动的屏幕距离要除以 scale,才是画布上的实际距离。也就是说,如果用户把画布放大到 2 倍,鼠标在屏幕上移动 20 像素,画布上的节点坐标应该只变化 10 个单位。如果忘了除以 scale,你会发现在放大状态下拖动节点,节点永远比鼠标慢半拍,或者更准确地说,鼠标移动了 20 像素,节点却移动了 40 像素,那体验简直让人抓狂。
第三个细节:缩放的中心点。如果用户用滚轮缩放,你是以鼠标所在的位置为缩放中心,还是以画布中心为缩放中心?绝大多数图编辑器的体验是以鼠标位置为缩放中心,也就是鼠标指着哪个位置,哪个位置就应该保持不变。这需要你在缩放的同时调整 translateX 和 translateY。
我封装了两个函数,一个是画布到屏幕的转换,一个是屏幕到画布的转换,所有涉及坐标的地方都必须通过这两个函数,绝对不允许在业务代码里手写公式。这样做的好处是,如果哪天你想支持多坐标系或者改变坐标基准,只需要改这两个函数即可。
3. 渲染层选型:DOM 还是 Canvas,这是个需要反复横跳的问题
渲染层的技术选型,我前前后后纠结了很长时间。有人推荐用 SVG,有人推荐用 Canvas,还有人推荐用 WebGL。每种方案都有自己的优势,但它们对应的场景完全不同。我自己最终采用的是分而治之的策略:静态基础层用 SVG 渲染,动态高频交互层用 Canvas。
3.1 SVG 的优势:简单、可调试、天然支持事件
SVG 最大的优势是简单直接。每个节点是一组 SVG 元素,每条连线是一条<path>,它们天然支持 DOM 事件,你可以直接给某个节点绑定 click、mousedown、mouseup 事件。调试的时候,打开开发者工具就能看到每个元素的属性,甚至可以直接在控制台修改 SVG 属性来测试样式,这对开发效率来说是巨大的提升。
SVG 的第二个优势是样式丰富。渐变、阴影、滤镜、虚线、圆角,这些图形学效果在 SVG 里就是几个属性的事,写起来非常直观。Canvas 对这些效果的支持虽然也不差,但需要你自己写一堆绘制代码,而且很多效果的性能消耗并不低。
SVG 的第三个优势是自适应能力。SVG 是矢量图形,缩放不会失真。这在图编辑场景里很重要,因为用户会频繁放大缩小,矢量图形的清晰度始终有保障。Canvas 在高分辨率屏上表现也不错,但需要额外处理像素比(devicePixelRatio)的问题,否则会发虚。
3.2 Canvas 的优势:性能、性能、还是性能
Canvas 的优势只有两个字:性能。当节点数量超过一定阈值时,SVG 的 DOM 元素数量会变得非常庞大,每次重绘都会引发大量的布局计算和样式计算,页面会明显卡顿。而 Canvas 不是基于 DOM 的,绘制过程是直接输出像素,所以承载几千个节点、几万条连线毫无压力。
但 Canvas 的代价是你得自己搞定拾取(点击检测)逻辑。在 Canvas 上点击一个节点,你需要把鼠标坐标和所有节点的包围盒做碰撞检测。节点数量不多的时候,遍历所有节点就行;节点数量大的时候,可以用四叉树之类的空间索引来加速。这个能力虽然不难实现,但确实增加了一部分开发量。
Canvas 的另一个代价是交互状态的维护。在 SVG 里修改一个节点的样式,直接改它的 class 或者属性就行,浏览器会自动重绘;在 Canvas 里,你得自己维护“当前选中的节点”、“当前 hover 的节点”这些状态,然后重绘整个画布。像 hover 高亮、选中描边看起来是很普通的交互,在 Canvas 里却要自己负责全套逻辑。
3.3 我最终的选择:分层混合渲染
我最后采用的方案是“SVG 为主,Canvas 为辅”。具体来说,在同一个坐标体系下,我把节点和连线全部用 SVG 渲染,包括节点的文本、图标、端口等;用一个覆盖在 SVG 之上的透明 Canvas 层专门处理拖拽过程中的橡皮筋框选、大量临时线条预览这类高频变化的效果。
为什么要这么分?因为节点和连线在交互过程中虽然会频繁变化,但变化之后的状态是稳定的,比如拖拽完一个节点,它的位置就固定下来了,这时候 SVG 的声明式优势就体现出来了。而拖拽过程中临时预览的内容是高频变化的,每一帧都在变,如果插到 SVG 里,会导致浏览器频繁操作 DOM,很快就卡了,放到 Canvas 里,每帧重新画一遍就行,性能完全不是问题。
这套分层方案跑了一段时间后,我的实际感受是:对于大多数业务系统(几百个节点以内)的图编辑器,纯 SVG 其实就够用了,Canvas 层是锦上添花。但如果你想做一个能撑住几屏大量节点的引擎,那分层渲染几乎是必经之路。
3.4 坐标体系:SVG viewBox 和 Canvas 对齐的细节
分层的最大麻烦不是“分层”,而是“同步”。SVG 层和 Canvas 层必须共享同一个坐标体系,否则你会在 Canvas 上画的框选选不中 SVG 里的节点,鼠标位置总是偏那么几像素。
我的做法是在 SVG 层上设置 viewBox,让它跟随画布的平移和缩放自动更新。这样 SVG 内部所有的节点坐标都是画布坐标,浏览器会自动帮我们做坐标转换。Canvas 层则每次重绘时手动进行坐标转换,把画布坐标转为屏幕坐标后再绘制。
这中间有一个细节值得注意:SVG 的 viewBox 更新会导致浏览器重新布局,如果频繁更新(比如每帧都在拖拽平移),SVG 层的渲染性能会受到一定影响。所以我在拖拽平移画布时,其实是用 CSS transform 配合 GPU 加速来移动整个 SVG 组,等拖拽结束再统一更新 viewBox,把坐标归位。这就是一个典型的“用时间换空间”的性能优化手段。
4. 交互设计细节:拖拽、连线、框选、缩放,每一个都不简单
图编辑器的用户体验,全看交互细节。很多功能单看好像都稀松平常,用户拖一个节点、拉一条线、框选一片区域,但把这些细节组合在一起,体验的差距就出现了。一个成熟的图编辑器和业余 Demo 的区别,恰恰就藏在这些细节里。
4.1 节点拖拽的防抖与吸附
节点拖拽是最基础的交互,但要做好有几个细节。
第一是拖拽冲突。用户从节点上按下鼠标左键时,到底是想拖动节点,还是想拉一条连线?如果节点上某个区域是连接点,那按下连接点应该开始拉线,按下节点空白区域才应该开始拖拽。这要求在数据结构上把节点的可拖拽区域和可连线锚点区分开。
第二是拖拽过程中的性能。节点在拖拽过程中位置会持续变化,每次变化都触发整张图重绘,那是不行的。我的做法是拖拽过程中,只动态更新被拖拽节点的位置,连线路径同步更新,但不触发全量渲染。等拖拽结束(mouseup)再触发一次全量更新。
第三是对齐辅助线。这是提升编辑器专业度的关键功能。用户拖拽一个节点到另一个节点附近时,如果水平中心线或者垂直边缘线和别的节点对齐了,就显示一条辅助线,并且自动吸附到对齐位置。实现原理并不复杂,拖拽移动时,把当前节点和其他节点做水平和垂直方向的对齐检测,当距离小于某个阈值(通常 5-8 像素)时,强行修正节点坐标并展示辅助线。但就是这么一个小功能,能极大提升用户的拖拽体验。
4.2 连线交互:从锚点出发是铁律
连线交互的核心原则是:线的起点必须从锚点出发,终点落到另一个锚点或空白画布上。这个原则看起来理所当然,但不少实现里,用户拉线时起点直接就是节点中心,导致出来的连线都是从节点中心冒出来的,既不美观也不专业。
我的实现是:每个节点注册若干锚点,锚点位置是相对于节点的偏移量,比如左侧中间、右侧中间、顶部中间、底部中间,也可以是自定义位置。用户拉线时,鼠标按下在某个锚点上,记录 source 和 sourcePort,然后创建一个临时连线跟随鼠标移动。鼠标移动到另一个节点的锚点上时,高亮该锚点,松开鼠标时把 target 和 targetPort 填充进去并创建正式连线。
如果用户没有把终点落在任何节点上,而是落在画布空白处,这时候可以有几种处理:一种是直接取消这次连线,一种是自动创建一个新节点并把连线连到新节点上。我建议默认取消,但提供设置项让用户选择。因为很多流程图软件支持“点空白区域快速创建节点并连上”,这个交互很顺手,但也有用户会误操作,经常不小心点出好多空节点。
4.3 框选的实现与坑
框选是用一个矩形区域批量选中节点。在 SVG 方案下,框选的实现非常直接:
- 在 Canvas 层监听 mousedown,记录起点屏幕坐标;
- 鼠标移动时绘制一个半透明矩形,实时更新矩形区域;
- mouseup 时,把屏幕坐标的矩形转换为画布坐标的矩形;
- 遍历所有节点,判断节点的包围盒是否与矩形相交。
这个逻辑本身不难,但有几个坑。
第一个坑是坐标转换遗漏。如果忘了把矩形坐标从屏幕坐标转换为画布坐标,在画布放大或缩小时,框选范围会完全错误。
第二个坑是相交判断的边界条件。是节点完全在矩形内部才算选中,还是节点和矩形有交集就算选中?这两种语义在用户预期里都能说得通,如果产品没有明确要求,我建议用“有交集就算选中”,因为用户很多时候只是想快速圈中一个区域,不会仔细把矩形包到刚好只包含目标节点。如果只支持完全包含,用户会经常圈不中,体验会显得很“笨”。
第三个坑是框选后如何处理“临时选中态”和“正式选中态”。框选过程中,被框中的节点应该实时高亮,但不要更新真正的选中集合;鼠标松开后,再统一更新真正的选中集合。这样避免了框选过程中反复触发选中事件导致的连锁反应。
4.4 缩放:以鼠标为中心是默认习惯
缩放交互的主流做法是监听滚轮事件,以鼠标位置为缩放中心,对 scale 做渐变或者步进调整。步进调整相对简单,比如每次滚轮 scale 乘以 1.1 或除以 1.1。但有些用户希望缩放更顺滑,这时候可以用平滑缩放:每帧插值逼近目标 scale,避免突变。
监听滚轮事件时要小心浏览器的默认行为。如果滚轮事件绑定在画布容器上,你需要调用preventDefault()阻止页面滚动。但要注意,React 这类框架里的合成事件,默认监听是被动的,也就是你在合成事件里调用preventDefault()会得到警告,而且可能不生效。正确做法是在容器元素上用原生addEventListener绑定滚轮事件,并设置{ passive: false },这样就能成功阻止页面滚动,而且滚动手感比较跟手。
缩放值还有一个安全边界问题。scale 如果无限缩小,画布上节点会小到看不见;如果无限放大,节点会大到失真。我一般把 scale 限制在 0.25 到 2.5 之间,两个边界值之外用橡皮筋回弹效果,也就是拖到边界后能感觉到一点阻力,松手后回弹到边界值。
4.5 线框命中检测:用户想选中一条线真的很难
选中线条的难度比选中节点高一个数量级。节点是一个矩形,命中检测就是矩形判断;线是一条折线路径,它的面积是 0,用户很难精确点中。为了让用户更容易选中线,我实现了一个“线框命中检测”机制:把每条线的路径转化为一个宽度为 8-10 像素的“隐形命中区域”,当鼠标进入这个区域时,就认为命中了这条线。这个机制在 SVG 里其实很简单,因为pointer-events: stroke这个 CSS 属性可以实现类似效果,但在某些复杂场景(比如多条线密集交错)还是需要自己手动做路径距离计算。
手动实现的话,可以把连线抽象成若干线段,计算鼠标点到每条线段的距离,若距离小于阈值则命中。这个算法虽然朴素,但在几千条线以内的场景性能是完全够用的。针对一条由多个拐点组成的折线,把每段线段分别计算距离,取最小值即可。
5. 常见问题与排查技巧实录
写到这里,整理几个我在实际开发中遇到过的高频问题,以及排查思路。这些问题如果没人提醒,往往会在某个加班的深夜突然爆炸,让人一头雾水。
5.1 导出图片时出现空白或错位
我遇到过的最典型问题,是用户点击“导出 PNG”按钮,生成出的图片整张是白色或者某些节点跑到奇怪的位置。排查了很久,才发现问题出在渲染时序上:导出时如果画布还没完成渲染,或者 viewBox 还没更新到位,导出的就是一张残图。
解决方法是:导出前强制调用一次全量渲染,等待渲染完成后再调用导出接口。如果用的是 SVG,可以把 SVG 中的内容序列化为 XML,放在一个隐藏的 Canvas 里绘制再导出;也可以用html2canvas这类工具直接截取 DOM。但html2canvas对 SVG 的支持其实并不是很好,遇到复杂样式时经常出问题,所以我更推荐先把 SVG 转为图片(通过Image对象加载 SVG 的 data URL),再绘制到 Canvas 上导出。只有经历了一次导出文件打不开的灾难,你才会理解这个流程的每一个细节有多重要。
5.2 节点拖拽时出现卡顿
很多图编辑器在拖拽节点时卡顿,原因往往不是渲染问题,而是数据更新过于频繁。比如每次鼠标移动都触发 Vue 或 React 的 setState,导致整个图组件重新渲染。要解决这个问题,有两个思路:一是“局部更新”,拖拽过程中只更新被拖拽节点的渲染元素,其他部分不动;二是“节流”,把 mouse 事件按帧率合并,或者用 requestAnimationFrame 统一更新。
我实际操作中的经验是“局部更新 + requestAnimationFrame”组合最稳。拖拽过程中用一个requestAnimationFrame循环来更新节点位置,每帧只操作那个被拖拽的 DOM 元素和相关的连线路径,不触碰整张图的状态。拖拽结束后,再触发一次全量数据提交。用这样的策略,即使同时拖拽几十个节点(多选框选后批量拖动),也能保持流畅。
5.3 鼠标坐标一直不准
坐标不准的问题,十有八九是“没考虑容器的 offset”或者“没换算 viewBox 坐标”。排查步骤是:
- 确认鼠标事件绑定的元素是谁,是不是画布容器;
- 确认是否减去了容器在页面中的偏移量;
- 确认是否经过画布坐标转换函数,而不是直接用原生坐标;
- 确认缩放状态下是否除以 scale。
如果以上都检查过了还是不准,那很可能是在一个设置了transform的父元素内部嵌套了画布,导致getBoundingClientRect返回的坐标是经过 transform 之后的,需要做额外换算。这是我认为最隐蔽的一个坑,因为它只有在特定布局下才会出现,一旦出现就会让人觉得是祖传代码在闹鬼。
5.4 撤销重做实现起来比想象的麻烦
撤销重做是图编辑器里最容易被低估的功能。你以为是存个快照就行,其实不是。最朴素的实现是完整快照:每次操作后,深拷贝整张图的数据存进历史栈。这个方案在节点不多的时候可行,一旦节点多了,内存消耗非常可观,而且深拷贝的时间会随着图规模线性增长。
进阶一点的方案是增量快照:记录每次操作前后的差异,比如“新增了哪个节点”“移动了哪个节点”“删除了哪条连线”。这需要你为每个操作实现对应的逆操作,工作量大一些,但性能更好。还有一种方案是操作命令模式:每个操作都是一个命令对象,命令对象有 execute 和 undo 两个方法。比如“添加节点”命令的 execute 是往数组里 push,undo 是把这个节点从数组里删掉。这个模式下,历史栈里存的是命令对象,不用存数据快照,内存占用极小。
我实际采用的是混合方案:大多数操作走命令模式,但对于一些复杂操作(比如批量修改样式),直接记录操作前的快照,作为 undo 的恢复点。这样兼顾了内存和开发效率。有一个细节值得强调:撤销重做历史在保存图数据之后应该清空还是保留?我强烈建议保留,因为用户经常在保存之后又尝试做几次操作,然后突然后悔想撤销到保存前。清空历史栈会给用户带来极大的心理创伤。
6. 性能优化与工程化:别让图编辑器把你拖垮
图编辑器是一个典型的“功能看似不多、但性能优化无底洞”的组件。如果你不提前做性能规划,用户量一旦上来,各种问题会接踵而至。这里分享几个我实际用过的性能优化手段。
6.1 虚拟化:只渲染视口内的节点
当图很大(几千个节点)时,SVG 里塞大量 DOM 元素是不可行的。一个有效的策略是在渲染前先计算当前视口范围,只渲染视口内的节点和连线,视口外的节点在滚动或缩放时再动态加载。
这个策略实现起来不算复杂:视口范围就是画布坐标下的可视矩形,判断每个节点的包围盒是否和视口相交,相交就加入渲染列表。但要注意,节点虽然可以不渲染,但拖动平移时,视口外的节点可能很快进入视口,如果渲染跟不上,就会出现白屏闪烁。我的解决办法是:视口范围外扩一定的边距(比如 100 像素)作为预渲染缓冲区,提前渲染即将进入视口的节点,这样用户基本感知不到节点是动态加载的。
6.2 事件代理:别给每个节点绑事件
给每个节点单独绑定事件,是另一个性能雷区。几千个 DOM 节点,每个都绑上 mousedown、mouseenter、mouseleave 等事件,光是事件监听的创建和内存占用就很可观。我的做法是在画布容器上只绑定一次事件,通过事件冒泡机制统一处理所有节点的交互。拿到事件对象后,通过event.target往上查找最近的节点元素,再根据节点的唯一标识(比如>