diagram-design是我前段时间的业余项目代号。一句话说清楚:一个基于Web的拖拽式图表设计器。你可以在画布上拖节点、连线条、框选多选、缩放平移、撤销重做,最后把整张图导出成SVG或PNG。当时做这个项目,是因为团队里画架构图、链路图非常频繁,用通用绘图工具总觉得别扭——节点不能复用、样式不统一、改一次重画一遍。干脆自研了一套可嵌入内部系统的图表设计组件。这篇文章会把我的整体设计思路、技术选型、核心交互实现、性能优化和踩坑记录都摊开讲,适合想做类似画图工具、编辑器、或对可视化交互有兴趣的开发者参考。
1. 项目概述:为什么需要自研一个图表设计器
1.1 背景:通用工具解决不了团队的问题
事情要从一次日常需求评审说起。某团队负责内部系统维护,每次评审前都要准备最新的模块关系图。用什么画?桌面绘图软件、在线白板都试过,但总有几种别扭:
- 素材复用率低:同一个模块在A图里画了一次,到B图还要重新拖一个节点进画布,颜色、大小、样式经常对不上。
- 图和代码是割裂的:文档里贴的是旧图,代码已经重构了两轮,图没人同步更新。
- 难以上下沉:如果想把图嵌到内部系统某个页面里,通用工具的方案基本不可用,要么截图,要么嵌入整个白板页,体验都很差。
于是“diagram-design”的想法就诞生了:做一套可嵌入、可序列化、可扩展的图表设计组件。它不需要做到和完整设计软件一样强大的排版能力,但要做到“画图工具该有的都有”,同时让别人能把它当作一个前端库集成到自己的产品中。
1.2 核心目标与边界控制
给这个项目定义目标时,我定下了三个必须满足的点:
- 拖拽式交互:鼠标拖拽节点、连线、画布平移缩放都要顺畅,这是图表设计器的底线。
- 文档模型化:整张图由一份结构化的JSON描述,视图只是这份数据的投射。任何时间保存JSON,就能还原出完整画面。
- 可扩展性:支持自定义节点类型、自定义连线样式,后续要接自动布局、导出插件都不需要推翻重来。
同时我也刻意划掉了几个范围:第一版不做多人实时协同,不做智能排版对齐,不做像专业设计工具那样几十个快捷键的交互体系。先把单机版做到“好用且能嵌入”,后面再有协同需求时,数据模型已经留好了扩展空间。
2. 技术选型:先想清楚渲染和数据的底层关系
2.1 渲染层方案:SVG还是Canvas
画布类工具最常见的技术路线就是SVG和Canvas二选一。我一开始纠结了很久,最终第一版选了SVG。把两者放在一起对比,优劣势还是比较明显的:
| 维度 | SVG | Canvas |
|---|---|---|
| 节点数量适配 | 几百个以内很舒服 | 几千、上万个更合适 |
| 事件交互 | 节点天然绑定事件,命中检测免费 | 需要自己实现坐标命中检测 |
| 文本和样式 | 直接支持text、stroke、fill,样式灵活 | 文本绘制和样式处理相对麻烦 |
| 导出图片 | 原生序列化即可 | 需要额外转raster步骤 |
| 渲染更新 | DOM diff有一定开销 | 全量重绘可控 |
| 学习曲线 | 低,写过HTML就能上手 | 需要理解Canvas状态机、绘制循环 |
选择SVG的理由很直接:团队要快速交付,单张图节点的数量级也就是几十到几百个,SVG的开发效率和交互优势是碾压级的。如果未来真遇到几千节点的大图,数据模型不变,只把渲染层替换成Canvas或者WebGL实现就行。这也是“数据模型驱动视图”带来的最大红利——渲染层是随时可换的。
2.2 数据模型是灵魂,不是附件
很多人在做图表工具时容易犯一个错:一上来就写DOM,把节点坐标直接塞进浏览器元素里,交互逻辑绕了一圈发现数据散落各地,想加撤销都无从下手。
diagram-design从一开始就把“一份JSON完整还原一张图”当作硬指标。我把它类比成拍电影:节点和连线是演员,坐标是站位,视图是摄像机拍出来的画面。摄像机可以平移、缩放,但演员的站位数据不会因为摄像机移动而改变。只要剧本也就是JSON文档是完备的,什么时候重拍、换一个机位都能再现同一个场景。
这套思路在技术层面体现为三个原则:
- 视图层无状态:节点不会被抛来抛去,所有属性都存在文档模型里,DOM只是渲染结果。
- 所有操作都落到模型上:拖拽结束时修改模型里的节点坐标,然后触发一次重渲染。
- 序列化不是额外功能:模型本身就能序列化成JSON,不需要写一堆“将DOM转成JSON”的晦涩逻辑。
2.3 撤销重做:快照还是命令模式
小工具做撤销,最简单的是快照:每操作一步,把整个文档JSON存一份,撤销时回退到上一步。但diagram-design没有用这个方案,而是从一开始就选择了命令模式。
原因是我踩过类似的坑。快照方案在节点数量少时确实很爽,几行代码就能跑通。但等文档大了,每存一份快照就是一次几百KB到几MB的拷贝,高频操作时内存和性能开销都受不了。更重要的是,快照丢失了“操作意图”的信息——你只知道文档变成了什么样,不知道用户做了什么。
命令模式下,每次操作都是一个显式的命令对象:
// 移动节点的命令 { type: 'moveNode', id: 'node-a', from: { x: 100, y: 100 }, to: { x: 320, y: 180 } }撤销就执行from,重做就执行to。这个设计带来的额外好处是:后期做埋点、做协同、做操作回放,几乎不用重构代码,因为命令本身就是最好的日志。
3. 核心功能设计与实现拆解
3.1 节点和连线的数据模型落地
先看节点。一个节点最基本要包含id、类型、位置、尺寸、业务属性。
interface DiagramNode { id: string; type: string; // 'rect' | 'circle' | 'custom-service' x: number; y: number; width: number; height: number; props: Record<string, unknown>; // 自定义属性,渲染层自行解析 }连线比节点麻烦一些。它要记录连接的两端,以及可选的折线控制点:
interface DiagramEdge { id: string; source: string; // 起始节点id target: string; // 目标节点id sourceAnchor?: number; // 起始节点第几个连接点 targetAnchor?: number; // 目标节点第几个连接点 points?: Point[]; // 折线的中间控制点 style?: { stroke: string; strokeWidth: number; dashed?: boolean; }; }这里有个容易被忽略的细节:连线不存节点的实时坐标副本。很多初学者会把起点终点坐标直接写到边数据里,结果节点一移动,线就成了“断头线”。正确做法是只记录节点id和锚点索引,渲染时再根据节点的实时位置计算线的两端坐标。锚点用索引而不是硬编码坐标,也是同一个道理——节点移动后,锚点跟着节点的局部坐标系走,渲染前再换算成全局坐标。
我把整个图的数据模型定义成这样:
interface DiagramDocument { id: string; nodes: Map<string, DiagramNode>; edges: Map<string, DiagramEdge>; viewport: { x: number; // 视口平移量 y: number; scale: number; // 缩放比例 }; }nodes和edges用Map而不是数组,是为了O(1)查找节点。图片大了以后,每帧交互都要频繁按id查节点,数组的find效率在几百个节点时还能忍,上千个就明显变慢。
3.2 画布交互:坐标变换是核心中的核心
图表设计器里最容易写乱的就是坐标变换。不夸张地说,这里想清楚了,整个项目就通了三分之一。
首先明确两套坐标系:
- 世界坐标:节点在文档中的真实位置,也就是模型里存的x/y。
- 屏幕坐标:用户在浏览器里看到的坐标。
两者通过viewport换算:
// 世界坐标 -> 屏幕坐标 function toScreen(worldX, worldY, viewport) { return { x: worldX * viewport.scale + viewport.x, y: worldY * viewport.scale + viewport.y }; } // 屏幕坐标 -> 世界坐标 function toWorld(screenX, screenY, viewport) { return { x: (screenX - viewport.x) / viewport.scale, y: (screenY - viewport.y) / viewport.scale }; }看起来很简单,但一旦涉及“鼠标滚轮缩放”,坑就来了。如果你只改了scale不改offset,画面会往左上角或者右下角飘,鼠标指向的地方根本停不住。正确的做法是:缩放发生时,预先计算鼠标光标在世界坐标中的位置,缩放后再让这个位置保持不动。
具体推导是这样:
function onWheel(e) { const rect = canvas.getBoundingClientRect(); const mouseScreen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; // 1. 计算鼠标当前对应的世界坐标 const mouseWorldBefore = toWorld(mouseScreen.x, mouseScreen.y, viewport); // 2. 更新缩放值 const factor = e.deltaY > 0 ? 0.9 : 1.1; const newScale = clamp(viewport.scale * factor, 0.2, 3); // 3. 调整平移,让同一世界坐标点仍对准鼠标 viewport.scale = newScale; viewport.x = mouseScreen.x - mouseWorldBefore.x * newScale; viewport.y = mouseScreen.y - mouseWorldBefore.y * newScale; }这个公式我建议直接背下来,凡是做地图、画布、白板类工具都绕不开它。
3.3 拖拽节点与画布平移
拖拽交互用Pointer Events而不是Mouse Events,原因很简单:Pointer Events可以同时覆盖鼠标、触控笔和手指,而且支持setPointerCapture。
核心思路:
pointerdown时判断点击到的是节点还是空白画布。- 如果点到节点,进入节点拖拽模式,记录初始位置。
- 如果点到空白,进入画布平移模式,记录视口起始偏移。
pointermove时根据是哪种模式,分别更新节点坐标或viewport。pointerup时结束模式。
有个很关键的技巧:在pointerdown时调用canvas.setPointerCapture(e.pointerId)。这样即使鼠标拖出画布边界,浏览器也会持续把move/up事件派发给当前元素,不会出现“拖到一半断了”的体验问题。
拖拽节点时,我加了网格吸附:
const GRID_SIZE = 20; function snap(value) { return Math.round(value / GRID_SIZE) * GRID_SIZE; }移动后坐标newX = snap(newX),这样连线对齐、节点排列都会舒服很多。网格吸附看起来是小功能,但实际画图时,没有对齐辅助线之前,用户只能靠肉眼对半天。
3.4 框选与多选操作
框选(rubber band)是图表设计器的标配能力。实现不算复杂,但有几个细节要注意。
当用户在空白处按下并拖拽,我会画出一个半透明的选择矩形。然后在每次move时,遍历所有节点,判断节点矩形和选择矩形是否相交:
function intersect(a, b) { return !( a.x + a.width < b.x || b.x + b.width < a.x || a.y + a.height < b.y || b.y + b.height < a.y ); }这里用到的就是AABB轴对齐包围盒相交检测,节点不会旋转的情况下这个办法又快又准。
多选交互上,我遵循了几乎所有图形编辑软件的共同习惯:单击选中单个节点,Shift+单击追加或取消选择,点击空白清空所有选择,按住空白画布拖拽进行框选。这套交互逻辑不需要用户看文档就能猜出来,学习成本是最低的。
3.5 导出SVG和PNG
导出功能看似不起眼,实际上很影响“工具好不好用”的印象。
导出SVG最简单:把当前文档按照世界坐标重新渲染到一个独立的SVG根元素上,再用XMLSerializer序列化,最后拼成下载链接。注意要手动加上xmlns命名空间,否则某些看图软件打不开。
导出PNG稍微绕一点。我的做法是先把SVG字符串转成Blob,用URL.createObjectURL生成一个图片URL,再创建Image对象等待加载,最后绘制到Canvas上调用canvas.toDataURL('image/png')。
这里有个大坑:如果SVG内部引用了外部图片或在线字体,导出时浏览器会因为跨域或者加载时机问题,在图片还没加载完时就提前绘制,结果导出PNG是空白。解决方案是在导出前预加载所有图片资源并内联成base64,字体样式写成内联CSS,确保整个SVG自包含。
4. 实操过程:从零手写一个最小可用的diagram-design核心
4.1 最小骨架:先把文档渲染出来
一个可用的图表设计器,核心代码其实可以压缩到非常小。我先写一个最简单的渲染逻辑:读取文档模型,生成SVG。
const doc = { nodes: [ { id: 'a', type: 'rect', x: 100, y: 100, width: 120, height: 60, props: { label: '用户服务' } }, { id: 'b', type: 'rect', x: 360, y: 100, width: 120, height: 60, props: { label: '订单服务' } } ], edges: [ { id: 'e1', source: 'a', target: 'b' } ] }; function renderDocument(doc) { const svg = document.getElementById('canvas'); svg.innerHTML = ''; for (const node of doc.nodes) { const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', node.x); rect.setAttribute('y', node.y); rect.setAttribute('width', node.width); rect.setAttribute('height', node.height); rect.setAttribute('fill', '#eef2ff'); rect.setAttribute('stroke', '#333'); rect.dataset.id = node.id; svg.appendChild(rect); } for (const edge of doc.edges) { const a = doc.nodes.find(n => n.id === edge.source); const b = doc.nodes.find(n => n.id === edge.target); const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', a.x + a.width / 2); line.setAttribute('y1', a.y + a.height / 2); line.setAttribute('x2', b.x + b.width / 2); line.setAttribute('y2', b.y + b.height / 2); line.setAttribute('stroke', '#666'); svg.appendChild(line); } }这一步的核心是建立“模型到视图”的单向流:模型变了,视图重渲染。不要在这里顺手加各种奇技淫巧,先把这条主链路跑通,后面所有交互都往这条链路里加。
4.2 接入视口变换与缩放平移
有了渲染基础,第二步就是让画布可以缩放平移。我在项目里把viewport做成一个类:
class Viewport { constructor() { this.x = 0; this.y = 0; this.scale = 1; } toScreen(wx, wy) { return { x: wx * this.scale + this.x, y: wy * this.scale + this.y }; } toWorld(sx, sy) { return { x: (sx - this.x) / this.scale, y: (sy - this.y) / this.scale }; } }渲染时,给根SVG加一层transform或者直接对渲染出的坐标做换算。我更推荐直接换算坐标,因为后面做节点命中检测时逻辑更简单——所有节点在世界坐标中计算,只有到最后输出DOM时才转屏幕坐标。
实现缩放时,就用前面讲的鼠标锚定公式。实测下来,在普通笔记本上,200个节点的图来回缩放平移非常顺滑,指针始终稳定指在目标节点上,完全不会飞走。
4.3 节点拖拽、网格吸附与性能实测
节点拖拽就是“三步走”:
- pointerdown命中节点,获取节点在世界坐标中的原始位置,记录鼠标起点。
- pointermove里计算鼠标在世界坐标中的位移,更新节点坐标并吸附网格,触发重渲染。
- pointerup清理监听器。
渲染频率要控制住。我一开始直接每次move都重建整个SVG,200个节点时还能跑,600个节点就开始肉眼可见的卡。后来改成用requestAnimationFrame合并渲染,也就是一帧内无论触发了多少次move,只更新一次界面:
let rafId = null; function requestRender() { if (rafId) return; rafId = requestAnimationFrame(() => { renderDocument(doc); rafId = null; }); }这一行改动对性能的提升非常明显。我统计过一组数据:
| 节点数量 | 不合并渲染 | 合并渲染 | 体验结论 |
|---|---|---|---|
| 200 | 大概30fps | 满帧60fps | 日常画图非常流畅 |
| 800 | 卡顿明显 | 40fps左右 | 还能接受,建议局部更新 |
| 2000 | 不可用 | 20fps以下 | 必须换Canvas或做局部渲染 |
到了2000节点,SVG的DOM diff成本已经没法忽视了。此时有两条路:一是对改变的目标做局部属性更新,而不是全量重建DOM;二是直接把渲染层替换成Canvas。我的实际建议是:如果预期节点数长期超过1000,还是尽早切Canvas,SVG撑到最后一定是瓶颈。
4.4 局部更新优化:不重建,只patch
用SVG还有个可用的中间态优化方案:拖拽过程中不需要重建整棵DOM树,只更新被拖动节点的x/y属性,以及连到它的那几条边。这相当于给SVG做“定向patch”。
实现思路是给每个节点DOM元素打上>