做企业级审批流程设计器的时候,很多人第一反应就是“画节点连线条线”,但真上手之后才会发现,一个能落地到生产环境的流程图可视化,远不止画图这么简单。这篇文章我基于 Vue3 + JavaScript 技术栈,用 VueFlow 这个开源库,完整梳理了一套企业级审批流程图可视化的实现方案。从技术选型、环境搭建、自定义节点,到拖拽连线、数据建模、性能优化和回显校验,都是我在实际项目里踩过坑之后的产物。如果你正准备在项目里接入流程设计能力,或者正在纠结到底用现成库还是自研,这篇内容应该对你有参考价值。
1. 为什么在审批项目里选了 VueFlow
1.1 审批流程可视化到底要解决什么问题
企业级审批流程,表面上是“几个方块 + 几条箭头”,但落地到系统里之后,它承担的东西远比视觉展示更重:用户要在画布上拖出流程节点,指定审批人、审批条件、抄送对象;管理员要能配置节点属性并保存成结构化数据;后端要能把这份数据解析成可执行的工作流引擎配置。也就是说,流程图组件不仅要画得好看,还要做到“可编辑、可序列化、可回显、可校验”。
我最早接过这类需求的时候,曾在项目群里看到产品经理贴了一张用绘图软件画出来的请假审批图,说“照着这个做就行”。等到真正开发时才发现,静态图离“流程设计器”之间的距离,差不多是手绘皮影和一台提线木偶的差距。你要处理节点拖动后的坐标持久化、连线重新连接、条件分支判断、非法流程校验,甚至还要考虑用户误操作后的撤销重做。所有这些,都需要一个真正的流程图交互组件来支撑,而不是一张图片或一段 SVG。
1.2 自研、BPMN.js、X6 与 VueFlow 的取舍
选型阶段,我身边主要有四条路:自己写、用 BPMN.js、用 AntV X6、用 VueFlow。先说自研,如果你只需要“展示一张流程图”,自绘完全可行;但要做交互式设计器,你得手写拖拽、连线、命中检测、画布缩放、坐标滚动同步,一套下来至少两到三周,还没算后续维护成本。对于大多数业务项目来说,这个投入不划算。
BPMN.js 功能很全,是流程引擎领域的标准建模工具,但它最大的问题在于“太重”。它自带一整套 BPMN 规范语义,事件、网关、泳道、消息流一应俱全,而企业内的大多数审批流其实用不到这么严格的规范。你如果只是做“发起人→主管审批→抄送→结束”这种轻量流程,BPMN 的接入会让数据结构被迫往标准靠拢,前端要处理一堆跟业务无关的 XML 节点,维护起来非常痛苦。
AntV X6 也是一款成熟的图编辑引擎,能力很强,社区和文档都不错。如果你所在团队用的是其他技术栈、对图形交互有极高自定义要求,X6 完全能扛住。但我在项目里是 Vue3 技术栈,X6 虽然提供了框架无关的 API,但接入 Vue 自定义节点时还是需要写不少桥接逻辑。相比之下,VueFlow 本身就是为 Vue3 设计的,节点可以直接写成 Vue 组件,和现有业务组件体系无缝贴合,改造成本最低。
1.3 VueFlow 的优势和适用边界
VueFlow 的核心优势,我的体会可以总结成四句话:上手快、可定制、生态够用、数据易序列化。
- 上手快:核心概念只有 Node、Edge、Handle、Viewport 四个,半天就能拉出可交互的第一版。
- 可定制:节点、连线、缩放按钮、小地图全都可以用 Vue 组件重写。
- 生态够用:官方提供了 MiniMap、Controls、Background 等配套包,常规设计器功能开箱即用。
- 数据易序列化:它维护的 nodes 和 edges 就是普通 JS 对象,可以直接 JSON.stringify 发给后端,无需额外适配层。
不过它也有适用边界。如果你的项目要覆盖复杂事件驱动的流程引擎,或者需要完整的 BPMN 语义建模,VueFlow 并不是最优解,它更适合“业务愿意用简单节点定义流程”的场景。我接手的那套审批系统正好属于后者,所以 VueFlow 对我来说就是那个“够用且顺手的工具”。
2. 环境搭建与核心概念,先让空白画布跑起来
2.1 初始化 Vue3 项目和安装 VueFlow 依赖
我是用 Vite 初始化的 Vue3 项目,这一步没什么玄学,直接创建即可。项目结构上,我会把流程图相关代码单独放到一个目录里,和业务页面解耦,方便后续维护。
npm create vite@latest approval-flow -- --template vue cd approval-flow npm install接着安装 VueFlow 核心包和配套包。很多人只装了 @vue-flow/core 就开写,结果发现没有背景网格、没有小地图,到处找配置项。正确做法是一开始就把常用配套包装齐。
npm install @vue-flow/core npm install @vue-flow/background @vue-flow/minimap @vue-flow/controls这里我踩过一个小坑:VueFlow 不同版本之间的 API 命名有差异,尤其是 v-model 绑定和 event 名称。安装时尽量锁一个大版本,不要直接在 package.json 里写一个不加限制的^1.0.0就完事。安装完成后先用官方默认样式确认版本是否正常,再加自定义样式,否则一旦样式写错,排查半天也分不清是版本问题还是自己代码的问题。
import '@vue-flow/core/dist/style.css' import '@vue-flow/core/dist/theme-default.css'这两个样式文件分别负责核心布局和默认主题。如果你想完全自定义外观,第一个文件建议保留,第二个可以替换成自己的设计系统样式,但如果刚开始没把握,两个都先带着,跑通再说。
2.2 理解 Node、Edge、Handle、Viewport 这四个概念
VueFlow 的核心抽象非常简洁,但理解了这四个词,整个库的用法就顺了一半。
Node(节点)是画布上的一个实体,通常对应审批流程里的某个步骤。它的核心字段包括 id、type、position、data。id 是全局唯一标识,type 决定节点使用哪种组件渲染,position 是相对画布左上角的坐标,data 是业务自己塞的数据,比如审批人姓名、审批状态、是否必办等。
Edge(边)是节点之间的连线,核心字段是 source、target 和 id。它描述的是“从哪个节点出去,连到哪个节点”。Edge 还支持自定义 label、动画、样式,项目里我一般用它的 label 展示“通过”“驳回”“条件命中”等流转动作名称,效果比在画布上干巴巴画一条线清晰很多。
Handle(连接点)是节点上用于挂接连线的锚点。每个节点可以定义 target handle(入点)和 source handle(出点),组件内部用<Handle type="target">和<Handle type="source">声明。这是最容易忽略的部分,因为默认节点样式里看不太出来,但一旦你开始自定义节点,忘记加 Handle 就会导致“节点画出来了却连不了线”。
Viewport(视口)是画布与屏幕之间的坐标变换关系,包含 x、y、scale 三个参数。当用户缩放或平移画布时,VueFlow 通过 Viewport 把画布坐标和屏幕坐标相互转换。理解这一点,后面处理“拖拽新节点到画布”时就不会因为坐标错位而抓狂。
提示:在自定义节点组件里,你拿到的 position 是相对于画布的坐标,而不是屏幕坐标。任何从屏幕坐标映射到画布坐标的操作,都要经过 viewport 转换,这一步不能省。
2.3 渲染第一版可交互的流程图
安装完依赖、理解了概念,就可以先跑一个最小 Demo。我通常会在一个独立页面上先初始化一批静态节点和边,确认数据驱动的基本链路是通的,再逐步往上加功能。
<script setup> import { ref } from 'vue' import { VueFlow, useNodesState, useEdgesState } from '@vue-flow/core' import '@vue-flow/core/dist/style.css' import '@vue-flow/core/dist/theme-default.css' const initialNodes = [ { id: '1', type: 'default', position: { x: 100, y: 100 }, data: { label: '发起人' } }, { id: '2', type: 'default', position: { x: 400, y: 100 }, data: { label: '主管审批' } }, { id: '3', type: 'default', position: { x: 700, y: 100 }, data: { label: '结束' } } ] const initialEdges = [ { id: 'e1-2', source: '1', target: '2', label: '提交' }, { id: 'e2-3', source: '2', target: '3', label: '同意' } ] const nodes = ref(initialNodes) const edges = ref(initialEdges) </script> <template> <div class="flow-container"> <VueFlow v-model:nodes="nodes" v-model:edges="edges"> <Background /> <MiniMap /> <Controls /> </VueFlow> </div> </template> <style scoped> .flow-container { width: 100%; height: 600px; border: 1px solid #d9d9d9; border-radius: 8px; } </style>这里的关键点在于v-model:nodes和v-model:edges,它让外部数据源和画布内部状态保持双向同步。你用 v-model 绑定之后,用户在画布上拖动节点、移动连线,数据会自动更新到你维护的 nodes/edges 数组里,不需要手动监听一堆事件再去 setState。这也是 VueFlow 写起来比某些框架无关库舒服的原因之一。
不过我要提醒一句:v-model 绑定虽然方便,但如果你后续要把流程数据保存到后端,不要直接把画布上的 nodes 数组整个序列化发过去,因为里面还混着 VueFlow 生成的内部字段,比如节点是否被选中、尺寸信息、路径详情等。到数据保存那一节我会专门讲怎么清洗。
3. 审批节点建模与自定义节点组件
3.1 审批流程的节点类型体系
先梳理业务,再写代码。企业审批流程看着复杂,但抽象到节点类型,无非这几种:
- 开始节点:流程唯一入口,没有入边,通常叫“发起人”或“申请人”。
- 审批节点:需要指定审批人,可能是一个人、多人会签、或签。这是我们最常用的节点。
- 抄送节点:审批通过后通知相关人员,不阻塞流程。
- 条件分支节点:根据业务字段走不同分支,比如请假天数大于 3 走总监审批,否则走主管审批。
- 结束节点:流程终点,没有出边。
我用一个对象统一管理这些节点类型的元信息,避免在代码里丢魔法字符串。
export const NODE_TYPES = { start: { label: '发起人', color: '#1677ff' }, approval: { label: '审批节点', color: '#52c41a' }, cc: { label: '抄送节点', color: '#faad14' }, condition: { label: '条件分支', color: '#722ed1' }, end: { label: '结束', color: '#f5222d' } }每个节点的 data 里,除了给展示用的 label,还会带业务配置,比如审批节点会有approverType(指定人/角色/发起人自选)、approverList、multiType(会签/或签);条件分支节点会有conditions数组。这些字段不参与渲染逻辑,只作为节点配置面板的数据来源。
设计节点类型体系时,我建议遵循一个原则:节点类型尽量少,能靠配置表达的不要靠新增类型表达。有些需求方会提“这里需要一个特殊的跨部门会审节点”,但你仔细一看,它其实就是审批节点加一个会签模式,把数据字段扩展一下就能解决。节点类型一旦膨胀,画布渲染分支和解析逻辑都会跟着膨胀,维护成本上升非常快。
3.2 用自定义 Vue 组件实现审批节点样式
VueFlow 默认节点可以满足 demo 需求,但企业级系统绝不能只给用户看简单方块,每个节点都要有自己的视觉样式和状态。在 VueFlow 中自定义节点就是写一个 Vue 组件,然后通过 nodeTypes 对象注册。
我先定义一个审批节点的组件:
<script setup> import { Handle, Position } from '@vue-flow/core' defineProps({ data: { type: Object, required: true } }) </script> <template> <div class="approval-node" :class="{ 'is-selected': data.selected }"> <Handle type="target" :position="Position.Left" /> <div class="approval-node__header"> <span class="approval-node__type">{{ data.typeLabel }}</span> <span class="approval-node__icon" v-if="data.approverType === 'or'">或签</span> </div> <div class="approval-node__body">{{ data.label }}</div> <Handle type="source" :position="Position.Right" /> </div> </template> <style scoped> .approval-node { width: 180px; border: 1px solid #d9d9d9; border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); font-size: 14px; overflow: hidden; } .approval-node.is-selected { border-color: #1677ff; box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2); } .approval-node__header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; background: #e6f4ff; color: #1677ff; font-weight: 500; } .approval-node__body { padding: 12px 8px; text-align: center; color: rgba(0, 0, 0, 0.88); } </style>然后注册 nodeTypes:
import ApprovalNode from '../components/ApprovalNode.vue' const nodeTypes = { approval: ApprovalNode, start: StartNode, cc: CcNode, condition: ConditionNode, end: EndNode }模板里传进去:
<VueFlow :node-types="nodeTypes" v-model:nodes="nodes" v-model:edges="edges" />这里有一个非常关键的细节:你必须在自定义组件里显式声明 Handle,否则连线功能会神秘失效。我见过不止一次,同事把节点样式写得漂漂亮亮,结果在界面上左拖右拖就是连不出线,最后发现自定义节点里忘了放<Handle>。Handlе 是 VueFlow 识别连接锚点的唯一依据,跟视觉长相无关。
还有一个细节是 Handle 的 position。审批流程通常是水平方向从左到右流动,所以我把 target 放在 Left,source 放在 Right。但如果你做的流程是自顶向下,那 target 应该在 Top,source 在 Bottom。这个方向要和整体布局对齐,否则用户的连线和节点视觉会非常别扭。
3.3 流程数据结构与后端接口的映射关系
自定义节点完成之后,要解决的是数据结构问题。前端画布上的节点和边,本质上是给后端流程引擎用的流程定义。我和后端同事约定的结构大致长这样:
{ "processKey": "leave_approval", "processName": "请假审批", "nodes": [ { "id": "node_start_001", "type": "start", "position": { "x": 100, "y": 100 }, "data": { "label": "发起人", "typeLabel": "发起人" } }, { "id": "node_approval_001", "type": "approval", "position": { "x": 400, "y": 100 }, "data": { "label": "部门主管审批", "typeLabel": "审批节点", "approverType": "role", "approverRole": "department_manager", "multiType": "or" } } ], "edges": [ { "id": "edge_001", "source": "node_start_001", "target": "node_approval_001", "sourceHandle": null, "targetHandle": null, "label": "提交" } ] }这个结构是给后端引擎用的,所以要尽量干净、可解析。VueFlow 内部在运行时会给节点添加 exposed、dimensions、selected 等字段,保存时我会用一次序列化函数把它们洗掉,只保留后端关心的字段,同时给 id 加上有业务含义的前缀,方便排查问题。
映射关系上,前端流程设计器对应的就是流程定义,节点 id 只要在单张流程内唯一即可,建议用可读性强的命名,比如node_approval_001,而不是直接用随机字符串。因为一旦用户保存流程后需要调接口查某个特定节点,后端日志里打印的是node_approval_001还是j3k2h1,排查效率差距非常明显。
4. 交互设计:拖拽建点、连线与属性面板
4.1 拖拽添加节点与坐标转换
审批设计器最直观的交互,是从左侧物料区拖出节点到画布上。这个功能看起来简单,但坐标转换是绕不开的一个点。
先给物料区的每一项设置可拖拽属性,把节点类型写进 dataTransfer:
function onDragStart(event, type) { event.dataTransfer.setData('application/vueflow', type) event.dataTransfer.effectAllowed = 'move' }然后在画布外层监听 drop 事件。这里的关键是不能直接使用event.clientX和event.clientY作为 node 的 position,因为这两个坐标是屏幕坐标,而画布的坐标系会受 viewport 的 scale 和偏移影响,必须调用 project 方法做一次转换。
import { useVueFlow } from '@vue-flow/core' const { project } = useVueFlow() function onDrop(event) { const type = event.dataTransfer.getData('application/vueflow') if (!type || !typeMap[type]) return const position = project({ x: event.clientX, y: event.clientY }) const id = `node_${type}_${Date.now()}` nodes.value = [ ...nodes.value, { id, type, position, data: createDefaultNodeData(type) } ] }这里有一个我踩过的坑:在旧版本的 VueFlow 里,project 方法需要通过useVueFlow获取,并且必须在VueFlow组件内部上下文中调用。如果你在组件 setup 里直接调用,会提示找不到实例。我当时是把 drop 监听放在<VueFlow>的父容器上,结果 project 一直报错。后来把拖拽逻辑封装到了 VueFlow 内部的子组件里才好。
还有一个体验细节:drop 时应该给新节点一个“合理的默认值”,而不是凭空出现一个空白节点。比如拖入审批节点,默认话术是“未命名审批节点”,用户双击或点击打开属性面板后直接改名称。这样用户连续创建多个节点时,不至于画面里全是空白方块,不知道自己拖的是什么。
4.2 节点连线的约束与交互细节
连线其实 VueFlow 自带了一套逻辑:用户从 source handle 拖到 target handle,会触发 connect 事件。我们要做的是在 connect 事件里拦截,把生成的 edge 存进业务数据。
function onConnect(params) { const sourceNode = nodeMap.get(params.source) const targetNode = nodeMap.get(params.target) if (!canConnect(sourceNode, targetNode)) { return } edges.value = [ ...edges.value, { ...params, id: `edge_${params.source}_${params.target}_${Date.now()}` } ] }连线约束是我特别想强调的一点。普通用户不会意识到流程连线的合法性,但产品必须限制。比如开始节点不能有入边,结束节点不能有出边,同一个节点不能有多个来自不同分支的“普通入边”(会签场景除外)。这些约束如果不在前端拦截,用户画出一张看似合理但逻辑上根本跑不通的流程,后端引擎执行时就会炸。
我总结了几条实用的连线校验规则,大家可以根据业务取舍:
- 开始节点不能作为任何 edge 的 target。
- 结束节点不能作为任何 edge 的 source。
- 同一对 source-target 不允许重复连线。
- 不允许形成循环链路,至少要给用户一个明显的警告提示。
- 条件分支节点出边必须都带条件,不允许存在一条无条件边和一条条件边并列。
b> 注意:连线校验要写在 connect 事件里,而不是等保存时再校验。用户在画布上操作时就立刻给出阻断反馈,体验比最后保存时弹一个“流程非法”的提示框好非常多。
4.3 节点属性配置面板与数据联动
光有节点和连线还不够,审批节点必须可配置。点击一个审批节点,右侧要能弹出属性面板,显示审批人、审批模式、条件规则等表单字段。
我通常的处理方式是用一个当前选中节点的响应式引用:
const selectedNode = ref(null) function onNodeClick({ node }) { selectedNode.value = node } function onPaneClick() { selectedNode.value = null }属性面板和画布组件共享同一个 selectedNode,面板里的表单直接修改selectedNode.value.data。这里我又碰到过一个非常隐蔽的问题:VueFlow 的节点 data 虽然声明为普通对象,但在某些情况下直接修改node.data的嵌套属性不会触发节点 UI 更新。如果只是纯文本展示还没问题,一旦要按 data 里的字段动态渲染 class 或文案,就得让这个修改动作触发节点重渲染。
我后来采用的办法是:面板修改数据时,不直接改 data,而是创建一个新的 data 对象再赋值给 node。比如审批人变了,就执行node.data = { ...node.data, approverList: newList },强制节点属性对象发生引用变化,VueFlow 就能正常感知到。简单说就是“拷贝替换,而不是就地修改”。
属性面板的另一个细节是条件分支节点的规则配置。我这里用一个简单的弹窗表格,每一行代表一个条件组,格式是“字段 + 运算符 + 值”。比如请假天数 > 3走分支 A,否则走分支 B。这些条件最终会跟 edge 绑定,表示“满足条件的边才被激活”。实际实现里要把条件配置的入口放在边或节点上,不能只放在面板里,否则用户很难理解这条分支为什么长这样。
5. 常见问题与排查技巧实录
5.1 自定义节点点击事件被吞 / 双击缩放误触
自定义节点里如果有按钮或者下拉框,比如“查看审批人”按钮,你会发现点击它的时候,可能会触发 VueFlow 的节点点击、画布缩放或选中变化,行为非常奇怪。原因很简单:事件从节点内部冒泡到了 VueFlow 画布层,被库统一处理了。
解决办法有两个思路。一是业务按钮在自己的组件里主动调用event.stopPropagation(),阻止冒泡。二是不要在自定义节点里铺太多交互,把统一的节点点击、双击逻辑都挂到 VueFlow 的@node-click、@node-double-click上。我实际项目中两个方案混合用:节点上的小按钮阻止冒泡,节点的整体选中、双击进面板则交给 VueFlow 事件。
5.2 缩放拖拽后坐标错位
拖拽新节点进来后,节点落点的位置和鼠标位置不一致,尤其是把画布缩小到 50% 之后,差距非常明显。这个问题几乎都是因为少了坐标转换。
我排查这个问题时,习惯直接在 onDrop 里临时打印一下转换前后的坐标:
console.log(event.clientX, event.clientY) console.log(project({ x: event.clientX, y: event.clientY }))如果转换后的 result 与鼠标位置匹配,说明问题出在画布区域的 DOM 偏移没有减掉。比如画布外面套了一层距离页面顶部 80px 的 header,如果直接用 clientY 减去容器偏移后再取整,就会稳定偏移一个 header 的高度。解决办法是使用event.currentTarget.getBoundingClientRect()算出画布相对页面的偏移,再传给 project。
5.3 大数据量流程渲染卡顿
审批流在合理规模下(几十个节点)通常不会卡,但一旦有人把那套“一个月几千步流程”的巨型流程塞到设计器里,就会出现拖动节点时帧率明显下降的问题。
VueFlow 本身对边的渲染做了不少优化,但自定义节点是独立 Vue 组件,数量上来后重渲染开销不容忽视。我这里有两个低成本优化手段:一是把 nodes 和 edges 的 v-model 改为浅响应式,尽量减小数据变化影响范围;二是把节点 data 里不参与渲染的大字段(比如完整的规则配置、审批人列表详情)挪到非响应式容器里,需要时再取。实际项目中我把一个 400 多个节点的流程从明显卡顿优化到基本流畅,靠的主要就是第二招。
5.4 流程数据保存、回显与校验遇到的坑
保存流程时,不能直接把 nodes 数组序列化发给后端。因为运行时的 node 对象可能带着 VueFlow 注入的 dimensions、selected、dragging 等状态,这些字段没有任何持久化意义,还容易造成后端入库数据体积膨胀。我在项目里写了一个精简函数,只保留跟流程引擎相关的字段。
回显时最常见的坑是:后端返回的节点 position 范围很大,画布默认缩放是 1,用户一打开浏览器只能看到一两个节点,其余全在视野外。解决办法是加载数据后主动调用 fitView,让画布自动缩放到刚好容纳所有节点的视口范围。VueFlow 在组件上提供了 fitView 方法和 prop,比较省事的做法是设置fit-view-on-init属性,在首次渲染后自动完成适配。
流程校验方面,我会在保存前做三道检查:所有节点是否有合法 id;所有 edge 的 source/target 是否都能对应到真实节点;是否存在孤立节点(没有任何条件的孤立节点基本就是配置配了一半)。这三道检查看起来简单,却帮我挡了不少线上流程配置错误。
6. 实际项目落地的几点经验
6.1 别让组件接管所有状态,数据层要独立
画布上的 nodes 和 edges 只是视图层的数据,业务真正需要的是一份可序列化、可解析的流程定义。我在项目里一直秉承“视图数据与业务数据分离”的原则:节点 data 里存的是业务字段,其他跟样式、布局相关的字段都放在节点顶层。这样后端拿到数据后不需要关心前端用了什么库,只按节点 type 和 data 去解析流程。如果哪天需要从 VueFlow 迁移到另一个图引擎,改动也仅限于前端渲染层,业务数据模型完全不用动。
6.2 版本升级要保守,别急着追新
VueFlow 的版本迭代速度不慢,新版本的确会带来性能优化和新 API,但因为它在 Vue3 生态里依赖比较重的组件链,一旦升级主版本,可能需要连带升级配套包,甚至会碰到 v-model 行为变化。我在项目中是固定在 1.x 的一个小版本上,非必要不开级。如果你刚开始做,建议在 package.json 里锁定精确版本号,不要用^范围。等整个系统稳定后再评估是否升级,不要一边做业务一边赶依赖升级。
6.3 这块能力后续还能往哪扩展
审批流程可视化只是工作流平台的第一步,后续通常还会接版本管理(同一流程多版本并存)、流程仿真(模拟跑一遍流程)、权限预览(按不同角色预览审批路径)等能力。VueFlow 的画布能力足以承载这些扩展,关键是底层数据结构从一开始就设计得干净,确保上面叠再多功能都不会散架。如果你也在用 VueFlow 做这块,建议先花时间把节点配置面板的交互细节、数据清洗逻辑、连线约束做好,这些才是真正的核心壁垒,画布本身谁都能画出来。