ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JS工作流引擎从零实现:审批流模型、引擎与视图三层拆解

JS工作流引擎从零实现:审批流模型、引擎与视图三层拆解 简介一套基于JavaScript实现的工作流与审批流学习资源面向Web前端开发者、全栈工程师及需要为业务系统引入审批机制的团队。资源以WebFlow项目为示例系统讲解工作流模型、BPMN图形化设计、状态机驱动、任务分配与角色权限控制等核心概念并演示如何用HTML、CSS与JS构建直观的审批操作界面配合Ajax实现异步数据同步。压缩包共49个文件大小仅69KB包含8个js核心脚本、8个html页面、6个xml流程定义及aspx/cs后端示例另有gif演示图与png截图辅助理解文件结构清晰便于对照源码拆解学习。目前已有1282人学习适合想要快速掌握工作流引擎设计思路、审批流前后端协作方式以及流程持久化与异常回退机制的开发者可作为小型审批系统开发时的实用参考。 公司管理系统里“加个审批流”这句话我听了不下十遍。每次前端团队都以为只是画个流程图、弹个审批弹窗结果需求文档一摊开要回退、要会签、要条件分支、还要实时看流程走到哪一步。用 js 从零实现一个工作流引擎难度不在于写代码而在于把审批流拆成模型、引擎、视图三层每层各管各的不混在一起。我按这个思路在真实项目里落地过多次这篇文章就把 js 工作流和审批流的完整拆解过程写出来适合正在做后台管理系统、被“流程引擎”四个字吓住的前端或全栈团队也适合已经接入了 Flowable、Camunda 但看不懂底层数据模型的开发者。1. 先想清楚你的项目到底需不需要一个“重型”审批流动手之前先花半小时把业务需求过一遍。我的判断依据非常朴素如果整个系统只需要“提交 — 审批 — 通过/驳回”这种固定顺序那不需要任何工作流引擎如果流程会随业务调整、节点会加、审批人需要动态指定那才需要考虑“流程定义”和“流程实例”的分离。我见过太多团队一上来就引 Camunda最后光部署和学习成本就压垮了项目节奏。1.1 用一张需求清单判断自研还是引入BPM引擎下面这张表我在需求沟通会上用了很多次每次都能很快把“要做什么”聊清楚需求特征自研轻量JS引擎Flowable/Camunda等重量级引擎固定的一条审批链完全够用过度设计流程需要业务人员动态调整需要连流程图设计器一起做成熟方案只需要审批回退简单状态机即可大材小用需要会签、或签、条件路由模型层扩展即可现成支持需要流程版本管理、超时提醒、定时催办要额外开发开箱即用团队对运行机制的可控性要求高代码在手随便改黑盒居多判断的核心是复杂度不要因为“Flowable 是行业标准”就上一个对你只是负担的引擎也不要因为“自研感觉不专业”就拒绝一个本来很轻量的需求。我自己在初期吃过一个亏一个只有六级审批链的报销系统硬套 Camunda光部署和学习成本就花了两周最后发现业务方要的只是“谁提交谁审批”五个节点白白浪费一期迭代。1.2 轻量级JS工作流的典型应用场景我接过的项目里适合用 js 自研的审批流大多是这几类企业内部审批请假、报销、合同、采购流程固定、节点少核心诉求是快速上线和可视化追踪管理后台的变更审批内容发布、权限申请、数据订正审批人通常是固定角色低代码平台里的一块小流程模块不想引入重依赖只想在表单提交后走一条带状态的流转链。这些场景有两个共同点。第一并发量有限不会有上千人同时抢同一个审批任务第二流程数量有限一般以个位数到几十个计算不会出现流程定义工厂化生产的情况。只要满足这两点自研方案完全不会输给工业级引擎反而能省掉一大半集成成本。1.3 Flowable、Camunda和自研最终怎么选从成本、维护、可控性三个角度看。成本上Flowable 和 Camunda 不是“引一个依赖就完事”要理解 BPMN 2.0 的 XML 语法、部署流程定义、处理模型版本学习成本被严重低估。Camunda 的 REST API 对前端还算友好但真正用起来还是绕不开它的用户管理、权限模型和一堆新概念。另一个容易被忽略的点是团队技术栈Flowable 基于 Java如果团队里没人愿意碰 Java 技术栈后续维护会非常痛苦而纯前端或者 Node.js 团队自研 JS 引擎反而是最优解。可控性上自研的优势非常直接整个引擎就几个核心类代码全部在自己手里线上出一个诡异问题能一行行跟进去。劣势也很清楚版本管理、并发控制、复杂网关这些能力要自己补齐属于“前期便宜、后期还债”。我的建议是短期没有复杂流程编排需求、团队希望在流程逻辑上有绝对掌控力的就自研如果企业本身已经有 Flowable/Camunda 基础设施或者流程必须由非技术人员可视化编排那才考虑引入重引擎。2. 流程的第一步把图画变成数据审批流本质上是把一张流程图翻译成计算机能读懂的数据结构。我用的模型和前端绘图领域保持一致节点node加连线edge。这样设计有个很明显的好处以后想换可视化库数据结构不用动反过来流程图设计器画出来的数据也可以直接交给引擎执行两边天然解耦。2.1 节点与连线的JSON结构设计节点结构我这样定义{ id: node_approve_001, type: approval, name: 部门经理审批, approverType: role, approverValue: manager, props: { multiInstance: single } }字段含义拆开看id节点唯一标识全流程不能重复type节点类型我常用 start、approval、cc、condition、end 五种approverType审批人类型可以是 person指定人、role指定角色、dept部门负责人、dynamic动态指定props.multiInstancesingle 单人审批all 会签any 或签。连线结构{ id: edge_001, source: node_start_001, target: node_approve_001, condition: amount 5000, priority: 1 }condition 是条件表达式运行时交给表达式引擎解析。priority 用来处理同一节点出现多个出口的情况值越小越优先最后一条边可以不写 condition作为默认出口保证流程一定能找到路走。2.2 会签、或签、条件分支在模型层怎么表达会签和或签都落在 props.multiInstance 上不需要单独设计特殊节点类型。single节点只有一个审批人审批完成即推进all会签节点有多个审批人必须所有人都处理后流程才继续any或签节点有多个审批人任意一人处理后流程继续其余待办任务自动关闭。条件分支则通过 edge 的 condition 实现。引擎内部维护一个 variables 变量池里面存着发起人填的表单数据比如金额、部门、加急标记表达式引擎从变量池取值计算。表达式的语法不需要造轮子能支持比较运算、、!、逻辑运算、||就覆盖绝大多数场景。这里有一个我实际踩过的坑表达式的键名要和表单字段严格对齐。真实项目里遇到过流程定义写的是 amount表单字段叫 totalAmount结果条件永远不匹配流程走到节点就停住。建议在设计阶段直接约定变量池的 key 命名规范并且在校验函数里把“表达式引用的变量是否存在于变量池”也一并检查比上线后再排查高效得多。2.3 用代码校验流程图的“合法性”流程图不是画出来就能跑的我强烈建议在保存流程定义时做一次校验。我在引擎里实现了 validateFlow 函数做三件事有且只有一个 start 节点和一个 end 节点从 start 出发能到达所有节点并且每个节点都能到达 end每个 condition 表达式能被解析且引用的变量存在于变量池 schema 中。第二点听起来像图论问题实际不需要复杂的算法从 start 节点做一次深度优先遍历记录可达节点集合再检查是否覆盖所有节点即可。流程图规模通常是两位数级别的节点性能完全不用担心。校验不通过的流程定义直接拒绝保存能把一大批配置错误扼杀在编辑阶段而不是等线上流程跑一半才发现卡死了。3. 核心引擎让流程按定义跑起来流程定义只是一张静态蓝图要让审批真正“走”起来得有一个运行时引擎。我的设计是每个流程实例维护一个 runtime 对象存放当前活跃节点和 token 集合。token 可以理解成流程执行到哪里的“游标”一批 token 表示流程当前有哪些节点处于激活状态。3.1 token机制与流程实例状态用请假流程举例start → 部门审批 → 人事备案 → end。发起人提交申请引擎在 start 节点生成一个 token调用 applyAction 推进到部门审批审批人同意token 再推进到人事备案最后到达 end实例状态变成 completed。一个 token 长这样{ tokenId: tk_10001, nodeId: node_approve_001, status: active, // active | completed | canceled assignees: [u_1001, u_1002], completedAssignees: [], variables: {} }流程实例本身也有状态pending运行中、completed已完成、canceled已取消、terminated异常终止。如果流程实例里已经没有 active token但实例状态还是 pending说明流程定义有漏洞应该在定义的校验阶段就提前拦掉而不是靠运行时兜底。3.2 moveNext推进核心几个关键分支当用户同意某个节点后引擎最终都会走到 moveNext 方法。简化版本如下function moveNext(instance, currentNode) { const outgoingEdges getEdgesBySource(instance.flow, currentNode.id) .sort((a, b) a.priority - b.priority); const matchedEdge outgoingEdges.find(edge edge.condition ? evaluateCondition(edge.condition, instance.variables) : true ); if (!matchedEdge) { throw new Error(节点 ${currentNode.name} 没有任何可走的出口); } const nextNode getNodeById(instance.flow, matchedEdge.target); if (nextNode.type end) { instance.status completed; return; } return createToken(instance, nextNode); }这里有个容易忽略的细节把所有费时操作都执行完后再统一修改实例状态避免出现“推到一半报错状态已经改了”的残留问题。condition 为空时当成“必定满足”排序后最后一条无条件的边就是默认出口这样只要画图的时候出口逻辑没画错流程就一定有路可走。3.3 会签“等所有人”、或签“推一把就走”的实现会签和或签的处理是审批流引擎里最容易写错的部分。会签节点进入时直接为该节点的所有审批人生成待办任务但 token 仍然停在这个节点。每个审批人提交意见后把其 ID 追加进 completedAssignees直到 completedAssignees.length 等于 assignees.length才调用 moveNext 继续前进。或签节点则反过来第一个人通过立即把其他待办任务全部关闭当前 token 推进到下一步。这里要格外小心的点是关闭其他待办任务时不能只是把任务状态改成“已关闭”还要在流程历史里记录“因或签被自动关闭”否则参与者会一头雾水地问“我还没审批呢怎么就到下一步了”。function onApprovalAction(instance, token, action) { if (token.props.multiInstance all) { token.completedAssignees.push(action.userId); if (token.completedAssignees.length token.assignees.length) return; // 还没等齐 moveNext(instance, token); } else if (token.props.multiInstance any) { if (!token.completedAssignees.includes(action.userId)) { closeOtherTasks(instance, token, action.userId); token.status completed; moveNext(instance, token); } } else { moveNext(instance, token); } }逻辑本身不复杂但一定要处理“重复提交”同一个审批人连续点两次同意completedAssignees 就会重复记录。提交时先校验此人是否已经处理过该任务是基本要求。4. 业务侧落地待办列表、审批操作与流程追踪引擎跑通只是完成了底层能力前端怎么接住这些能力才真正决定用户体验。一个审批流系统至少要包含待办列表、已办列表、我发起的、流程追踪四个页面每个页面背后都有对应的接口设计取舍。4.1 待办、已办、我发起的查询怎么设计待办列表的查询本质是“查流程实例 当前活跃节点 审批任务”。我的做法是任务表存 user_id、instance_id、node_id、task_status待办 task_statustodo AND user_id当前登录人已办 task_statusdone AND user_id当前登录人我发起的 流程实例表查 initiator当前登录人。很多团队犯的错是在 SQL 里把流程实例表和任务表直接 join 查询实例一多性能马上就崩。我更推荐先查任务表拿到 instance_id 列表再批量查实例详情接口返回结构大致是{ taskId: task_001, instanceId: instance_001, flowName: 请假申请, nodeName: 部门经理审批, initiator: 张三, submitTime: 2024-06-01 10:30:00 }冗余的 nodeName、flowName 字段虽然不太符合“正规化”直觉但对列表页来说非常好用不用每次请求都解析一遍流程图定义。4.2 同意、驳回、撤回、转办背后各是什么审批操作本质上都是对引擎的一次动作调用我习惯把它们分成两类操作作用对象核心影响同意任务当前节点推进到下一个节点驳回任务/实例回到配置的驳回目标节点并记录意见撤回实例整体回到起点通常仅发起人可用转办任务当前节点审批人变更为其他人操作设计时最重要的原则是分清“面向流程实例”还是“面向任务”。撤回是面向流程实例的整个实例都要回退同意和转办是面向任务的只影响当前任务。如果混在一起多人会签时就会出现“一个人撤回整条流程”的严重事故。建议所有面向任务的接口都传 taskId所有面向实例的接口都传 instanceId前端不要替后端做语义判断。4.3 流程图高亮与审批历史时间线前端拿到流程定义和当前活跃节点后渲染逻辑很直接已经经过的节点显示为绿色当前节点橙色未到达节点灰色连线根据来源节点的状态染色。用 Vue 或 React 配合任意流程图组件库都能做本质只是根据节点状态切换类名。审批历史建议按“节点 操作 操作人 时间 意见”五元组渲染成时间线。数据结构可以是{ histories: [ { nodeName: 部门经理审批, action: 同意, operator: 李经理, time: 2024-06-01 11:20:00, comment: 同意注意差旅标准 } ] }有个细节很值得做把发起、会签自动跳过、或签自动关闭这些系统事件也塞进时间线。用户看流程的时候信息才完整不会觉得流程“莫名跳过了”某个节点。5. 上生产之前这几个坑我替你踩过了5.1 并发提交同一节点先锁再改会签节点上多个审批人同时点“同意”请求同时到达后端。如果代码是先读 token 状态再写状态两个请求可能都读到“待审批”然后各推一次 moveNext后写的覆盖前写的流程状态直接崩。我的处理方式是加锁锁的粒度用“流程实例ID 节点ID 任务ID”数据库行锁或者 Redis 分布式锁都行。拿到锁后再查询 token 状态判断是否已处理过未处理才继续。重复提交的请求直接返回“已处理”而不是报错前端体验也更好。5.2 驳回、撤回和会签的边界很容易翻车驳回的目标节点不能简单存“上一个节点”因为在并行分支下上一次节点可能不唯一。我的实现是给每个节点预配置“驳回目标节点”比如部门审批节点配置为“重回发起人”人事备案节点配置为“重回部门审批”。这样不管流程怎么走驳回目标都是确定的不会出现逻辑二义性。撤回同样有边界必须判断当前节点是否仍然停留在发起人之后的第一个审批节点如果已经有人审批过就不能撤回只能走驳回。这个判断依赖的正是 4.2 里说的“流程实例当前 token 的位置”所以撤回接口一定要传 instanceId不要传 taskId。真出问题的时候多半不是引擎算法复杂而是这类边界判断没做。5.3 如果某天要迁移Flowable/Camunda提前留好退路自研引擎不必走到底。如果业务规模上来了需要 BPMN 2.0 可视化编排、流程版本管理、定时催办这些能力很自然会想迁移到 Flowable 或 Camunda。我的建议是从第一天就把流程定义、流程运行时、历史记录三层彻底分开。迁移时只需要重写运行时引擎层流程定义 JSON 可以写一层转换器映射到 BPMN 的 XML 元素流程节点对应 bpmn:task连线对应 bpmn:sequenceFlow历史记录表保留原样。这样即使后面真要迁移数据资产也不会白做。最后再分享一个经验自研审批流最忌讳一上来就想做全覆盖。先把最常用的单线审批、会签、或签跑通再逐步扩展条件路由、驳回、转办。别看 BPMN 规范厚厚一本90% 的业务要的只是那 20% 的功能。先让流程转起来再谈完美先把一个流程用顺了再考虑做成可配置的工作流平台。本文还有配套的精品资源点击获取
返回列表