
流程图编辑器是低代码、工作流、数据编排等场景里非常常见的一类交互组件。市面上虽然有 node-red、ngx-flowchart、X6、LogicFlow 等成熟方案,但如果想真正吃透「节点拖拽、连线、框选」这些核心交互背后的实现原理,自己动手写一个仍然是收获最大的方式。
本文以我自己开源的一个 Vue3 流程图项目为例,拆解它的整体架构与核心交互实现,包括节点拖拽、贝塞尔曲线连线、连线编辑、矩形框选、画布自动扩展与边缘自动滚动,以及数据持久化。
项目已开源在 github.com/Fate-ui/flo...,文中涉及的源码都能在仓库里对照查看。
一、技术栈与整体效果
项目采用了较为现代的前端技术栈:
- Vue3 +
<script setup>:组合式 API,逻辑组织更灵活 - Vite:开发与构建工具
- TypeScript:完整类型约束,连画布数据结构都有清晰的接口定义
- Pinia:集中管理画布状态(节点、连线、选中态等)
- Element Plus:节点表单、弹窗等 UI
- UnoCSS:原子化 CSS
- VueUse :
tryOnScopeDispose等组合式工具 - mitt:轻量级事件总线,串联各个交互模块
整体界面分为三部分:左侧节点面板(事件 / 中间 / 动作三类节点)、中间可滚动的画布、以及右侧的数据展示面板。
二、架构设计:用 Service 类组织复杂交互
流程图最大的难点在于交互逻辑非常多:拖拽、连线、框选、滚动......如果全部塞进组件的 setup 里,代码会迅速失控。
这个项目的做法是把每一类交互都抽象成一个独立的 Service 类 ,放在 Services 目录下:
arduino
Services/
├── DragElementNodeService.ts // 节点拖拽
├── DrawLineService.ts // 连线的绘制与编辑
├── RectangleSelect.ts // 矩形框选
├── ScrollParent.ts // 边缘自动滚动
└── EditElementNodeService.ts // 节点编辑
每个 Service 在构造时拿到必要的 Ref 引用与画布数据,并通过事件总线订阅全局的鼠标事件:
ts
export class DrawLineService {
constructor(flowSvgRef, flowDomOffset, flowData, flowRef) {
this.flowSvgRef = flowSvgRef
this.flowData = flowData
// 订阅全局 mousemove / mouseup
useOn(flowEmitter, 'mouseMove', this.mouseMove)
useOn(flowEmitter, 'mouseUp', this.mouseUp)
}
}
这样组件只负责「派发原始事件」,具体的业务逻辑全部交给 Service,职责非常清晰。
事件总线 + 自动注销:useOn
值得一提的是这里没有让每个 Service 直接 addEventListener,而是封装了一个 useOn 组合式函数。它配合 mitt 的 on/off,在组件作用域销毁时自动解绑,避免内存泄漏:
ts
export function useOn(target, event, listener) {
let cleanup = () => {}
const stopWatch = watch(() => unref(target), (val) => {
cleanup()
val.on(event, listener)
cleanup = () => val.off(event, listener)
}, { immediate: true, flush: 'post' })
// 组件销毁后自动取消监听
tryOnScopeDispose(() => { stopWatch(); cleanup() })
}
而全局的 mousemove、mouseup 只在最外层组件监听一次,再通过 flowEmitter.emit('mouseMove', e) 广播给所有 Service。这样做的好处是:拖拽过程中即使鼠标移出了节点,事件依然能被捕获,交互不会「断手」。
三、核心数据结构
整张流程图由三部分构成,类型定义非常直观:
ts
export interface IFlow {
nodes: INode[] // 所有节点
connections: IConnect[] // 所有连线
canvasSize: { width: number; height: number } // 画布尺寸
}
export interface INode<Params = any> {
id: string // uuid
type: string // 节点类型,如 eventNode1 / actionNode1
params: Params // 该节点的表单数据
additional: {
layoutX: number // 画布中的 x 坐标
layoutY: number // 画布中的 y 坐标
showDrop?: boolean // 是否收缩
}
}
export interface IConnect {
fromId: string // 起点节点 id
toId: string // 终点节点 id
type: string // 连线类型(满足 / 不满足 / 条件一二三)
id: string
hidden?: boolean
}
所有交互本质上都是在增删改这三个数组 ,再由 Vue 的响应式系统驱动视图更新------节点用绝对定位渲染,连线用 SVG path 渲染。
四、节点拖拽:偏移量 + 多选联动 + 画布扩展
拖拽看似简单,但要做好有几个细节。DragElementNodeService 在 mouseDown 时记录的不是鼠标坐标,而是鼠标相对节点左上角的偏移量,这样拖动时节点不会「跳」到鼠标位置:
ts
onMouseDown = (data, e, dom) => {
this.curNode = data
const { x, y } = dom.getBoundingClientRect()
this.moveOffset.x = e.x - x // 记录偏移量
this.moveOffset.y = e.y - y
this.setIndex(dom) // 把当前节点提到最上层
}
mouseMove 时再用「鼠标坐标 - 画布偏移 - 偏移量 + 滚动距离」算出节点真实坐标,并做了两件事:
- 边界保护 :
x < 0或y < 0时归零,节点不会被拖出画布左 / 上边界; - 多选联动 :如果当前拖动的节点属于已框选的多个节点,则计算位移增量
delta,让所有选中节点一起移动;只要有一个节点会越界,本次位移就被忽略。
ts
let xDelta = x - this.curNode.additional.layoutX
let yDelta = y - this.curNode.additional.layoutY
if (selectedNodes.length > 1 && selectedNodes.includes(curNode)) {
if (selectedNodes.some(n => n.additional.layoutX + xDelta < 0)) xDelta = 0
if (selectedNodes.some(n => n.additional.layoutY + yDelta < 0)) yDelta = 0
selectedNodes.forEach(n => { n.additional.layoutX += xDelta; n.additional.layoutY += yDelta })
}
最后,当节点被拖到画布右 / 下边缘附近时,自动把画布尺寸加大 500px,实现「无限画布」的体验:
ts
private extendCanvasSize = (pos) => {
if (pos.x > width - elementNodeSize.width) flowData.canvasSize.width += 500
if (pos.y > height) flowData.canvasSize.height += 500
}
五、连线:SVG 贝塞尔曲线的绘制与吸附
连线是整个项目最精彩的部分,由 DrawLineService 负责。
1. 起点落下 :在连接点 mousedown 时,动态创建一个 SVG <path> 元素挂到画布的 <svg> 上,并记录起点坐标。
2. 移动绘制 :mousemove 时实时更新 path 的 d 属性。曲线用三次贝塞尔曲线,让连线平滑自然:
ts
export function getCurvePath(from, to) {
return `M ${from.x} ${from.y} C ${from.x}, ${from.y + (to.y - from.y) / 2} ${to.x - 50}, ${to.y - (to.y - from.y) / 2} ${to.x} ${to.y}`
}
两个控制点分别落在起点正下方与终点左侧,于是连线会以「先向下、再水平靠近终点」的姿态延伸,视觉上很像专业流程图工具。
3. 自动吸附:当鼠标进入一个合法的目标连接点时,直接把连线终点吸附到该节点的入口坐标,松手即可成功连线。这里还内置了一套连接规则校验:
ts
mouseEnterConnector = (type, node, e) => {
// 1. 左侧连接点不能作为起点
// 2. 终点类型必须与起点类型不同
// 3. 起点和终点不能是同一个节点
if (!this.isDrawing || this.startPointType == 'left'
|| type === this.startPointType || this.startNode.id === node.id) return
// 4. 两个节点之间最多一条连线
const isConnected = this.flowData.connections.some(item =>
[item.fromId, item.toId].every(el => [this.startNode.id, node.id].includes(el)))
if (isConnected) return
this.endNode = node
this.setPath(/* 吸附到目标入口坐标 */)
}
4. 松手结算 :mouseup 时移除临时 path。如果没匹配到终点就放弃;如果起点是「条件节点」,弹出 Element Plus 弹窗让用户选择连线类型(条件一 / 二 / 三);否则默认按「满足 / 不满足」直接生成一条连线 push 进 connections。
连线编辑也很巧妙:从一个节点的左侧入口按下时,会找到所有以它为终点的连线,把原连线临时隐藏、克隆出一个标签跟随鼠标,相当于「把这根线的终点拔下来重新接」,松手后删除旧线、生成新线。
六、矩形框选
RectangleSelect 负责框选。mousedown 记录起点,mousemove 不断更新选框的 left/top/width/height(用 Math.min/Math.max 处理反向拖动),mouseup 时做碰撞检测,把完全落在选框内的节点收集起来:
ts
if (layoutX > x3 && layoutX + nodeWidth < x4 &&
layoutY > y3 && layoutY + nodeHeight < y4) {
flowStore.selectedElementNodes.push(node)
}
选中节点后,再把「起点在选中节点里」的连线也一并选中,于是删除、移动都能整体操作。
七、边缘自动滚动
当拖拽或框选到达视口边缘时,画布应当自动滚动------这是 ScrollParent 的职责。它同样订阅全局 mousemove,判断鼠标是否进入预设的边缘区域,是则按固定 delta 滚动父容器:
ts
mouseMove = (e) => {
if (!flowStore.maybeNeedScrollParent) return
const scrollDom = this.flowRef.value.parentElement
if (e.x > this.edge.right) scrollDom.scrollLeft += this.delta
else if (e.x < this.edge.left) scrollDom.scrollLeft -= this.delta
if (e.y > this.edge.bottom) scrollDom.scrollTop += this.delta
else if (e.y < this.edge.top) scrollDom.scrollTop -= this.delta
}
这里用一个全局状态 maybeNeedScrollParent 作为开关,只有在「正在拖拽 / 连线 / 框选」时才允许滚动,避免误触。
八、数据持久化
最后是持久化。项目用一个 watch 监听整个画布数据,任何变化都同步写入 localStorage,刷新页面后自动还原,不丢工作进度:
ts
const oldFlowData = localStorage.getItem('flowData')
flowStore.flowData = oldFlowData ? JSON.parse(oldFlowData) : defaultFlowData
watch(flowStore.flowData, (value) => {
localStorage.setItem('flowData', JSON.stringify(toRaw(value)))
})
注意这里写入前用了 toRaw,把 Vue 的响应式 Proxy 转回普通对象再序列化,更稳妥。
九、总结
做完这个项目,我自己在工程层面有几点设计上的思考,也一并分享给同样想做流程图的你:
- 用 Service 类拆分复杂交互:拖拽、连线、框选、滚动各司其职,组件只做事件派发,可读性和可维护性都更好;
- 事件总线统一调度鼠标事件:全局只监听一次,再广播给各模块,解决了拖拽过程中事件丢失的问题;
- 配合
tryOnScopeDispose自动注销监听,从源头规避内存泄漏; - 数据驱动视图 :所有交互归结为对
nodes / connections两个数组的增删改,SVG 贝塞尔曲线负责把抽象的连接关系渲染成顺滑的曲线。
如果你也想深入理解流程图编辑器的实现,非常推荐照着这套思路自己撸一遍。这个项目我已经开源在 GitHub:Fate-ui/flowChart,感兴趣的话欢迎点个 star,也欢迎在 issue 区一起交流。
本文从源码角度梳理了一个 Vue3 流程图编辑器的核心实现,如有疏漏欢迎在评论区交流指正。