“diagram-design”这个项目名,乍一看平平无奇,但只要是做过可视化、流程图、拓扑图这类前端工具的人,都会心一笑——这种项目永远没有“做完”的那一天。节点、连线、布局、缩放、拖拽、命中检测、文本编辑、撤销重做……每个模块拆开都能写一篇长文,合在一起就是一个十足的“磨人精”。
这篇文章把我做 diagram-design 的完整过程、设计取舍、踩坑记录都摊开讲讲。内容偏实践,适合已经能用框架做点东西、想挑战一下复杂交互,或者打算从零写一个图形编辑器的开发者。我会尽量把“当时为什么这么选”“后来怎么改的”“哪个决定让我后悔了”都交代清楚,不是为了自吹自擂,而是希望你能少走一点我走过的弯路。
1. 整体设计与思路拆解
1.1 这个项目到底要做什么
diagram-design 的核心目标很朴素:在浏览器里画出一张可以交互的图表。节点可以拖拽移动,连线会跟着节点走,画布可以缩放平移,节点支持双击编辑文字。听起来就是一个简化版流程图工具,但真正动手的时候,你会发现“简单”两个字是最昂贵的包装。
我给自己定了一个范围边界:不做自动布局,不做复杂图形库,不做协同编辑,专注把手工绘制、交互体验和渲染性能这三件事做透。为什么这么定?因为自动布局(比如树形布局、力导向布局)本质上是另一个大型课题,一旦摊进去,主线就会失焦;而协同编辑需要后端基础设施配合,前端项目本身根本承载不了。先把“人直接操作图表”这一条路走通,反而是约束最少、最能出成果的做法。
从技术栈开始,我最早用原生 JS + SVG 起步,没有引任何绘图框架。很多人问为什么不直接上 canvas、不用现成的图形库,这个问题的答案后面会详细讲,但结论先放在这里:对于典型的中等规模图表(几百到几千个节点),SVG 在交互开发效率和视觉还原度上,依然是最稳妥的选择。
1.2 核心功能拆解与优先级排序
把需求逐个拆开,我列了一张功能清单,并给每一项标了优先级:
- 节点渲染(矩形、圆角、图标、文字)——P0
- 连线渲染(直线、折线,带箭头)——P0
- 画布缩放、平移、适应视口——P0
- 节点拖拽移动——P0
- 点击选中、高亮、多选——P1
- 双击编辑节点文本——P1
- 样式面板(颜色、边框、字体大小)——P2
- 导出图片 / JSON 序列化——P2
- 小地图 navigator——P3
- 撤销重做——P3
我故意把 P3 的东西往后放,因为撤销重做看似简单,实际涉及命令模式重构、深拷贝策略、合并历史记录等多个环节,做不好反而拖垮核心体验。P0 和 P1 做完之后,这个工具已经很实用了;P2 是锦上添花;P3 更像长期演进方向。
1.3 为什么不用现成库
必须先回答一个质疑:类似的需求,用成熟的绘图库(比如某图形编辑框架)两周就能搞定,为什么要自己造轮子?
我的理由有三条。第一,可控性。图形编辑器的坑极其密集,坐标转换、事件冒泡、缩放状态同步……用库的时候一旦出了深层 bug,排查成本反而更高;自己写整个模型在脑子里清清楚楚,任何异常都能快速定位到具体模块。第二,学习收益。自己写过一遍坐标变换、命中检测、序列化逻辑,以后再接触任何上层框架,你都具备“透视能力”,能看出它底层在干什么。第三,体积和依赖。核心图形逻辑全部自研,包体积可以压得很小,也不用担心第三方库的 breaking change。
这不是说用库不行。如果你的目标是快速交付、对原理无感、需求常规,那么成熟库绝对是效率最高的路径。但如果你想亲手掌控整个链路,想要深入理解图形引擎的运转方式,自己写一遍的回报非常高。diagram-design 的定位偏向后者,所以我选择了硬造轮子。
2. 技术选型与架构设计
2.1 渲染方案对比:SVG、Canvas、DOM
这是一个绕不开的灵魂拷问。我分别做了简单实测,把三者摆在一起看:
| 维度 | SVG | Canvas | DOM |
|---|---|---|---|
| 节点数量上限 | 中等(约 2000 左右流畅) | 高(数万甚至更高) | 低(几百就明显卡) |
| 交互事件绑定 | 每个元素可单独绑定,直观 | 全靠命中检测,手动处理 | 天然 DOM 事件,但节点一多就是灾难 |
| 样式控制 | CSS 驱动,动画和 hover 容易 | 需要手动重绘 | 天然支持,但布局负担重 |
| 文本处理 | 原生 text 元素,自动换行比较费劲 | 需要手动排版、测量 | 最自然,但图形内文本定位繁琐 |
| 导出 | 序列化 SVG 即可,方便 | 需要 toDataURL 或者另做快照 | 复杂,需要 clone 样式 |
对比下来,SVG 在“交互开发效率和中等规模性能”之间取得了一个对我很有利的平衡点。Canvas 的优势在规模,但在“双击编辑文字”“样式面板调颜色”这些场景里,你需要自己实现太多图形学层面的事情;DOM 的劣势在规模一上来就崩,而且绝对定位布局做自由画布,会让浏览器负担很重。
我当时还做了个简单压力测试:在 SVG 里塞了 3000 个元素(节点加连线合计),平移缩放时的重绘还是能扛住的,帧率在 30 到 50 之间浮动;到 8000 个元素就明显吃力了。到后面我在优化阶段引入视口裁剪,情况大幅改善,这个细节后面单开一节说。最终选型就是:主渲染层采用 SVG,弹层、工具栏、上下文菜单使用 DOM 覆盖层(overlay)。
2.2 顶层架构:数据与渲染分离
整个项目我坚持了“数据模型和视图渲染分离”这条铁律。内部维护一份独立的 JSON 数据模型:
- 节点:
{ id, x, y, width, height, type, text, style } - 连线:
{ id, source, target, points, style }
SVG 层只负责把这份模型“翻译”成可视元素。任何交互动作——拖拽、缩放、双击编辑——都先修改这个数据模型,再触发一个统一的重绘方法,而不是直接去操作某个 SVG 属性。这带来的直接好处是:
- 排查问题容易(数据错了就看数据,不牵扯渲染)
- 序列化和反序列化自然成立,导出 JSON 就是导出这份模型
- 未来接撤销重做时,可以对模型做快照和 diff
这个架构思路不复杂,但非常关键。很多人写图形工具,写着写着就用变量到处存状态,最后陷入“改哪里要同步哪里”的泥潭。始终让数据模型成为唯一事实来源,是所有图形应用的定海神针。
2.3 模块划分与目录组织
为了不让自己在后期迷失,我把目录按职责划分成四大块:
- core:数据模型、序列化、坐标变换、缩放状态
- render:SVG 渲染器、节点渲染、连线渲染、箭头绘制
- interaction:拖拽、选择、双击编辑、滚轮缩放、快捷键
- ui:工具栏、属性面板、上下文菜单、小地图
core 和 render 之间通过一个极简接口通信,interaction 只负责修改 core 模型,不直接碰 SVG。这套划分让后续加新功能(比如加一种新的节点类型)只需要在 render 里加一个分支,core 完全不用动。模块间依赖关系清晰,回头维护的时候心情会好很多。
3. 核心细节解析与实操要点
3.1 坐标系统:千万别在多个坐标系里迷路
图形编辑器最容易翻车的地方,就是坐标系混用。diagram-design 涉及三套坐标:
- 模型坐标:业务数据里的坐标,即节点在“真实画布”上的位置,不受缩放和平移影响。
- 屏幕坐标:相对浏览器可视窗口的坐标,通常来自鼠标事件里的
clientX/clientY。 - SVG 用户坐标:经过
viewBox换算后的坐标。SVG 内部的元素定位一律是用户坐标,所以需要把屏幕坐标换算到用户坐标才能做命中检测。
换算的公尺是:
用户坐标 = (屏幕坐标 - 容器的偏移量) / 缩放比例 - 平移量这里最容易踩的坑是“容器偏移量”。如果页面本身可以滚动,一个普通的getBoundingClientRect().left就能解决;但如果在 canvas 外层套了一层 transform,那还要叠加 transform 矩阵,不然算出来的坐标永远差一个常数,而且只在特定缩放级别看起来是对的。为了这件事我加了一个screenToModel(x, y)和modelToScreen(x, y)的完整函数族,所有坐标转换都走这两个入口,杜绝了在业务代码里手写换算公式。
3.2 缩放平移的正交性
画布的缩放和平移,一种常见实现是给 SVG 根元素加transform: scale(...) translate(...)。我实测下来,直接修改 SVG 的viewBox在视觉上更干净,但有一个问题:viewBox 一变,所有内部元素的用户坐标也会跟着变,如果模型里存的是用户坐标,就乱了。
我采取的做法是:模型数据完全不动,缩放和平移只影响 render 层的全局变换参数。具体实现是维护一个viewport = { scale, tx, ty }对象,渲染的时候在根<g>元素上一次性设置:
g.setAttribute('transform', `translate(${tx}, ${ty}) scale(${scale})`);这样可以保证模型坐标始终是“真实坐标”,任何状态下读出来的数据都是未经过变换的原始值。设计原则就是:不要让可视化参数污染业务数据。
3.3 连线绘制与正交路由
连线是图表工具里远比想象中复杂的环节。diagram-design 支持直线和正交折线两种模式。直线最简单,起点、终点一画就行。折线则需要计算一个不穿越节点内部的路径。
我最初写的正交路由很简单:从源节点中心出发,先水平走一段,再垂直走,再水平到达目标节点。效果在节点对齐的时候还行,但两个节点上下左右错位时,画出来的线会穿过节点矩形,非常难看。
后来我改进成锚点 + 避让结合的策略:每个节点定义一组连接锚点(上、下、左、右),连线的起点和终点是“最靠近目标方向的锚点”,中间路径用曼哈顿路径生成器计算。曼哈顿路径核心是找到一条只包含水平/垂直段的折线,且控制点偏离障碍物。如果两节点在同一水平线上,就直接走水平线;否则计算出拐点坐标,再根据障碍检测微调。这个方案仍然不是完美的寻路算法(真正的寻路要上 A*),但对于手工绘制的图表场景,视觉上已经足够自然。
在箭头实现上,我画在折线最后一个线段上,通过计算终点线段的角度来旋转箭头 path:
const dx = lastPoint.x - prevPoint.x; const dy = lastPoint.y - prevPoint.y; const angle = Math.atan2(dy, dx);箭头的两个侧翼就基于这个角度绘制。初期我踩过一个坑:箭头方向在垂直向下时会翻转 180 度,原因就是atan2的分支判断没处理好两端点完全重合的情况。后来加了线段长度阈值判断,过短就直接不画箭头,才对。
3.4 文本测量与节点尺寸自适应
节点里需要显示文字,而 SVG 的<text>元素默认不会自动换行。逻辑上,我设计成“节点宽度固定,文字超出部分省略号处理”,这实现起来简单;但后来发现用户的需求往往是“文字长了,节点能不能自动变宽”。
自动变宽的思路:先让文字渲染到不可见的测量元素里,通过getComputedTextLength()拿到实际宽度,然后比较它与节点默认宽度的关系,取最大值作为最终节点宽度。对于多行文本,事先根据预设宽度把文字拆行,再计算每行宽度。
这里的“预设宽度”有个经验值:单行文本节点宽度下限建议 100px 左右,上限画布内不做硬限制,但节点过宽会影响布局可读性。拿max(defaultMinWidth, textWidth + padding * 2)作为最终宽度即可。
3.5 命中检测与选中交互
SVG 的好处是自带事件系统,节点上直接绑定事件就行。但画布是缩放过的,鼠标事件拿到的坐标永远是屏幕坐标,必须经过坐标转换才能判断“点的是哪个模型元素”。
我给每个节点注册了独立的点击和 hover 事件,连线同理。不过有一个细节:连线的可点击区域非常小,因为线条只有 1~2 像素宽,手指粗的用户会点不中。这个问题我在绘制连线的 path 下面叠加了一层不可见的、宽度扩大到 10~12 像素的辅助路径,专门用来接收事件,视觉上完全看不出来。
选中状态的处理:同一个时间只允许一个主选中目标,但用 Command/Control 键可以追加选中。所有选中的元素都会高亮,主选中目标还会额外显示手柄(用于后续尺寸调整)。
3.6 双击编辑与遮罩层
双击节点进入编辑模式,我用了一个 DOM 覆盖层实现:在节点上方绝对定位一个<textarea>,盒子大小和位置跟节点模型坐标对齐,内部的文字是节点文本,背景设为透明以模拟原地编辑的效果。这个方案比直接修改 SVG<text>简单,因为 textarea 天然支持光标、选区、换行、输入法,而 SVG 文本编辑要自己处理一堆输入法问题。
说起来很容易,但我实际做的时候出过一次挺无语的 bug:SVG 的坐标系和 DOM overlay 的坐标系不一致。DOM overlay 定位用left/top,数字得从模型坐标经过 viewport 变换换算成屏幕像素。换算的时候有个四舍五入误差叠加的问题,缩放级别是例如 1.25、1.5 这种小数时,textarea 边框会对不上节点边框。解决办法是,在计算尺寸时就按缩放系数换算:
const rect = editor.getBoundingClientRect(); const textarea.style.left = rect.left + 'px'; const textarea.style.top = rect.top + 'px'; const textarea.style.width = rect.width + 'px'; const textarea.style.height = rect.height + 'px';3.7 节点拖拽的模型驱动过程
拖拽的本质是一个三阶段过程:
- mousedown 记录起始位置,判断命中的节点;
- mousemove 计算位移增量,修改模型坐标;
- mouseup 结束拖拽,并触发一次轻量 diff 更新关联的连线。
位移增量计算有个细节:连续帧之间鼠标位置的变化量可以直接加到模型坐标上,不需要每次都做一次完整的 screenToModel 重算。但有一种情况例外——缩放值在拖拽过程中被 n 个设备同时改变(比如另一只手滚轮),此时位移增量需要先除以缩放系数,否则节点会被拖得比鼠标快或慢。我统一封装了一个moveNodes(dx, dy)方法,内部自动除以 scale:
modelNode.x += dx / viewport.scale; modelNode.y += dy / viewport.scale;拖拽时连线的实时更新,我直接对连线的端点做重计算。每个线段的路径重新生成算法不复杂,但频繁的 path 重写会触发浏览器大量 layout/paint,所以我在拖拽循环里用了 requestAnimationFrame 节流,只有数据变化时标记 dirty,下一帧统一 flush 更新 DOM,而不是每毫秒都直接改 SVG 属性。
4. 实操过程与核心环节实现
4.1 初始化画布与渲染管线
搭建画布的初始化代码,我采用了一个简单的类封装。以DiagramEditor为核心类,构造时传入容器元素和初始模型,随后调用render()渲染全量数据。
const editor = new DiagramEditor({ container: document.getElementById('canvas'), model: initialModel, viewport: { scale: 1, tx: 40, ty: 40 } });构造器内部做三件事:创建 SVG 根节点、创建 DOM overlay 层、绑定窗口事件。渲染管线分两级:
- 全量渲染:
render()遍历整个模型,生成所有节点和连线的 SVG 元素。适合初始化、数据导入、撤销重做等场景。 - 增量渲染:只对标记为 dirty 的元素执行更新。适合拖拽、编辑、样式修改等高频细粒度操作。
增量渲染听起来很优雅,但实现时需要小心:更新一个节点时,节点本身重画简单,但关联它的所有连线都要重算路径。所以 dirty 标记不能只标记节点,得在节点数据变更时把关联边列表也一并标记。
4.2 视口裁剪:让 3000 节点也不卡
前面提到 SVG 在节点数量大的时候会变慢,我最初的优化办法是视口裁剪。因为用户的屏幕有限,画布上大部分元素其实都不在可视区域内,把它们渲染出来是纯浪费。
实现思路也不复杂:每次 pan 或者 zoom 结束后,计算当前视口对应的模型坐标包围盒,然后遍历所有节点,只保留与包围盒相交的节点及相关的连线,其余节点的display设为none。
visibleNodes = allNodes.filter(node => isRectIntersect( viewportRect, { x: node.x, y: node.y, w: node.width, h: node.height } ))实测效果立竿见影:1500 个节点的图,裁剪前平移缩放帧率 30~40,裁剪后稳定 60。代价是每次视口变化要多一次遍历计算,但filter的复杂度在几千量级下微乎其微。
这里面有个细节:连线的裁剪不能只看两端节点,如果两个节点都在视口外但连线的中间段穿过了视口,这条线就不能隐藏。我的粗粒度方案是:只要连线任一端点在视口内就显示,简单粗暴,对性能的影响可以接受(通常连线数不会超过节点数的两倍)。如果想更精确,可以额外做线段与矩形的相交检测,但性价比不高。
4.3 滚轮缩放与缩放聚焦
滚轮缩放是图形工具的标准操作。通常希望光标所在位置成为视觉缩放中心,也就是“看图的时候我想放大哪里,就要以哪里为中心”。这个效果的数学推导不复杂,但调试起来很顺利的原因在于我提前把坐标变换函数的单位测准了。
核心逻辑是:记录缩放前的鼠标位置对应模型坐标系中的点,缩放完后再次把该点映射回屏幕,看二者是否重合。如果不重合,就调整平移向量。
const scaleFactor = e.deltaY > 0 ? 1 / 1.1 : 1.1; const beforePoint = screenToModel(mouseX, mouseY); viewport.scale *= scaleFactor; viewport.scale = clamp(viewport.scale, 0.2, 4); const afterPoint = screenToModel(mouseX, mouseY); viewport.tx -= (afterPoint.x - beforePoint.x) * viewport.scale; viewport.ty -= (afterPoint.y - beforePoint.y) * viewport.scale;我一开始把tx/ty的符号搞反了,结果是每次滚轮缩放完,光标指向的内容会漂移一大截,完全没法用。后来逐步推导坐标系公式才纠正过来。这个 function 一旦做对,后面所有局部放大、聚焦编辑的体验都会变得很顺滑。
4.4 序列化与导入导出
数据驱动的好处,在序列化时体现得最彻底。因为我所有状态都集中在model这一份数据结构里,导出无非就是把对象转换成 JSON 字符串:
const json = JSON.stringify(model, null, 2);导入反过来,先解析 JSON,校验基本结构(有没有nodes、edges字段),然后重置画布状态并全量渲染。
序列化版本号的字段我也加了,因为后续加新功能会改变数据结构的字段名和含义,没有版本号的情况下老数据导入新版本会出现完全无法识别的情况。加了版本号以后,就可以写迁移函数,把旧版本数据逐步升级到最新结构。
还有一个导出图片的需求,这个直接用 SVG 的序列化方式做:克隆当前 SVG 节点,嵌入到一张<canvas>里,然后调canvas.toDataURL('image/png')下载。克隆前要把 style 里的外部 CSS 属性都用内联样式设置,否则导出的图片会出现样式丢失的问题。
4.5 小地图导航器
小地图(minimap)不是必须的功能,但对体验的提升非常明显,尤其是当图表很大,用户迷失在大画布里的时候。实现思路相当直接:
- 渲染一个缩略版的 SVG,等比例缩小整个模型。
- 在缩略图上绘制一个矩形框,表示当前视口范围。
- 拖动矩形框(或点击缩略图位置)时,反过来更新主画布的 viewport。
小地图和主画布之间是单向同步的关系:主画布变化则小地图更新,小地图操作则主画布跳转。这套逻辑接入我已有的 viewport 状态后,只花了一下午就做完了。它受益于“core 不依赖 render”的架构,不需要侵入任何渲染细节。
5. 常见问题与排查技巧实录
5.1 坐标漂移:视口计算中的经典谜团
症状:节点拖拽后,鼠标和节点的相对位置会缓慢偏移,缩放级别越极端偏移越明显。
排查过程我花了不少时间,最后发现是多因素叠加:一是 DOM overlay 的坐标需要经过getBoundingClientRect换算,而这个 rect 在页面存在滚动时会变化;二是我的screenToModel在内部某个分支里使用了旧的viewport,而不是最新值。两者叠加导致误差逐渐累积。
解决办法是统一封装坐标换算 API,所有事件回调里都从最新的 viewport 状态取数据,禁止在交互逻辑里缓存任何旧的 viewport 快照。排查这个问题的过程中,我还意外发现了一个边界情况:页面在 iframe 里且有缩放时,clientX的相对基准不再是 iframe 自身的坐标系,需要再考虑 iframe 的左侧偏移。这套东西测试时不容易覆盖,但真实使用中很可能遇到。
5.2 事件冒泡导致的误选中
症状:在画布空白处按下鼠标拖动,却会选中一个节点;或者拖拽节点时触发了画布平移。
原因是事件冒泡的时候,我一开始把 mousedown 同时绑在了 SVG 根节点和每个图形元素上。点击节点时,节点 mousedown 触发后冒泡到根节点,根节点也执行了平移启动逻辑,两者冲突。
解决办法:
- 在图形元素的 mousedown 里显式调用
stopPropagation()。 - 在根节点 mousedown 里判断
event.target是否等于根节点本身,只有点空白区域才启动平移。 - 记录按下时的目标元素 ID,mouseup 时如果不一致,不触发 click 动作。
顺带提一句,移动端浏览器里 click 事件会晚 300 毫秒触发(为了判断双击),所以拖拽和点击的判断不能依赖 click 事件,必须用 mousedown + mouseup 的距离差来综合判断。这个教训在 touch 设备上尤其重要。
5.3 SVG 文本模糊与高清屏适配
症状:画布缩放到非整数比例时,文字边缘有轻微模糊。
排查后发现是 SVG 文本在 1.25、1.5 等非整数缩放系数下,浏览器没有采用亚像素渲染,于是部分像素被填充在网格之间,看起来有毛边。解决方案有两个方向:
- 强制让文本使用整像素对齐(
text-rendering="geometricPrecision"可以缓解一部分情况)。 - 接受缩放模糊,但对倍数缩放(2x、0.5x)做特殊 round,让缩放结果恰好是整数倍,减少模糊感。
大多数用户对轻微模糊并不敏感,但作为工具类产品,体验的打磨就在这些地方。最终我选择在 scaling 结束后对 transform 的平移量做 snap 取整,避免非整像素定位:
viewport.tx = Math.round(viewport.tx); viewport.ty = Math.round(viewport.ty);这个方法二次解决了不少后续文字抖动的问题,值得记下来。
5.4 大量节点同时拖拽的卡顿
当用户框选了几十个节点然后一起拖动,如果每次 mousemove 都同步更新每个节点的 DOM 位置和每条关联连线,性能会瞬间垮掉。
我做了两件事:
- 把拖拽状态提到全局单例,渲染层只根据最新模型状态绘制,不做节点级缓存。
- mousemove 事件里只修改模型数据,把具体的 SVG 更新操作推迟到
requestAnimationFrame的 flush 阶段,且多个变更合并到同一帧。
实测下来,50 个节点同时拖拽从原来的卡顿(明显掉帧)变成了流畅(稳定 50 帧以上)。核心思路就是:交互逻辑和渲染逻辑彻底分离,交互只改数据,渲染统一收敛。
5.5 内存泄漏与事件绑定
图表编辑器一般会长时间驻留在一个单页应用里,内存泄漏问题会随时间累积而最终导致页面卡死。我最开始把事件绑定写在某个子组件里,但组件销毁时没有解绑,导致每次切换页面就多出一份事件监听的引用。
排查方式:打开浏览器 DevTools 的 Performance 面板,录制几轮“进入画布—退出画布—再进入”的操作,看内存曲线是否持续上升;用 Heap Snapshot 对比明显增长的引用,定位到了具体未销毁的对象。
解决办法并不复杂,但在实践里要严格遵守:所有动态创建的节点、定时器、事件监听器在destroy()中统一释放;SVG 元素直接移除时,如果绑定过事件,先移除事件再移除元素。这套规则我用了很久,后来项目长期挂载也不再肉眼可见地涨内存。
6. 经验总结与扩展方向
6.1 架构抽象要有,但别过度抽
一开始我为了“优雅”,给渲染层设计了一整套工厂模式、策略模式,每个节点类型一个类,每类连线一个渲染器,还把样式系统抽象成嵌套的 mixin 链。结果真正写代码时发现:大量抽象根本没有必要,反而让最简单的“改个颜色”都要跳三层函数。
后来我砍掉了一半抽象,换成“一个根部渲染函数 + 一个配置对象”的模式,简单直接。好的抽象是在真真切切遇到重复代码之后才去提取的,不要提前为未来几个月可能出现的需求设计接口。提前设计通常是过度设计。
6.2 数据模型版本化,从第一天就开始
我吃过的最大亏,是在现在这个项目早期导出了十几版 JSON 数据,但没加版本号。后来模型字段名做了一次调整,老数据全废,只能封锁所有旧版本。从那之后,所有导出的数据都带上"version": 1这类字段。
如果你的工具将来会开源或者被其他人长期使用,数据向后兼容非常重要。版本号加在序列化根节点上,是一切兼容性的前提。
6.3 从 diagram-design 到通用图形工具
diagram-design 当前已经具备了一个基础图形编辑器的完整骨架,再往下扩展可以做几件事:
- 节点类型扩展:图片节点、组件节点、iframe 嵌板,核心是给节点定义
type并分支渲染。 - 自动布局算法:把树形、层次、力导向布局作为一个可选插件接入,让用户点击“自动整理”就能理清大图。
- 预设主题系统:节点和线段的样式集中到 theme 对象,方便一键换肤。
- 协同编辑:接一个后端来做同步,前端把本地修改广播给其他人。
走到现在,让我对这类工具有一个很深的体会:它不是高不可攀的复杂系统,但也绝对不是一个周末能做完的小玩具。它的每一块技术点单独拿出来都能写一篇长文——坐标变换、图形渲染、事件交互、性能优化,每一样都需要你亲手调试出那层“屏幕与数据之间的翻译层”。
如果你也在做类似的东西,我最后给三条我自己的经验,不一定对所有场景适用,但确实让我少走了很多弯路:第一,数据模型一定独立于渲染层,任何时候都能纯靠数据重建界面;第二,所有坐标转换必须收敛到统一的工具函数,不要散落在事件回调里各自为政;第三,性能优化永远用数据说话,先压测再动手,别凭空猜瓶颈。先从一张 20 个节点的图开始把它做顺,再慢慢往里加东西,这比一开始就想着做一个全能编辑器要踏实得多。