接手内部工具链里的 diagram-design 模块,是在半年前。当时需求听起来很朴素:把一堆流程节点拖到画布上,拉线连起来,然后导出成图片。我起初以为这不过是“拖拽 + 连线 + 导出”三件事,真正做完才发现,diagram-design 的核心难点根本不在 UI 特效,而在数据模型、坐标系换算、增量渲染和一堆边界条件。这篇文章把我这半年踩过的坑、验证过的方案、以及最终沉淀下来的实现思路完整整理出来。如果你是前端开发、可视化工程师,或者正准备做类似的图表设计工具,可以按这条路线直接开始。1. 动手之前,先搞清楚要做的是哪一类图表工具1.1 编辑器型与渲染器型的本质区别很多人拿到 diagram-design 这个标题,第一反应是“画图”,但画图和你实际要做的工具之间,隔着一整条设计决策链。我把它分成两类:渲染器型:把已有的图数据渲染成静态或半静态画面,比如拓扑图展示、依赖关系图、架构图预览。这类工具交互少,核心追求是“万级节点不卡”。编辑器型:用户要拖节点、连线、改属性、撤销重做、保存再打开。核心追求是“操作正确且反馈实时”。diagram-design 这个名字天然属于编辑器型。为什么这个判断重要?因为两类工具在选型上的取舍完全不同:渲染器可以为了性能牺牲一部分交互精确度,编辑器则必须优先保证“拖拽时节点跟着手指走”这类基础体验正确,性能反而要靠裁剪、分层等手段去补。当时我把需求拆完,确定了一个边界:单图画布上最多放 2000 个节点,编辑响应延迟不能超过 100ms,导出图片要清晰可打印。这个边界直接决定了下文的几乎所有技术选择。1.2 渲染方案:SVG、Canvas,还是混合渲染方案是最早要拍板的决策。我试过纯 Canvas、纯 SVG、Canvas 铺底 + SVG 覆盖三层结构,最后稳定在了“混合”方案上。先看对比:维度SVGCanvasWebGL节点数量适合 500 以内交互场景适合 2000 以上的纯绘制适合上万级别的复杂图事件/命中检测原生支持,直接绑定元素需要手写坐标换算和命中判断更难命中检测样式控制依赖 CSS,方便需要代码重绘Shader 编程文本渲染清晰,缩放不虚缩放会模糊需要特殊处理数据更新增量更新灵活容易整幅重绘局部更新复杂我最终的选择是:背景网格用 Canvas 画,节点和连线用 SVG 渲染,再在最上层叠加一个透明的交互层。理由是编辑器的交互密度很高:拖拽时要知道鼠标是否按在节点上、是否靠近某个端口、是否落在空白区域。SVG 元素的 hit area 和事件分发是浏览器原生能力,你不需要自己实现四叉树和射线判断,这对第一版上线很有价值。Canvas 虽然绘制性能更好,但所有命中检测、局部刷新、事件冒泡都要手写,排错成本会高一个量级。至于 WebGL,适合真正的重型图可视化,比如引擎依赖图、大规模关系图谱。普通 diagram-design 场景用 WebGL 属于用大炮打蚊子,还会引入 Shader 编译、纹理更新等一整套复杂度。1.3 数据模型是地基,先把它定下来很多人在画布上画了几十行代码之后才发现数据模型不对,要推倒重来。我建议第一件事就是定义清楚图数据结构,而且必须用 JSON 可序列化、可版本化的方式。我最终用的核心结构是:interface DiagramGraph { id: string; nodes: DiagramNode[]; edges: DiagramEdge[]; } interface DiagramNode { id: string; type: string; // "start" | "process" | "end" ... x: number; // 世界坐标 y: number; width: number; height: number; ports: Port[]; // 连接点 attrs: Record<string, unknown>; // label、color、样式等 } interface Port { id: string; dx: number; // 相对节点左上角的偏移 dy: number; direction: "top" | "right" | "bottom" | "left"; } interface DiagramEdge { id: string; source: { nodeId: string; portId: string }; target: { nodeId: string; portId: string }; attrs?: Record<string, unknown>; }这里有一个我用数据换来的教训:Edge 不要存绝对坐标,只存连接关系。早期我觉得“边不就是一条线嘛,把起点终点坐标存下来不就完事了”,结果节点一移动,旧的坐标全部失效,要么边不跟着走,要么得写一堆联动更新逻辑。改成引用nodeId + portId之后,边的渲染位置永远在渲染时实时计算,节点移动、缩放、旋转都不需要额外维护边的数据一致性。2. 画布坐标系:几乎所有交互问题的源头2.1 屏幕坐标与世界坐标的一对一换算diagram-design 里绕不开一对概念:世界坐标(World Coordinate)和屏幕坐标(Screen Coordinate)。世界坐标是业务里的“真实位置”,比如某个节点放在 (100, 100) 处,不管你在屏幕上怎么缩放、怎么平移,这个值都不变。屏幕坐标是用户最终看到的像素位置,它会随着视口变化而改变。两者通过一个视口变换关联起来。最朴素的做法是维护三个变量:缩放系数zoom,水平偏移tx,垂直偏移ty。换算关系如下:interface Viewport { zoom: number; tx: number; ty: number; } function toScreen(p: Point, vp: Viewport): Point { return { x: p.x * vp.zoom + vp.tx, y: p.y * vp.zoom + vp.ty, }; } function toWorld(p: Point, vp: Viewport): Point { return { x: (p.x - vp.tx) / vp.zoom, y: (p.y - vp.ty) / vp.zoom, }; }这段代码看起来简单,但它是整个画布所有交互的地基。节点渲染位置、连线路径、鼠标点击坐标、拖拽位移,全部要经过这一层换算,任何一步直接在屏幕坐标上做加法,都会在缩放后出现漂移。2.2 为什么拖拽时手感会“发飘”拖拽节点是第一个需要正确使用坐标系的交互。我当时踩过一个很典型的坑:mousedown 时记录鼠标在屏幕坐标下的位置,mousemove 时把位移量算出来后,直接除以zoom再加到节点坐标上。初看好像没问题,但用户把画布缩放到 200% 之后拖拽,节点要么跑得比鼠标快,要么跟不上,甚至会出现抖动。原因是屏幕坐标下的位移换算到世界坐标,应该用“转变换”,而不是“除以缩放系数”。正确的做法是:mousedown 时同时记录鼠标的世界坐标和节点的世界坐标,mousemove 时重新把鼠标屏幕坐标转到世界坐标,然后计算世界坐标的增量,加给节点。let startWorld: Point; let startNodePos: Point; function onPointerDown(e: PointerEvent, node: DiagramNode, vp: Viewport) { startWorld = toWorld({ x: e.clientX, y: e.clientY }, vp); startNodePos = { x: node.x, y: node.y }; node.setPointerCapture(e.pointerId); } function onPointerMove(e: PointerEvent, node: DiagramNode, vp: Viewport) { const currentWorld = toWorld({ x: e.clientX, y: e.clientY }, vp); const dx = currentWorld.x - startWorld.x; const dy = currentWorld.y - startWorld.y; node.x = startNodePos.x + dx; node.y = startNodePos.y + dy; }这样无论缩放多少,节点都能牢牢贴在鼠标上。核心原则只有一条:所有位移增量都在世界坐标系里计算。2.3 以鼠标为中心缩放:公式推导缩放功能如果做得不对,会让用户“找不到北”。最常见的需求是:鼠标滚轮往下滚,以鼠标所在点为中心缩小;放大时画面应该朝鼠标方向聚拢。要保证缩放前后鼠标下的世界坐标点不变,可以推导公式。缩放前鼠标对应的世界坐标是:worldX = (mouseX - tx) / zoom缩放后要保证worldX不变,新的tx'必须满足:(mouseX - tx') / zoom' = (mouseX - tx) / zoom解出来就是:function zoomAt(mouseX: number, mouseY: number, nextZoom: number, vp: Viewport): Viewport { const worldX = (mouseX - vp.tx) / vp.zoom; const worldY = (mouseY - vp.ty) / vp.zoom; return { zoom: nextZoom, tx: mouseX - worldX * nextZoom, ty: mouseY - worldY * nextZoom, }; }我见过不少实现是直接改zoom,不管tx、ty,结果画面一缩放就跑到屏幕角落去,用户要找很久。正确的体验是鼠标指着哪里,哪里就是“视觉锚点”,这事必须用公式保证,而不是靠手动调整。3. 从零实现一个示例图表编辑器的核心链路3.1 数据层:用命令式更新替代散落的 setState编辑器最大的痛点之一,是状态管理会越来越乱。节点列表、兄弟节点、边列表、选中状态、历史栈,如果互相直接修改,很快会变成“牵一发动全身”。我用的方案是:所有对图的修改,都走一个统一入口,每个改动包装成命令对象。核心命令分为四类:添加:AddNode、AddEdge修改:MoveNode、UpdateNodeAttrs、UpdateEdgeAttrs删除:RemoveNodes、RemoveEdges组合:GroupNodes、UngroupNodes每个命令至少实现execute、undo两个方法,并携带执行前状态和执行后状态。这样做的收益非常大:撤销重做、协作同步、操作日志、甚至是后续接入自动化测试,全都依托于同一套命令管道。我现在宁可多写一层封装,也不愿意让业务代码直接通过 index 修改节点数组。3.2 画布组件:用 Transform 还是 ViewBoxSVG 场景下实现视口有两个主流方案:一是 SVG 的viewBox属性,它天然支持平移缩放,浏览器会替你完成坐标变换。但这个方案有个问题:viewBox一变,所有文本、边框、线条的视觉粗细都会跟着缩放,用户看边缘线会忽粗忽细,文档上也不容易局部控制。二是外层 div 绝对定位 + CSS transform:把 SVG 看成一张“无限大的纸”,然后整体塞进一个width: 100%; height: 100%; overflow: hidden的容器里。用transform: translate(tx, ty) scale(zoom)控制整张大纸的位置和大小。我推荐方案二,原因也很实际:CSS transform 的缩放是“视觉放大”,节点内部的文字和样式不会在逻辑上发生重排,性能开销也比反复修改 viewBox 低。唯一要处理的问题是transform-origin必须设为0 0,否则缩放和平移的合成顺序会混乱。function CanvasStage({ vp, children }) { return ( <div style={{ width: "100%", height: "100%", overflow: "hidden", position: "relative" }}> <div style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, transformOrigin: "0 0", transform: `translate(${vp.tx}px, ${vp.ty}px) scale(${vp.zoom})`, }} > {children} </div> </div> ); }这里有个小坑:外层包裹层的大小不能直接设为画布内容的大小,因为内容会随着缩放变化,最好的方式是设为一个固定的基础单位,再通过 transform 控制摆位。3.3 连线交互:从端口到端口连线是 diagram-design 里最能体现“设计感”的模块。第一版的连线是简单的起点终点画直线,用户反馈“两个节点重叠在一起时根本分不清谁连谁”。后来我改成了带箭头的贝塞尔曲线,并且强制要求连接点必须是端口。端口命中检测是这里最容易忽略的细节。直接让用户去点一条 2px 宽的线是不现实的,手指和鼠标的默认命中精度都不够。我给每个端口套了一个不可见的命中区,半径 12px 到 16px。视觉上端口只有 6px 见方,但点击区域大了四倍。连线交互的完整流程如下:pointerdown 落在某个端口上,进入“连线中”状态。pointermove 时,从源端口画一条跟随鼠标的临时曲线。pointerup 时,检测鼠标位置是否命中某端口,如果命中且不是同一个端口,则创建 Edge;否则取消连线。贝塞尔曲线的控制点怎么定?我这里的方案是取源端点到目标端点的方向向量,根据端口方向设置控制点的偏移量。比如右侧连左侧,曲线先向右走一段,再向左拐回。function buildCurvePath(source: Point, target: Point, direction: "left" | "right"): string { const dx = Math.max(24, Math.abs(target.x - source.x) * 0.5); const cp1x = direction === "right" ? source.x + dx : source.x - dx; const cp2x = direction === "right" ? target.x - dx : target.x + dx; return `M ${source.x} ${source.y} C ${cp1x} ${source.y}, ${cp2x} ${target.y}, ${target.x} ${target.y}`; }这段代码展示的是最常用的简化思路。真实产品里还可以加入正交折线模式,但核心逻辑不变:路径只依赖两个端口的世界坐标,任何节点移动都会触发重新计算,不再额外存储中间路径点。3.4 撤销重做:把历史栈做对撤销重做不能等产品做到一半再补,否则所有业务操作都要回头改。我的建议是命令注册的前一天就把历史栈设计进去。历史栈的典型结构是两条栈:interface HistoryState { undoStack: HistoryNode[]; redoStack: HistoryNode[]; } interface HistoryNode { commandName: string; before: unknown; // 执行前状态 after: unknown; // 执行后状态 }每次执行命令时,把 command 入 undo 栈,清空 redo 栈。撤销时从 undo 栈取出 command,恢复 before,把 command 压入 redo 栈。重做反之。这里大家最容易踩坑的是拖拽:用户按住节点拖了 30 次 mousemove,如果每次 move 都入一条历史记录,撤销一次就等于只后退了一个像素,用户要按 30 次 Ctrl+Z 才能回到原位。解法是给历史记录加“合并窗口”:同一个命令的连续执行,在 pointerdown 到 pointerup 之间合并为一条记录。每次 move 只更新 after 状态,不新增栈节点。等 pointerup 时再正式提交当前记录。3.5 序列化与导入导出保存、打开、分享是 diagram-design 绕不开的能力。JSON 序列化看似简单,实际有四个坑:函数不能序列化,任何保存在节点 attrs 里的函数、富对象、循环引用都要提前清洗。undefined 字段会被JSON.stringify忽略掉,加载时可能拿到缺少默认值的对象,因此需要一个填充默认值的 normalize 函数。id 必须稳定且全局唯一,否则协作或者导入外部数据时会出现引用断裂。版本号必须有。数据结构未来一定会加字段,没有版本号,旧文件加载到新版本代码里容易出诡异行为。我在序列化对象里固定放一个version: 1字段,所有在加载时做的兼容转换都按版本分支处理。导出图片也有讲究。直接把 SVG 转成图片会遇到一个经典问题:缩放导出时文字模糊。我的做法是导出前先把视口zoom临时改为目标倍数,比如导出 2x 图就把 zoom 设为当前 zoom 的两倍,调用toDataURL后再恢复视口状态。这样导出的图比单纯把 canvas 放大清晰得多。4. 节点多了以后:性能优化的三板斧4.1 视口裁剪:只画看得见的性能优化第一板斧是“不画看不见的东西”。视口裁剪逻辑很简单:只有当节点的包围盒与当前可见世界坐标矩形相交时,才把它挂到 DOM 上。求可见世界坐标矩形的方法:function getVisibleWorldRect(vp: Viewport, canvasW: number, canvasH: number): Rect { const topLeft = toWorld({ x: 0, y: 0 }, vp); const bottomRight = toWorld({ x: canvasW, y: canvasH }, vp); return { x: topLeft.x, y: topLeft.y, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y, }; }然后对每个节点判断矩形相交:function intersects(a: Rect, b: Rect): boolean { 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); }这个优化在 500 节点时体感不明显,但到 2000 节点时差别巨大:视口内通常只有几十个节点,DOM 操作量少了一个数量级。裁剪逻辑要特别注意两个边界:一是 zoom 特别小时整个图缩成一个点,绝大多数节点应该被跳过;二是视图平移过程中,节点的包围盒稍微放大一点,避免“拖动时边缘节点闪一下”的视觉瑕疵。4.2 渲染分层与拖拽免重排性能优化第二板斧是分层。把所有内容按“变化频率”拆分:低频层:背景网格、页面边框、水印。变,一年都不变。中频层:节点本身、连线路径。只在增删改时变化。高频层:拖拽中的临时虚线、跟随鼠标的新连线、选框。每帧都在变。高频层要单独放在一个 SVG 图层,用requestAnimationFrame控制帧率,并且禁止在拖拽过程中触发低频层的重排。拖拽时还有一个容易踩的性能坑:每次 mousemove 都重新 setState,导致整个画布组件重渲染。正确做法是对被拖拽节点做“局部更新”,在画布最上层用一个浮层单独绘制拖动中的节点,或者通过 ref 直接更新对应 SVG 元素的 transform,而不是让 React 协调器走完整 diff 流程。4.3 命中检测的空间索引节点多起来后,鼠标点击时如果遍历所有节点做矩形碰撞,每次都要算 2000 次,虽然 2000 次也不算多,但连点十几下也能感受到卡顿。更极端的情况下,单画布上万节点时,遍历就是不可接受的。实现一个简单高效的网格索引:把世界坐标按照格子边长 256px 划分,维护一个Map<CellKey, Node[]>。鼠标点击时,根据鼠标世界坐标算出所在格子,只需检查当前格子和相邻 8 个格子里的节点即可。function getCellKey(x: number, y: number, cellSize = 256): string { return `${Math.floor(x / cellSize)},${Math.floor(y / cellSize)}`; }格子边长的选择有讲究:太小会导致节点跨越多个格子、重复索引;太大又失去减枝意义。256px 对大多数场景比较合适,既能控制索引数量,又不会让单个格子里塞进太多节点。4.4 大数据量加载时的渐进渲染如果一次性从后端加载一个包含几千节点的图,直接渲染所有节点会让首屏卡顿好几秒。我采用的是渐进式加载:先加载并渲染视口可看到的节点,剩下的节点按网格分区延后处理。用户平移画布时,再动态加载新进入视口的区域。这样首屏速度可以做到 300ms 以内。渐进渲染有几个需要注意的点:加载状态要设计成不阻塞交互,用户拖拽和缩放照常进行。未加载区域要显示占位骨架,避免白屏。如果数据已经全部拿到,只是不想一次性渲染 DOM,可以用“虚拟节点”的方式,先只渲染节点矩形轮廓,用户放大后再渲染内部详细信息。5. 实战踩坑:常见问题与排查记录5.1 缩放后节点全不见了这是我经历过的最高频问题。表现是:用户连续缩放两次,画面里的节点全都不在视野里。排查下来,问题几乎都出在以鼠标为中心缩放的公式写错,或者 transform-origin 没设对。还有一种隐蔽情况:zoom 被允许设置为特别小的值,比如 0.001,节点缩小到 1px 以下,视口裁剪又按原尺寸判断,节点被优化掉,看起来就是“图消失了”。我的解法是在缩放函数里 clamp:const MIN_ZOOM = 0.05; const MAX_ZOOM = 4; const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));同时在画布容器尺寸变化时,重新计算tx、ty,保证画布中心点不变。这两条规则加上之后,节点消失的问题基本绝迹。5.2 连线永远对不上端口另一个高频问题是:创建连线时,线已经拉到另一个端口,但松开鼠标后,箭头却没有落在端口上,而是偏了十几个像素。排查后发现的根源是端口坐标的换算层级混乱。端口的dx、dy是相对节点左上角的本地坐标,而节点在世界坐标系里有x、y。端口的世界坐标应该是:function getPortWorldPosition(node: DiagramNode, port: Port): Point { return { x: node.x + port.dx * scaleRatio, y: node.y + port.dy * scaleRatio, }; }如果渲染时直接把dx、dy当作世界坐标用了,缩放超过 100% 后就会偏移。这个问题光看代码很难发现,建议在开发阶段就写几个断言工具,时刻校验端口世界坐标是否与节点包围盒相交。5.3 撤销重做把 UI 状态也卷回去了撤销重做如果只做一半,会带来诡异的交互:用户选中一个节点后按 Ctrl+Z,结果节点的选中框也跟着回退了,或者撤销后选中状态保留在已删除节点上。解法是把历史栈记录的范围严格限定在业务数据:节点增删、边增删、属性修改。选中状态、hover 状态、展开折叠状态、画布缩放值这些 UI 临时状态一律不进历史栈。代码设计上可以用类型把两类内容分开:type CommandData = { nodes?: DiagramNode[]; edges?: DiagramEdge[]; // 只允许放业务数据 };撤销时只恢复 CommandData 和重建图结构,不恢复任何 UI 状态。如果产品确实要求撤销时恢复选中状态,那就单独再设计一套选中历史,而不要混在同一个历史栈里。5.4 双击编辑与拖拽事件冲突编辑器里最脏的交互冲突之一是双击编辑文本和单击拖拽节点。如果处理不好,用户想双击改字,结果节点被拖走了一段距离。解决方案:用两次 pointerdown 的时间差来判定双击,而不是依赖 dblclick 事件,因为 dblclick 在拖拽后触发会与浏览器原生行为打架。第一次 pointerdown 后启动一个 250ms 的短计时器,如果第二次 pointerdown 出现且位移小于 5px,则进入编辑模式。进入编辑后,在 contentEditable 或 input 外层包一层遮罩,屏蔽画布层的 pointermove 拖拽行为。编辑框失焦时提交文本,Esc 取消并恢复原文本。这套机制还有一个隐藏收益:它让“单击选中”和“双击编辑”在交互上有了明确的优先级,不会出现按一次鼠标就反复触发两种行为的混乱。最后再分享一点零碎心得半年做下来,我最大的体会是:diagram-design 这类工具,真正难的不是某个炫酷的交互效果,而是那些看不见的底层正确性问题——坐标系换算、数据一致性、增量更新、边界条件。它们每一个单独看都不难,但串在一起,才是整个工具的骨架。而且这类项目最忌讳“想一步做一步”。我见过很多半成品,界面做着做着就开始在组件里直接改数组下标、在 mousemove 里维护坐标、在撤销栈里塞 DOM 状态。到最后,新增一个需求往往要重构三处代码。如果你正准备动手做自己的 diagram-design,我会建议你把数据模型和视口变换这两块放在最前面,花时间把接口设计干净。这两块稳了,后面所有的交互都是顺着骨架长出来的,而不是临时找地方挂上去的。
接手内部工具链里的 diagram-design 模块,是在半年前。当时需求听起来很朴素:把一堆流程节点拖到画布上,拉线连起来,然后导出成图片。我起初以为这不过是“拖拽 + 连线 + 导出”三件事,真正做完才发现,diagram-design 的核心难点根本不在 UI 特效,而在数据模型、坐标系换算、增量渲染和一堆边界条件。这篇文章把我这半年踩过的坑、验证过的方案、以及最终沉淀下来的实现思路完整整理出来。如果你是前端开发、可视化工程师,或者正准备做类似的图表设计工具,可以按这条路线直接开始。1. 动手之前,先搞清楚要做的是哪一类图表工具1.1 编辑器型与渲染器型的本质区别很多人拿到 diagram-design 这个标题,第一反应是“画图”,但画图和你实际要做的工具之间,隔着一整条设计决策链。我把它分成两类:渲染器型:把已有的图数据渲染成静态或半静态画面,比如拓扑图展示、依赖关系图、架构图预览。这类工具交互少,核心追求是“万级节点不卡”。编辑器型:用户要拖节点、连线、改属性、撤销重做、保存再打开。核心追求是“操作正确且反馈实时”。diagram-design 这个名字天然属于编辑器型。为什么这个判断重要?因为两类工具在选型上的取舍完全不同:渲染器可以为了性能牺牲一部分交互精确度,编辑器则必须优先保证“拖拽时节点跟着手指走”这类基础体验正确,性能反而要靠裁剪、分层等手段去补。当时我把需求拆完,确定了一个边界:单图画布上最多放 2000 个节点,编辑响应延迟不能超过 100ms,导出图片要清晰可打印。这个边界直接决定了下文的几乎所有技术选择。1.2 渲染方案:SVG、Canvas,还是混合渲染方案是最早要拍板的决策。我试过纯 Canvas、纯 SVG、Canvas 铺底 + SVG 覆盖三层结构,最后稳定在了“混合”方案上。先看对比:维度SVGCanvasWebGL节点数量适合 500 以内交互场景适合 2000 以上的纯绘制适合上万级别的复杂图事件/命中检测原生支持,直接绑定元素需要手写坐标换算和命中判断更难命中检测样式控制依赖 CSS,方便需要代码重绘Shader 编程文本渲染清晰,缩放不虚缩放会模糊需要特殊处理数据更新增量更新灵活容易整幅重绘局部更新复杂我最终的选择是:背景网格用 Canvas 画,节点和连线用 SVG 渲染,再在最上层叠加一个透明的交互层。理由是编辑器的交互密度很高:拖拽时要知道鼠标是否按在节点上、是否靠近某个端口、是否落在空白区域。SVG 元素的 hit area 和事件分发是浏览器原生能力,你不需要自己实现四叉树和射线判断,这对第一版上线很有价值。Canvas 虽然绘制性能更好,但所有命中检测、局部刷新、事件冒泡都要手写,排错成本会高一个量级。至于 WebGL,适合真正的重型图可视化,比如引擎依赖图、大规模关系图谱。普通 diagram-design 场景用 WebGL 属于用大炮打蚊子,还会引入 Shader 编译、纹理更新等一整套复杂度。1.3 数据模型是地基,先把它定下来很多人在画布上画了几十行代码之后才发现数据模型不对,要推倒重来。我建议第一件事就是定义清楚图数据结构,而且必须用 JSON 可序列化、可版本化的方式。我最终用的核心结构是:interface DiagramGraph { id: string; nodes: DiagramNode[]; edges: DiagramEdge[]; } interface DiagramNode { id: string; type: string; // "start" | "process" | "end" ... x: number; // 世界坐标 y: number; width: number; height: number; ports: Port[]; // 连接点 attrs: Record<string, unknown>; // label、color、样式等 } interface Port { id: string; dx: number; // 相对节点左上角的偏移 dy: number; direction: "top" | "right" | "bottom" | "left"; } interface DiagramEdge { id: string; source: { nodeId: string; portId: string }; target: { nodeId: string; portId: string }; attrs?: Record<string, unknown>; }这里有一个我用数据换来的教训:Edge 不要存绝对坐标,只存连接关系。早期我觉得“边不就是一条线嘛,把起点终点坐标存下来不就完事了”,结果节点一移动,旧的坐标全部失效,要么边不跟着走,要么得写一堆联动更新逻辑。改成引用nodeId + portId之后,边的渲染位置永远在渲染时实时计算,节点移动、缩放、旋转都不需要额外维护边的数据一致性。2. 画布坐标系:几乎所有交互问题的源头2.1 屏幕坐标与世界坐标的一对一换算diagram-design 里绕不开一对概念:世界坐标(World Coordinate)和屏幕坐标(Screen Coordinate)。世界坐标是业务里的“真实位置”,比如某个节点放在 (100, 100) 处,不管你在屏幕上怎么缩放、怎么平移,这个值都不变。屏幕坐标是用户最终看到的像素位置,它会随着视口变化而改变。两者通过一个视口变换关联起来。最朴素的做法是维护三个变量:缩放系数zoom,水平偏移tx,垂直偏移ty。换算关系如下:interface Viewport { zoom: number; tx: number; ty: number; } function toScreen(p: Point, vp: Viewport): Point { return { x: p.x * vp.zoom + vp.tx, y: p.y * vp.zoom + vp.ty, }; } function toWorld(p: Point, vp: Viewport): Point { return { x: (p.x - vp.tx) / vp.zoom, y: (p.y - vp.ty) / vp.zoom, }; }这段代码看起来简单,但它是整个画布所有交互的地基。节点渲染位置、连线路径、鼠标点击坐标、拖拽位移,全部要经过这一层换算,任何一步直接在屏幕坐标上做加法,都会在缩放后出现漂移。2.2 为什么拖拽时手感会“发飘”拖拽节点是第一个需要正确使用坐标系的交互。我当时踩过一个很典型的坑:mousedown 时记录鼠标在屏幕坐标下的位置,mousemove 时把位移量算出来后,直接除以zoom再加到节点坐标上。初看好像没问题,但用户把画布缩放到 200% 之后拖拽,节点要么跑得比鼠标快,要么跟不上,甚至会出现抖动。原因是屏幕坐标下的位移换算到世界坐标,应该用“转变换”,而不是“除以缩放系数”。正确的做法是:mousedown 时同时记录鼠标的世界坐标和节点的世界坐标,mousemove 时重新把鼠标屏幕坐标转到世界坐标,然后计算世界坐标的增量,加给节点。let startWorld: Point; let startNodePos: Point; function onPointerDown(e: PointerEvent, node: DiagramNode, vp: Viewport) { startWorld = toWorld({ x: e.clientX, y: e.clientY }, vp); startNodePos = { x: node.x, y: node.y }; node.setPointerCapture(e.pointerId); } function onPointerMove(e: PointerEvent, node: DiagramNode, vp: Viewport) { const currentWorld = toWorld({ x: e.clientX, y: e.clientY }, vp); const dx = currentWorld.x - startWorld.x; const dy = currentWorld.y - startWorld.y; node.x = startNodePos.x + dx; node.y = startNodePos.y + dy; }这样无论缩放多少,节点都能牢牢贴在鼠标上。核心原则只有一条:所有位移增量都在世界坐标系里计算。2.3 以鼠标为中心缩放:公式推导缩放功能如果做得不对,会让用户“找不到北”。最常见的需求是:鼠标滚轮往下滚,以鼠标所在点为中心缩小;放大时画面应该朝鼠标方向聚拢。要保证缩放前后鼠标下的世界坐标点不变,可以推导公式。缩放前鼠标对应的世界坐标是:worldX = (mouseX - tx) / zoom缩放后要保证worldX不变,新的tx'必须满足:(mouseX - tx') / zoom' = (mouseX - tx) / zoom解出来就是:function zoomAt(mouseX: number, mouseY: number, nextZoom: number, vp: Viewport): Viewport { const worldX = (mouseX - vp.tx) / vp.zoom; const worldY = (mouseY - vp.ty) / vp.zoom; return { zoom: nextZoom, tx: mouseX - worldX * nextZoom, ty: mouseY - worldY * nextZoom, }; }我见过不少实现是直接改zoom,不管tx、ty,结果画面一缩放就跑到屏幕角落去,用户要找很久。正确的体验是鼠标指着哪里,哪里就是“视觉锚点”,这事必须用公式保证,而不是靠手动调整。3. 从零实现一个示例图表编辑器的核心链路3.1 数据层:用命令式更新替代散落的 setState编辑器最大的痛点之一,是状态管理会越来越乱。节点列表、兄弟节点、边列表、选中状态、历史栈,如果互相直接修改,很快会变成“牵一发动全身”。我用的方案是:所有对图的修改,都走一个统一入口,每个改动包装成命令对象。核心命令分为四类:添加:AddNode、AddEdge修改:MoveNode、UpdateNodeAttrs、UpdateEdgeAttrs删除:RemoveNodes、RemoveEdges组合:GroupNodes、UngroupNodes每个命令至少实现execute、undo两个方法,并携带执行前状态和执行后状态。这样做的收益非常大:撤销重做、协作同步、操作日志、甚至是后续接入自动化测试,全都依托于同一套命令管道。我现在宁可多写一层封装,也不愿意让业务代码直接通过 index 修改节点数组。3.2 画布组件:用 Transform 还是 ViewBoxSVG 场景下实现视口有两个主流方案:一是 SVG 的viewBox属性,它天然支持平移缩放,浏览器会替你完成坐标变换。但这个方案有个问题:viewBox一变,所有文本、边框、线条的视觉粗细都会跟着缩放,用户看边缘线会忽粗忽细,文档上也不容易局部控制。二是外层 div 绝对定位 + CSS transform:把 SVG 看成一张“无限大的纸”,然后整体塞进一个width: 100%; height: 100%; overflow: hidden的容器里。用transform: translate(tx, ty) scale(zoom)控制整张大纸的位置和大小。我推荐方案二,原因也很实际:CSS transform 的缩放是“视觉放大”,节点内部的文字和样式不会在逻辑上发生重排,性能开销也比反复修改 viewBox 低。唯一要处理的问题是transform-origin必须设为0 0,否则缩放和平移的合成顺序会混乱。function CanvasStage({ vp, children }) { return ( <div style={{ width: "100%", height: "100%", overflow: "hidden", position: "relative" }}> <div style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, transformOrigin: "0 0", transform: `translate(${vp.tx}px, ${vp.ty}px) scale(${vp.zoom})`, }} > {children} </div> </div> ); }这里有个小坑:外层包裹层的大小不能直接设为画布内容的大小,因为内容会随着缩放变化,最好的方式是设为一个固定的基础单位,再通过 transform 控制摆位。3.3 连线交互:从端口到端口连线是 diagram-design 里最能体现“设计感”的模块。第一版的连线是简单的起点终点画直线,用户反馈“两个节点重叠在一起时根本分不清谁连谁”。后来我改成了带箭头的贝塞尔曲线,并且强制要求连接点必须是端口。端口命中检测是这里最容易忽略的细节。直接让用户去点一条 2px 宽的线是不现实的,手指和鼠标的默认命中精度都不够。我给每个端口套了一个不可见的命中区,半径 12px 到 16px。视觉上端口只有 6px 见方,但点击区域大了四倍。连线交互的完整流程如下:pointerdown 落在某个端口上,进入“连线中”状态。pointermove 时,从源端口画一条跟随鼠标的临时曲线。pointerup 时,检测鼠标位置是否命中某端口,如果命中且不是同一个端口,则创建 Edge;否则取消连线。贝塞尔曲线的控制点怎么定?我这里的方案是取源端点到目标端点的方向向量,根据端口方向设置控制点的偏移量。比如右侧连左侧,曲线先向右走一段,再向左拐回。function buildCurvePath(source: Point, target: Point, direction: "left" | "right"): string { const dx = Math.max(24, Math.abs(target.x - source.x) * 0.5); const cp1x = direction === "right" ? source.x + dx : source.x - dx; const cp2x = direction === "right" ? target.x - dx : target.x + dx; return `M ${source.x} ${source.y} C ${cp1x} ${source.y}, ${cp2x} ${target.y}, ${target.x} ${target.y}`; }这段代码展示的是最常用的简化思路。真实产品里还可以加入正交折线模式,但核心逻辑不变:路径只依赖两个端口的世界坐标,任何节点移动都会触发重新计算,不再额外存储中间路径点。3.4 撤销重做:把历史栈做对撤销重做不能等产品做到一半再补,否则所有业务操作都要回头改。我的建议是命令注册的前一天就把历史栈设计进去。历史栈的典型结构是两条栈:interface HistoryState { undoStack: HistoryNode[]; redoStack: HistoryNode[]; } interface HistoryNode { commandName: string; before: unknown; // 执行前状态 after: unknown; // 执行后状态 }每次执行命令时,把 command 入 undo 栈,清空 redo 栈。撤销时从 undo 栈取出 command,恢复 before,把 command 压入 redo 栈。重做反之。这里大家最容易踩坑的是拖拽:用户按住节点拖了 30 次 mousemove,如果每次 move 都入一条历史记录,撤销一次就等于只后退了一个像素,用户要按 30 次 Ctrl+Z 才能回到原位。解法是给历史记录加“合并窗口”:同一个命令的连续执行,在 pointerdown 到 pointerup 之间合并为一条记录。每次 move 只更新 after 状态,不新增栈节点。等 pointerup 时再正式提交当前记录。3.5 序列化与导入导出保存、打开、分享是 diagram-design 绕不开的能力。JSON 序列化看似简单,实际有四个坑:函数不能序列化,任何保存在节点 attrs 里的函数、富对象、循环引用都要提前清洗。undefined 字段会被JSON.stringify忽略掉,加载时可能拿到缺少默认值的对象,因此需要一个填充默认值的 normalize 函数。id 必须稳定且全局唯一,否则协作或者导入外部数据时会出现引用断裂。版本号必须有。数据结构未来一定会加字段,没有版本号,旧文件加载到新版本代码里容易出诡异行为。我在序列化对象里固定放一个version: 1字段,所有在加载时做的兼容转换都按版本分支处理。导出图片也有讲究。直接把 SVG 转成图片会遇到一个经典问题:缩放导出时文字模糊。我的做法是导出前先把视口zoom临时改为目标倍数,比如导出 2x 图就把 zoom 设为当前 zoom 的两倍,调用toDataURL后再恢复视口状态。这样导出的图比单纯把 canvas 放大清晰得多。4. 节点多了以后:性能优化的三板斧4.1 视口裁剪:只画看得见的性能优化第一板斧是“不画看不见的东西”。视口裁剪逻辑很简单:只有当节点的包围盒与当前可见世界坐标矩形相交时,才把它挂到 DOM 上。求可见世界坐标矩形的方法:function getVisibleWorldRect(vp: Viewport, canvasW: number, canvasH: number): Rect { const topLeft = toWorld({ x: 0, y: 0 }, vp); const bottomRight = toWorld({ x: canvasW, y: canvasH }, vp); return { x: topLeft.x, y: topLeft.y, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y, }; }然后对每个节点判断矩形相交:function intersects(a: Rect, b: Rect): boolean { 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); }这个优化在 500 节点时体感不明显,但到 2000 节点时差别巨大:视口内通常只有几十个节点,DOM 操作量少了一个数量级。裁剪逻辑要特别注意两个边界:一是 zoom 特别小时整个图缩成一个点,绝大多数节点应该被跳过;二是视图平移过程中,节点的包围盒稍微放大一点,避免“拖动时边缘节点闪一下”的视觉瑕疵。4.2 渲染分层与拖拽免重排性能优化第二板斧是分层。把所有内容按“变化频率”拆分:低频层:背景网格、页面边框、水印。变,一年都不变。中频层:节点本身、连线路径。只在增删改时变化。高频层:拖拽中的临时虚线、跟随鼠标的新连线、选框。每帧都在变。高频层要单独放在一个 SVG 图层,用requestAnimationFrame控制帧率,并且禁止在拖拽过程中触发低频层的重排。拖拽时还有一个容易踩的性能坑:每次 mousemove 都重新 setState,导致整个画布组件重渲染。正确做法是对被拖拽节点做“局部更新”,在画布最上层用一个浮层单独绘制拖动中的节点,或者通过 ref 直接更新对应 SVG 元素的 transform,而不是让 React 协调器走完整 diff 流程。4.3 命中检测的空间索引节点多起来后,鼠标点击时如果遍历所有节点做矩形碰撞,每次都要算 2000 次,虽然 2000 次也不算多,但连点十几下也能感受到卡顿。更极端的情况下,单画布上万节点时,遍历就是不可接受的。实现一个简单高效的网格索引:把世界坐标按照格子边长 256px 划分,维护一个Map<CellKey, Node[]>。鼠标点击时,根据鼠标世界坐标算出所在格子,只需检查当前格子和相邻 8 个格子里的节点即可。function getCellKey(x: number, y: number, cellSize = 256): string { return `${Math.floor(x / cellSize)},${Math.floor(y / cellSize)}`; }格子边长的选择有讲究:太小会导致节点跨越多个格子、重复索引;太大又失去减枝意义。256px 对大多数场景比较合适,既能控制索引数量,又不会让单个格子里塞进太多节点。4.4 大数据量加载时的渐进渲染如果一次性从后端加载一个包含几千节点的图,直接渲染所有节点会让首屏卡顿好几秒。我采用的是渐进式加载:先加载并渲染视口可看到的节点,剩下的节点按网格分区延后处理。用户平移画布时,再动态加载新进入视口的区域。这样首屏速度可以做到 300ms 以内。渐进渲染有几个需要注意的点:加载状态要设计成不阻塞交互,用户拖拽和缩放照常进行。未加载区域要显示占位骨架,避免白屏。如果数据已经全部拿到,只是不想一次性渲染 DOM,可以用“虚拟节点”的方式,先只渲染节点矩形轮廓,用户放大后再渲染内部详细信息。5. 实战踩坑:常见问题与排查记录5.1 缩放后节点全不见了这是我经历过的最高频问题。表现是:用户连续缩放两次,画面里的节点全都不在视野里。排查下来,问题几乎都出在以鼠标为中心缩放的公式写错,或者 transform-origin 没设对。还有一种隐蔽情况:zoom 被允许设置为特别小的值,比如 0.001,节点缩小到 1px 以下,视口裁剪又按原尺寸判断,节点被优化掉,看起来就是“图消失了”。我的解法是在缩放函数里 clamp:const MIN_ZOOM = 0.05; const MAX_ZOOM = 4; const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));同时在画布容器尺寸变化时,重新计算tx、ty,保证画布中心点不变。这两条规则加上之后,节点消失的问题基本绝迹。5.2 连线永远对不上端口另一个高频问题是:创建连线时,线已经拉到另一个端口,但松开鼠标后,箭头却没有落在端口上,而是偏了十几个像素。排查后发现的根源是端口坐标的换算层级混乱。端口的dx、dy是相对节点左上角的本地坐标,而节点在世界坐标系里有x、y。端口的世界坐标应该是:function getPortWorldPosition(node: DiagramNode, port: Port): Point { return { x: node.x + port.dx * scaleRatio, y: node.y + port.dy * scaleRatio, }; }如果渲染时直接把dx、dy当作世界坐标用了,缩放超过 100% 后就会偏移。这个问题光看代码很难发现,建议在开发阶段就写几个断言工具,时刻校验端口世界坐标是否与节点包围盒相交。5.3 撤销重做把 UI 状态也卷回去了撤销重做如果只做一半,会带来诡异的交互:用户选中一个节点后按 Ctrl+Z,结果节点的选中框也跟着回退了,或者撤销后选中状态保留在已删除节点上。解法是把历史栈记录的范围严格限定在业务数据:节点增删、边增删、属性修改。选中状态、hover 状态、展开折叠状态、画布缩放值这些 UI 临时状态一律不进历史栈。代码设计上可以用类型把两类内容分开:type CommandData = { nodes?: DiagramNode[]; edges?: DiagramEdge[]; // 只允许放业务数据 };撤销时只恢复 CommandData 和重建图结构,不恢复任何 UI 状态。如果产品确实要求撤销时恢复选中状态,那就单独再设计一套选中历史,而不要混在同一个历史栈里。5.4 双击编辑与拖拽事件冲突编辑器里最脏的交互冲突之一是双击编辑文本和单击拖拽节点。如果处理不好,用户想双击改字,结果节点被拖走了一段距离。解决方案:用两次 pointerdown 的时间差来判定双击,而不是依赖 dblclick 事件,因为 dblclick 在拖拽后触发会与浏览器原生行为打架。第一次 pointerdown 后启动一个 250ms 的短计时器,如果第二次 pointerdown 出现且位移小于 5px,则进入编辑模式。进入编辑后,在 contentEditable 或 input 外层包一层遮罩,屏蔽画布层的 pointermove 拖拽行为。编辑框失焦时提交文本,Esc 取消并恢复原文本。这套机制还有一个隐藏收益:它让“单击选中”和“双击编辑”在交互上有了明确的优先级,不会出现按一次鼠标就反复触发两种行为的混乱。最后再分享一点零碎心得半年做下来,我最大的体会是:diagram-design 这类工具,真正难的不是某个炫酷的交互效果,而是那些看不见的底层正确性问题——坐标系换算、数据一致性、增量更新、边界条件。它们每一个单独看都不难,但串在一起,才是整个工具的骨架。而且这类项目最忌讳“想一步做一步”。我见过很多半成品,界面做着做着就开始在组件里直接改数组下标、在 mousemove 里维护坐标、在撤销栈里塞 DOM 状态。到最后,新增一个需求往往要重构三处代码。如果你正准备动手做自己的 diagram-design,我会建议你把数据模型和视口变换这两块放在最前面,花时间把接口设计干净。这两块稳了,后面所有的交互都是顺着骨架长出来的,而不是临时找地方挂上去的。