从零实现一个 Vue3 流程图编辑器:节点拖拽、贝塞尔连线与框选

流程图编辑器是低代码、工作流、数据编排等场景里非常常见的一类交互组件。市面上虽然有 node-redngx-flowchartX6LogicFlow 等成熟方案,但如果想真正吃透「节点拖拽、连线、框选」这些核心交互背后的实现原理,自己动手写一个仍然是收获最大的方式。

本文以我自己开源的一个 Vue3 流程图项目为例,拆解它的整体架构与核心交互实现,包括节点拖拽、贝塞尔曲线连线、连线编辑、矩形框选、画布自动扩展与边缘自动滚动,以及数据持久化。

项目已开源在 github.com/Fate-ui/flo...,文中涉及的源码都能在仓库里对照查看。

一、技术栈与整体效果

项目采用了较为现代的前端技术栈:

  • Vue3 + <script setup>:组合式 API,逻辑组织更灵活
  • Vite:开发与构建工具
  • TypeScript:完整类型约束,连画布数据结构都有清晰的接口定义
  • Pinia:集中管理画布状态(节点、连线、选中态等)
  • Element Plus:节点表单、弹窗等 UI
  • UnoCSS:原子化 CSS
  • VueUsetryOnScopeDispose 等组合式工具
  • 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() })
}

而全局的 mousemovemouseup 只在最外层组件监听一次,再通过 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 渲染。

四、节点拖拽:偏移量 + 多选联动 + 画布扩展

拖拽看似简单,但要做好有几个细节。DragElementNodeServicemouseDown 时记录的不是鼠标坐标,而是鼠标相对节点左上角的偏移量,这样拖动时节点不会「跳」到鼠标位置:

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 时再用「鼠标坐标 - 画布偏移 - 偏移量 + 滚动距离」算出节点真实坐标,并做了两件事:

  1. 边界保护x < 0y < 0 时归零,节点不会被拖出画布左 / 上边界;
  2. 多选联动 :如果当前拖动的节点属于已框选的多个节点,则计算位移增量 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 时实时更新 pathd 属性。曲线用三次贝塞尔曲线,让连线平滑自然:

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 转回普通对象再序列化,更稳妥。

九、总结

做完这个项目,我自己在工程层面有几点设计上的思考,也一并分享给同样想做流程图的你:

  1. 用 Service 类拆分复杂交互:拖拽、连线、框选、滚动各司其职,组件只做事件派发,可读性和可维护性都更好;
  2. 事件总线统一调度鼠标事件:全局只监听一次,再广播给各模块,解决了拖拽过程中事件丢失的问题;
  3. 配合 tryOnScopeDispose 自动注销监听,从源头规避内存泄漏;
  4. 数据驱动视图 :所有交互归结为对 nodes / connections 两个数组的增删改,SVG 贝塞尔曲线负责把抽象的连接关系渲染成顺滑的曲线。

如果你也想深入理解流程图编辑器的实现,非常推荐照着这套思路自己撸一遍。这个项目我已经开源在 GitHub:Fate-ui/flowChart,感兴趣的话欢迎点个 star,也欢迎在 issue 区一起交流。

本文从源码角度梳理了一个 Vue3 流程图编辑器的核心实现,如有疏漏欢迎在评论区交流指正。

相关推荐
森鹿1 小时前
express中间件原理以及大致实现
前端·express
光影少年1 小时前
HashRouter 和 BrowserRouter 区别、底层原理、部署差异
前端·react.js·nestjs
柯克七七1 小时前
我把祖传项目的构建时间砍了90%,领导以为我只是在"优化了一下",结果隔壁组的CI都崩了来问我配置
前端·webpack
风骏时光牛马1 小时前
JSP页面直接输出实体对象空属性引发页面500报错实战案例
前端
IT_陈寒2 小时前
Python里这个赋值坑,连老司机都能翻车
前端·人工智能·后端
Hyyy3 小时前
什么是bun?和pnpm有什么区别
前端·面试·bun
To_OC12 小时前
LC 128 最长连续序列:别上来就排序,O (n) 解法才是这题的灵魂
javascript·算法·leetcode
IT_陈寒16 小时前
Vue这个坑我跳了两次,原来问题出在这
前端·人工智能·后端
kyriewen16 小时前
我用 50 行代码重写了 React Router 核心,终于搞懂了前端路由原理
前端·javascript·react.js