很多人第一次看到这个项目名“diagram-design”的时候,第一反应都是:这又是一个画流程图的轮子?其实不完全对。这是我最近大半年里一直在打磨的一个纯前端图表设计与编辑模块,你可以拖拽方块、连线、加箭头、改文案、调整配色,最后导出成 PNG 或 SVG。它不是某个开源框架的封装,而是从零开始实现的一套画布交互体系。
这玩意儿到底能干什么?说白了,如果你的产品里需要嵌入一个“能画图的小白板”——比如低代码平台的流程编排、后台系统的架构图设计器、教学工具里的概念图编辑区——你都会遇到同样的问题:现成的在线绘图工具要想塞进自己的产品里,要么太重、要么定制困难、要么授权成本高。这个项目就是冲着这个缺口去的,用原生 JavaScript + SVG 实现,不依赖重量级框架,核心代码量可控,适合想搞懂图表编辑器底层原理、或者需要在产品里自研绘图能力的同学参考。
1. 需求拆解:所谓的“图表设计”,本质上是做一套轻量级的绘图交互引擎
1.1 一个绘图模块到底包含哪些能力
我在动手之前,先把自己用过的各种绘图工具挨个打开用了一遍,然后列了一个功能清单。你要画一个节点,围绕节点有创建、选中、拖拽、编辑文本、缩放、旋转这些操作;你要画一条连线,围绕连线有从哪个锚点出发、经过什么路径、用什么样式、箭头怎么画、和节点连动后怎么更新这些细节。再往下挖,画布本身还需要平移、缩放、多选、框选、撤销重做、对齐辅助线、网格吸附这些基础设施。这还没算导出图片、保存 JSON 这类和业务强相关的功能。
如果你只是临时画一张示意图,那市面上任何免费在线工具都够用。但如果你要做一个“嵌入产品内部的图表设计器”,核心从来不是“能画”,而是“能编”——数据模型和画布上的图形要实时双向同步,用户每拖一下、每改一个文字,底层的数据都要同步变化,这样才能保存、恢复、对接业务流程。这个认知直接决定了整个项目的架构方向:它不是一个画图工具,而是一个图形化编辑器内核。
1.2 自研的理由:为什么不能直接拿来主义
我也不是一开始就想自研。前期我考察过三类现成方案:第一类是开源图编辑框架,功能很全,但学习成本高,而且版本迭代快的框架在定制深层交互时非常痛苦;第二类是各种图表库,擅长渲染统计图表,但做编辑交互很弱,基本不支持拖拽连线和节点自由布局;第三类是嵌入第三方在线设计工具,体验确实好,但和自己的业务系统集成时,权限、数据安全、界面风格全都难以控制,还要考虑外部服务可用性。
最后真正推动我自研的,是一个再简单不过的需求:我们的业务场景里,节点和连线需要携带大量自定义业务字段,比如审批节点要绑定审批人、判断节点要绑定条件表达式。这些字段必须跟随每个节点存储,并且和用户操作实时联动。外包方案里,数据存在对方那里,字段得通过对方 API 去扩展;开源框架里,数据模型是它定死的。两种都绕。自研看似成本最高,但一旦驾驭了画布交互的底层逻辑,后面所有定制都变成纯粹的“写业务代码”,反而痛快。
1.3 这个项目不是重复造轮子,而是拆轮子
总有人觉得自研一个绘图组件是“重复造轮子”,我的看法刚好相反:当你的业务逻辑是围着图形转的时候,绘图组件就是产品核心价值的一部分了,它不该是一个黑盒依赖。就拿节点上那排小图标来说,需求方今天说放左边,明天说放右边,后天说要新增一个删除按钮。用现成框架,你得跟框架的事件体系做各种桥接;自己写,这不过就是在节点组里追加一个子元素而已。我做完这个项目最明显的感觉是,所谓“造轮子”其实是一次“拆轮子”,拆清楚之后,做任何定制都不心虚。
2. 技术方案选型:为什么是 SVG 而不是 Canvas,为什么用原生 JavaScript
2.1 渲染层选型的核心权衡
图表编辑器的技术选型,第一个岔路口就是 SVG 和 Canvas。Canvas 适合成千上万个节点的超大图渲染,性能好,但所有交互命中检测都要自己算;SVG 的每个图形都是 DOM 元素,天然支持事件绑定、CSS 样式、浏览器开发者工具调试,缺点是节点数量大了以后性能下降。
我的场景是典型的“中等规模图编辑”:一个画布上几十到几百个节点,交互频繁,用户会不断拖拽、编辑文本。这个规模下,SVG 的性能完全不是问题,但它的开发效率优势却非常明显——我不用自己实现点击命中检测,不用手动管理重绘区域,甚至节点的文字编辑可以直接内嵌 HTML 来做到所见即所得。最终的结论是:SVG 做交互编辑器、Canvas 做大图渲染器。如果要处理几千上万个节点的场景,可以在数据层共用的情况下,单独把渲染层替换成 Canvas 或 WebGL,这是后话。
2.2 没有选择成熟框架的另一个原因:可控性
我知道很多工程师会用成熟的图编辑库来省事,但在实际项目里,一个不可控的依赖往往成为压垮排期的最后一根稻草。比如我需要实现“节点上的端口位置随着节点宽高动态计算”,在成熟框架里这可能要阅读大量源码,搞清楚它的数据流和渲染时机;自己写的话,无非就是每次节点尺寸变化时重新算一遍端口坐标,然后调用一次路径刷新函数。
另外还有一个现实原因:模块体积和加载性能。我只需要画布编辑能力,但许多成熟框架把分析、布局、图算法都打包了,为了几个功能引入一大坨代码实在不划算。自研方案把核心代码控制在有限的规模内,首屏加载毫无压力,移动端也能跑。
2.3 整体架构:三层分离
我把它分成三层来设计。最底层是数据模型层,只管节点、连线、画布视图状态这些数据,不关心渲染;中间是渲染层,负责把数据模型变成 SVG 元素,并为每个元素维护对应的数据引用;最上层是交互层,处理鼠标、键盘、滚轮事件,转化成对数据模型的修改,再由数据模型的变化驱动渲染层更新。
这样分工的好处是:用户拖拽节点时,事件处理器只更新数据;性能优化时,可以把渲染更新放在 requestAnimationFrame 里合并执行,避免每次微小操作都导致全量重绘。我实际跑下来,即便不加虚拟化,在几百个节点的情况下拖拽缩放都还算顺滑。
3. 核心功能实现拆解:从画布坐标系到连线路由的完整细节
3.1 画布与坐标系:一切交互的地基
画布编辑器最容易踩的第一个坑就是坐标系混乱。屏幕坐标系、SVG 内部坐标系、经过 transform 变换后的视图坐标系,这三者如果不统一,你实现的拖拽一定飘。
我的做法是在 SVG 根元素上挂一个视图变换矩阵 viewTransform,它包含平移量 tx、ty 和缩放比例 scale。所有节点和连线的坐标都以“世界坐标”存储,也就是未经过缩放和平移的坐标。渲染的时候,把世界坐标乘上视图变换矩阵,显示到屏幕上。这样节点的位置数据永远是稳定的,缩放平移只影响视图,不影响数据。
涉及交互的地方,一定要把屏幕坐标换算回世界坐标。换算公式很简单:世界X = (屏幕X - tx) / scale,世界Y = (屏幕Y - ty) / scale。很多问题,比如缩放之后拖拽节点跟不上鼠标、点击连线永远点不准,都是因为丢了这个换算步骤。
3.2 节点渲染:让一个“方块”具备完整的编辑器能力
一个节点在 SVG 里可以由几个元素组合而成:矩形做底板,文本做标签,几个小圆点做端口,再加一个隐藏的输入框用于编辑文本。把这些元素包在同一个 group 里,拖拽就只移动 group,子元素跟着走。
我建议把节点的每个区域都拆成独立元素,不要在一个矩形上既当显示又当交互热区。底板矩形只管背景和边框;文本元素只负责显示文字,双击它的时候才切换成可编辑状态;端口圆点单独绑定鼠标事件,鼠标移上去就扩大命中区域,方便连线。现实里很多编辑器难用,就是命中区域太小,用户拿鼠标很难精确够到那 6 像素的小圆点。
关于文本编辑,有一个经验可以分享:不要用 SVG 自带的文本编辑机制,兼容性不好。我在每个节点内部隐藏了一个 textarea 元素,双击时让它变透明覆盖在文本上方,用户在里面输入,输入内容实时同步回文本元素。用 textarea 的好处是各种输入法、移动端键盘、复制粘贴都不会出怪问题。
3.3 连线和路由:最考验工程细节的部分
连线比节点复杂一个量级。它至少要有以下几个能力:起点和终点跟随节点移动、线的样式可配置、线上可以加文本标签、点击线之后能选中并删除。我的数据模型里,一条连线存的是起点节点 id、终点节点 id、起点端口、终点端口、路径形状、样式配置。渲染时,每次起点或终点节点的位置变化,都触发一次路径重算。
路径重算的核心是计算贝塞尔曲线的控制点。最简单也最常用的是三次贝塞尔曲线:起点和终点的坐标已知,控制点沿着起点的方向延伸一段距离,再从终点的方向延伸一段距离,曲线自然弯过去。这个距离我一般取起点到终点水平距离的一半,但不小于一个固定值,这样横跨很远的两条线不会挤在一起。
function computeCurvePath(source, target) { const dx = target.x - source.x; const dy = target.y - source.y; const distance = Math.sqrt(dx * dx + dy * dy); const offset = Math.max(40, distance * 0.5); const cp1 = { x: source.x + offset, y: source.y }; const cp2 = { x: target.x - offset, y: target.y }; return `M ${source.x} ${source.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${target.x} ${target.y}`; }只用这个公式处理横线还行,但纵向连线和反向连线会很难看。后来我加了一个方向判断:如果起点在终点的左边,控制点就往右偏;如果在右边,就往左偏;如果在正上方或正下方,则让控制点往垂直方向偏。这个逻辑看起来简单,但对视觉体验的提升是质变。节点下方往上方连接时,曲线要像倒 U 一样绕上去,而不是直接横着插过去。
3.4 交互细节:拖拽、框选、缩放这三道坎
拖拽节点看似简单,实际上涉及“按下——移动——抬起”的完整状态机。在 pointerdown 时,要判断你点的是节点、是端口、还是空白区域;如果点在节点上,记录鼠标起始位置和节点起始坐标;在 pointermove 时,世界坐标换算后加上偏移量,更新节点位置;在 pointerup 时,判断这是一次“拖拽”还是一次“点击”,然后决定是否进入编辑状态。
框选和画布平移会打架,必须用 modifier 键区分:直接拖拽空白是框选,按住空格或中键拖拽是平移画布。框选的范围框是一个绝对定位的矩形,拖动时实时计算屏幕坐标范围,然后遍历所有节点,判断世界坐标是否落入框内,命中的节点全部进入选中状态并高亮。
缩放我默认用滚轮控制,以鼠标所在位置为缩放中心。这个细节很影响手感——如果以画布中心缩放,你滚轮想放大看某个角落,内容反而飞走了。以鼠标为中心的缩放实现其实很简单:鼠标位置对应的世界坐标在缩放前后要保持一致,解方程得到新的平移量。
function zoomAt(mouseScreenX, mouseScreenY, newScale) { const worldX = (mouseScreenX - tx) / scale; const worldY = (mouseScreenY - ty) / scale; scale = newScale; tx = mouseScreenX - worldX * scale; ty = mouseScreenY - worldY * scale; }4. 实操记录:从搭建项目到完整跑通核心流程
4.1 初始化项目与画布
我建这个项目时没有引入任何构建工具,就是一个静态 HTML 文件加几个 JavaScript 模块,方便调试也方便后续集成。页面里放一个全屏的 SVG 元素,SVG 内部需要有一个主 group,所有节点和连线都挂在这个主 group 里。主 group 上一律不写样式,因为全画布的 transform 统一放在父级 group 上。
初始化时分三步:先把视图变换矩阵设置成默认值,然后渲染空画布背景,最后绑定全局事件。事件绑定要绑在 SVG 的父容器上,而不是 SVG 本身,因为拖拽操作经常会把鼠标移到 SVG 外侧,如果事件绑在 SVG 上,一旦移出边界就丢事件了。绑在父容器上,哪怕鼠标移动到页面空白处,事件依然能继续触发,拖拽不会断。
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const container = document.getElementById('canvas-container'); svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); container.appendChild(svg); const viewport = document.createElementNS('http://www.w3.org/2000/svg', 'g'); svg.appendChild(viewport); viewport.setAttribute('transform', `translate(${tx}, ${ty}) scale(${scale})`);4.2 节点数据建模与渲染同步
数据模型我用了一个简单的结构。一个 Node 对象包含 id、x、y、width、height、text、style、ports、data 这些字段。data 就是业务自定义字段的容器,用户绑定什么业务数据都往这里塞。渲染层维护一个 id -> SVG group 的映射表,方便根据数据快速找到对应的图形。
function renderNode(node) { const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); g.dataset.id = node.id; g.setAttribute('transform', `translate(${node.x}, ${node.y})`); const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('width', node.width); rect.setAttribute('height', node.height); rect.setAttribute('rx', 4); rect.setAttribute('fill', node.style.backgroundColor || '#fff'); rect.setAttribute('stroke', node.style.borderColor || '#333'); g.appendChild(rect); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.textContent = node.text; text.setAttribute('x', node.width / 2); text.setAttribute('y', node.height / 2); text.setAttribute('text-anchor', 'middle'); text.setAttribute('dominant-baseline', 'central'); g.appendChild(text); viewport.appendChild(g); node.g = g; }这里我用 transform 来定位节点。每次修改节点坐标,只需要调用一个 setter 函数,更新 group 的 transform 属性即可,不用重新创建 DOM。文本内容变化就单独更新 text 元素的值。这种“数据驱动、局部更新”的模式,是整个编辑器流畅性的基础。
4.3 连线的交互与样式配置
连线从交互上分成两种:已存在的连线和正在绘制中的临时连线。临时连线从端口按下开始,鼠标移动时实时更新一个虚线路径;鼠标松开在目标端口上,就创建正式连线。创建连线的核心是把端口坐标换算成世界坐标。
连线的渲染同样用一个函数 setter:传入连线数据,计算路径,更新 d 属性。有一条坑要提醒:SVG 的 path 元素 d 属性更新频繁时,不要直接改字符串然后重新赋值,而应该把用于拼接路径的数值组成一个数组,最后统一 join,避免频繁产生超大字符串触发性能瓶颈。另外,线被选中时,要给它添加一个更大的透明命中路径,否则细线很难用鼠标点到。
4.4 导出 PNG 和 SVG 的方案
导出 SVG 比较简单,因为内容本来就是 SVG 元素。序列化的时候要注意,style 属性有的浏览器序列化不稳定,最好在导出前把所有需要的样式都设置成显式属性。我先序列化 SVG 文本,然后用 Blob 生成一个可下载的链接。
导出 PNG 需要把 SVG 画到 Canvas 上。核心步骤是把 SVG 转成 data URL,然后 new Image 加载它,图片加载完成后调用 canvas.toBlob 得到 PNG。这中间有两个典型问题:一是 SVG 里如果引用了外部字体或图片,Canvas 绘制时可能存在跨域问题和字体加载延迟;二是图片尺寸要根据缩放比例放大,否则导出的 PNG 是模糊的。我实际测试后,导出的图片宽高要乘以设备像素比,再给 canvas 设置对应大小,生成图片才有清晰度。
async function exportPNG(svgElement, scale) { const xml = new XMLSerializer().serializeToString(svgElement); const svg64 = 'data:image/svg+xml;base64,' + btoa(xml); const img = new Image(); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = svg64; }); const canvas = document.createElement('canvas'); canvas.width = svgElement.clientWidth * scale; canvas.height = svgElement.clientHeight * scale; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); return canvas.toDataURL('image/png'); }5. 踩坑实录:坐标系、命中检测和性能优化,这几个问题我花了最多时间
5.1 坐标系混乱是最隐蔽的坑
前面说坐标系是地基,实际开发中它也是最容易反复出 bug 的地方。拿删除节点来说,用户点击删除按钮后,节点被移出了数据列表,但连线两端的 id 还引用着被删的节点,下一次渲染连线时,找不到坐标就直接崩溃。这个问题排查到最后,发现根因还是数据模型里连线和节点的关联没有维护好。解决方法是删除节点前,先遍历所有连线,把以该节点为端点的连线全部移除,并且这个逻辑要放在数据层做,渲染层不应该感知。
还有一次,用户反馈拖拽节点时会卡顿。排查下来发现,是因为我在 pointermove 里不仅有更新节点本身的位置,还顺手重算了全部连线的路径。其实只有与该节点关联的连线需要重算,其他线根本不用动。后来我把“和节点关联的连线”建了一个索引,每次更新节点只刷新相关连线,性能立竿见影。
5.2 命中检测:细线和端点的可用性决定了编辑器的下限
实话说,我一开始觉得画一个节点、连一条线没什么难度,真正把用得顺手才算过关。细线难点、端点太小、点和线互相遮挡,这些都是用户高频吐槽的点。我做的第一个改进是所有可交互元素都有一个扩大的命中区域。线的命中路径用透明的宽线段覆盖;端口圆点周围再加一个更大的透明圆来接收鼠标事件;节点的选中操作需要判断你点的是文字还是背景,如果点的是文字,要优先触发文字选中的逻辑。
第二个改进是 hover 反馈。鼠标移动到某个可操作元素上,光标形状变化,元素自身加一圈高亮描边。这玩意儿很影响“专业感”,哪怕功能没变化,交互反馈到位了,整个编辑器会显得成熟很多。
5.3 缩放平移时的视觉卡顿优化
缩放和平移本质上是修改 transform,然后触发整个 SVG 重绘。如果节点数量多,每一帧都要重绘几百个 DOM 元素,低端设备就掉帧。我用了两个优化方案:第一,缩放过程中不实时更新节点内部子元素的样式,只在缩放结束后统一刷新一次;第二,平移过程直接用 CSS 属性 transform 来做,并且开启 GPU 加速,让浏览器合成器去处理移动的部分,而不是重新执行 JavaScript 计算。
还有一个细节:SVG 里的文字在缩小到很小的尺度之后,会糊成一团,影响观感。我的处理是当画布缩放比例低于某个阈值时,把文字隐藏,只显示方块和颜色,同时在右上角提示“缩小至 X%,文字已隐藏”。很多绘图工具都有类似行为,用户其实能接受,但你要主动告诉用户,而不是默默消失。
5.4 撤销重做:必须从数据层面实现
我最初偷懒,把撤销重做做成了“保存当前 SVG 字符串”,结果内存占用暴涨,而且用户拖拽过程中的瞬时状态也被记录进去了,一个拖拽动作产生了十几条历史记录,体验很怪异。后来改成真正数据层的历史快照:每次操作开始时,记录当前数据模型的 JSON 快照,操作结束时再记录一次。撤销就是回到上一个快照,重做是回到下一个快照。
关键点是合并连续操作。比如用户拖动一个节点,拖动过程中不该记录每一个像素级别的变化,应该只在鼠标抬起时记录一次最终位置。我实现了一个简单的 transaction 机制:操作开始调用 beginTransaction(),结束调用 commitTransaction(),中间的所有数据修改只在结束时产生一条历史记录。
5.5 文本编辑时的滚动和自动换行问题
文本编辑在桌面端和移动端差别巨大。桌面端输入中文时 textarea 会挡住节点,我让它在编辑状态下覆盖整个画布区域,但用 viewport 定位,看起来就像原地编辑。移动端的问题是软键盘会顶起页面,导致画布位置错乱,需要监听 resize 事件做一次坐标校准。
文本自动换行是另一个老大难。SVG 的 text 元素不支持自动换行,我最开始用 tspan 实测发现维护换行数据太麻烦。后来直接用 HTML 元素来做文本显示层,通过 foreignObject 嵌入 HTML 段落。这样文字排版交给浏览器的排版引擎,所有 CSS 样式都能用,编辑器的文本编辑能力直接升级。
5.6 浏览器兼容性问题
这类项目最容易忽略的是 pointer events 在不同浏览器的差异。新版浏览器都支持 PointerEvent,但老一些的浏览器对 pointerdown 的处理和 touch 事件响应不同。我统一用 PointerEvent 处理鼠标和触控,pointermove 之前要调用 setPointerCapture,这样即使手指或鼠标移出元素范围,事件也不会丢失。
另外,exportNode 的序列化过程在 Firefox 里有个坑:XMLSerializer 会把 namespace 声明加在奇怪的位置,导致生成的 SVG 无法被浏览器直接预览。后来我自己写了一个简单的 serialize 函数,手动拼接 XML 字符串,反而干净利落。
6. 进阶扩展思路:这个设计还可以往哪些方向走
6.1 从“编辑器”到“图形化业务设计器”
核心画布能力稳定之后,接业务就非常顺了。我在这个项目基础上做了一个审批流程设计器,节点扩展成“审批节点”“条件节点”“抄送节点”,数据字段里绑定审批人和条件表达式。因为底层数据模型是完全独立的,业务层怎么扩展都行,不需要动画布引擎。
6.2 用 Worker 分担大型图布局的耗时
节点多了以后,自动布局算法可能会阻塞主线程。比如树形布局算法在几百个节点时还有可感知的卡顿。后续可以把布局算法丢到 Web Worker 里执行,计算完布局结果再回传到主线程更新数据模型。数据和渲染分离的架构在这里又体现了一次优势:布局算法只需要操作数据,和渲染无关。
6.3 引入模板系统
有了模板系统,用户可以直接从侧边栏拖一个模板图进来,然后在此基础上改。我实现模板系统的方式是把模板图和当前图的数据做合并:模板的节点 id 全部重新生成,避免和已有节点冲突。这听起来简单,但处理连线引用时要注意,模板内部的连线也要跟着重生成 id。
6.4 多人实时协作的可行性
现在的架构是单机内存模型,没有服务端。要做多人协作,最合理的方向是 CRDT 或 OT,在数据模型层接入状态同步。这块工作量和画布渲染完全解耦,属于“数据层增强”,不会反过来修改渲染逻辑。正因为一开始没把业务逻辑写在视图层里,才能这么放心地扩展。
最后分享一个我自己最受用的小技巧
如果让我只说一个建议,那就是在动手写第一行渲染代码之前,把数据模型定义得足够干净。我踩过最大的坑就是把 position 和 size 拆成两个扁平字段,导致每次修改一个维度都产生冗余历史快照。后来我把它们收敛成 x、y、width、height 四个基础字段,其他派生状态一律不存。就是这么简单的一个梳理,让整个项目的稳定性提升了一大截。
还有一个贴心提示给后来者:如果你只是需要在产品里画简单的思维导图或架构示意图,完全没有必要一上来就自研。先评估成熟方案能不能满足你 80% 的核心需求,剩下的 20% 是否值得你投入几个月去打磨。如果你和我一样,业务高度依赖图形化的自定义编辑,那这个 chart 设计引擎做下来,它带给你的不仅是一个组件,更是一整套对交互、数据、渲染的掌控力。这是任何现成依赖都给不了你的底气和自由。