状态机驱动的JS轻量审批流引擎:三表模型与实战避坑
发布时间:2026/9/26 11:52:29来源:尧图网络
简介面向Web开发者的JavaScript工作流与审批流示例包适合需要在网页端实现任务提交、审核、驳回、流程可视化等场景的技术人员。包内演示了基于状态机驱动的前端流程引擎包含流程设计界面、步骤跳转和上下文菜单等交互并结合角色权限与多语言配置展示了轻量级审批系统从界面到数据交互的完整思路。整套资源共49个文件以JS脚本、HTML页面、XML流程定义和CSS样式为主辅以ASPX后端与数据库文件压缩包仅69KB结构紧凑便于快速阅读和二次修改。已有1285人浏览学习。通过这份示例可以快速搭起一个可运行的工作流前端原型后续接上自己的后端接口即可用于实际项目适合学习JS流程管理的中级开发者参考。1. 审批流不是画流程图是写状态机JS 轻量方案的定位有位同事拿需求来找我说用 JS 做个请假审批的工作流跟钉钉差不多但排期只有一周。如果你一上来就钻进 bpmn.js 的节点和 XML大概率翻车。我做过几轮请假、报销、合同审批这类轻量级工作流最深体会是审批流的主体是状态机流程图只是给人看的壳。同一套引擎换一组流程定义就能支撑简历筛选、采购审批它和 n8n、Dify 那类自动化编排不是一回事也不是 Flowable 那种 Java 重引擎。中小团队、几十个流程以内的内部 OA 场景用「流程定义 JSON 实例表 任务表」三件套最划算。下文按数据模型、引擎实现、前端设计、踩坑实录、验证手段往下拆。2. 三张表把状态机说清楚审批流的数据模型设计2.1 审批流和 n8n 那种工作流不是一回事现在网上到处是 n8n工作流、Dify 工作流、扣子Coze工作流那是自动化编排节点之间传数据、调接口跑完就结束没有人等审批这回事。审批流完全相反核心约束是人的介入——一个审批节点可能悬挂三天等某个具体的人点同意或驳回流程才继续。所以设计引擎时脑子里要装的是状态机不是画布。状态机落到数据上就三个问题实例整体停在哪实例状态、流程走到哪个节点当前节点、这个节点上有哪些人在等任务状态。把这三个问题答清楚其余表单、通知、日志都是附属品。2.2 流程定义节点、边、条件是配置不是代码流程定义是一份 JSON它是引擎的输入也是前端设计器保存的产物。下面是一份请假审批的定义三类节点加一组条件边{ key: leave_approval, name: 请假审批, nodes: [ { id: start, type: start }, { id: manager_approve, type: approve, assigneeType: role, assigneeValue: dept_manager }, { id: days_gate, type: condition }, { id: hr_approve, type: approve, assigneeType: role, assigneeValue: hr }, { id: end, type: end } ], edges: [ { from: start, to: manager_approve }, { from: manager_approve, to: days_gate }, { from: days_gate, to: end, condition: days 3 }, { from: days_gate, to: hr_approve, condition: days 3 }, { from: hr_approve, to: end } ] }节点类型我一般只用四种start发起、approve审批、condition条件网关、end结束。assigneeType 区分 user 和 rolerole 的好处是换人不用改流程。edge 上的 condition 是表达式字符串值为空表示无条件直达。这个 JSON 是配置不是代码意味着改流程不用发版本、不用改代码刷新一下配置就能生效。2.3 实例表与任务表状态位和乐观锁是关键流程定义存一份剩下的核心是两张表。我常用的结构CREATE TABLE flow_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flow_key VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL, current_node_id VARCHAR(64), variables JSON, initiator VARCHAR(64) NOT NULL, flow_snapshot JSON, version INT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL ); CREATE TABLE flow_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_id VARCHAR(64) NOT NULL, assignee VARCHAR(64), status VARCHAR(16) NOT NULL, action VARCHAR(16), comment TEXT, version INT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, acted_at DATETIME );说三个容易被忽略的字段。version 是乐观锁后面并发避坑全靠它。flow_snapshot 在创建实例时把 flow definition 整个冗余进来以后流程版本升级老实例照旧按老规则跑不会走着走着规则变了。status 用字符串枚举就行RUNNING / COMPLETED / REJECTED / CANCELED 四个值别用数字排查问题时一眼能看出状态。2.4 引擎的心脏从当前节点算后继节点推进逻辑是引擎里最容易写错的地方。顺序流转还好说遇到条件分支就要小心先看有没有条件边有就逐条 eval谁先命中走谁没有条件边的多条出边当作并行分支同时创建任务。为了防止条件节点之间互相指导致死循环递归解析时要加一个步数上限function getNextNodes(flowDef, currentNodeId, variables) { const edges flowDef.edges.filter(e e.from currentNodeId); if (edges.length 0) return []; const withCond edges.filter(e e.condition); const withoutCond edges.filter(e !e.condition); if (withCond.length 0) { return withoutCond.map(e e.to); } for (const e of withCond) { if (evalCondition(e.condition, variables)) return [e.to]; } // 条件全不命中时走默认边没有默认边就报错 if (withoutCond.length 1) return [withoutCond[0].to]; throw new Error(节点 ${currentNodeId} 没有命中的条件分支); } function resolveNextNode(flowDef, fromNodeId, variables) { let current fromNodeId; let guard 0; while (guard 50) { const node flowDef.nodes.find(n n.id current); if (node.type ! condition) return current; const nexts getNextNodes(flowDef, current, variables); if (nexts.length 0) throw new Error(条件节点 ${current} 没有命中任何分支); current nexts[0]; } throw new Error(条件节点链过长疑似流程定义死循环); }resolveNextNode 的作用是跳过连续的条件节点让调用方拿到的永远是一个真实要创建任务的审批节点。guard 上限 50 是防呆配置人员在画布上把两个条件节点互相指没有这个上限引擎会递归到栈溢出。3. 用 Node.js 落地审批引擎推进、会签、驳回与事务边界3.1 从定义 JSON 到可运行引擎六步走完最小闭环第一步确定 flow_key 的编码规则。工作流编码从第一天就要定规范我习惯用小写蛇形比如 leave_approval、purchase_contract这个值会写进实例表、任务表、日志表改起来是全局的事。第二步把 startFlow 跑通插入实例、解析第一个审批节点、创建初始任务。第三步实现 submitTask 推进。第四步补 reject、withdraw、transfer 三个动作。第五步写待办、已办、发起三个查询接口。第六步用脚本把发起→审批→结束整条链路跑一遍。startFlow 是入口逻辑很直白async function startFlow(flowKey, initiator, variables) { const flowDef await flowRepository.get(flowKey); const firstNodeId resolveNextNode(flowDef, start, variables); const instanceId await db.insert(flow_instance, { flow_key: flowKey, status: RUNNING, current_node_id: firstNodeId, variables, initiator, flow_snapshot: flowDef, // 快照防版本升级影响老实例 version: 1, created_at: new Date() }); await createTasksForNode(instanceId, firstNodeId, flowDef, variables); return instanceId; }注意 current_node_id 存的是 resolveNextNode 返回的节点不是 start。这样实例一创建待办列表就能查到第一个审批人的任务不出现流程启动了但没人可办的空窗。3.2 会签、或签和条件表达式的实现差别审批节点常见两种并发模式。会签AND同节点给多个人都派任务必须全部同意才向下走任何一人驳回就整节点驳回。或签OR多个人都可办第一个同意的人说了算其余人的任务直接取消。数据模型上不需要新表给节点配置加一个 signType 字段就行。async function submitTask(taskId, userId, action, comment) { const task await db.findOne(flow_task, { id: taskId, status: PENDING }); if (!task) throw new Error(任务不存在或已被处理); const instance await db.findOne(flow_instance, { id: task.instanceId }); const flowDef instance.flow_snapshot; const node flowDef.nodes.find(n n.id task.nodeId); await db.withTransaction(async (tx) { const updated await tx.update( flow_task, { status: action approve ? APPROVED : REJECTED, action, comment, acted_by: userId, acted_at: new Date() }, id ? AND status PENDING AND version ?, [task.id, task.version] ); if (updated 0) throw new Error(任务已被其他审批人处理请刷新); await tx.insert(flow_history, { instance_id: instance.id, node_id: task.nodeId, node_name: node.name, actor: userId, action, comment, created_at: new Date() }); if (action reject) { await rejectInstance(tx, instance, task, flowDef); return; } if (node.signType OR) { // 或签第一人同意取消同节点其余待办直接推进 await tx.update(flow_task, { status: CANCELED, comment: 或签已由其他审批人处理 }, instance_id ? AND node_id ? AND status PENDING, [instance.id, task.nodeId]); await advanceInstance(tx, instance, flowDef); } else { // 会签更新完之后数剩余待办 const remain await tx.count(flow_task, instance_id ? AND node_id ? AND status PENDING, [instance.id, task.nodeId]); if (remain 0) await advanceInstance(tx, instance, flowDef); } }); }条件表达式用 Function 构造器包一层求值注意这是有安全边界的做法只有后台管理员配置的流程定义能进这里任何用户输入都不该走到 eval 这一层否则就是代码注入漏洞。function evalCondition(condition, variables) { const keys Object.keys(variables); const values keys.map(k variables[k]); try { return new Function(...keys, return ${condition};)(...values); } catch (e) { throw new Error(条件表达式解析失败: ${condition}); } }这里有个血泪经验variables 里 days 可能是字符串 5表达式里写 days 3 会得到 false因为字符串和数字比较规则和你想的不一样。建议 startFlow 入口处强制做一次类型转换days、amount 这类数值字段统一转 Number。3.3 驳回、撤回、转交三个边界动作的参数设计驳回的目标不能写死我习惯在流程定义里加 rejectTargets 映射manager_approve 驳回到 starthr_approve 驳回到 manager_approve。这样设计人员画图时就能在每个审批节点上指定驳回给谁。驳回到 start 的语义是整单作废实例直接置为 REJECTED驳回到中间节点则重新给那个节点创建任务。撤回的约束只有一个实例上没有任何任务被处理过也就是还停在第一个审批节点且无人点击。只要有人批过就不允许撤回这是审批留痕的基本要求。转交则是换人不换节点async function transferTask(taskId, fromUserId, toUserId) { const updated await db.update(flow_task, { assignee: toUserId, version: db.raw(version 1) }, id ? AND assignee ? AND status PENDING, [taskId, fromUserId] ); if (updated 0) throw new Error(任务不存在或该用户已不是当前审批人); await db.insert(flow_history, { instance_id: instanceId, node_id: nodeId, node_name: nodeName, actor: fromUserId, action: transfer, comment: 转交给 ${toUserId}, created_at: new Date() }); }转交只允许当前 assignee 本人操作update 条件里带上 assignee fromUserId既能防止转交给自己的同事时误伤他人又天然处理了并发。3.4 事务边界状态更新、任务创建、历史写入必须同生共死前面代码里多次出现 db.withTransaction这不是摆设。审批推进这个动作至少改三处实例的 current_node_id、任务的状态、历史表的新记录。任何一处失败另外两处必须回滚。我见过线上翻车案例实例状态改成了 COMPLETED但任务表还剩一条 PENDING待办列表永远挂着一个幽灵任务。原因就是没包事务状态更新先写了创建任务时报错没人管。锁的选择上审批系统并发量不大乐观锁足够。update 语句带 status PENDING 和 version ? 两个条件返回值是 0 就说明被并发改了直接抛任务已被处理。不用 select for update那会放大锁粒度万一会签节点多人同时点反而把自己锁死。4. 前端怎么配流程设计器选型与审批页面的展示细节4.1 流程设计器选型bpmn.js、AntV X6 还是自研前端最纠结的就是设计器。我按自己的经验给个对比方案学习成本与自研引擎的对接适用场景bpmn.js高要懂 BPMN 2.0 语义难要处理 XML 与 JSON 互转必须导出标准 BPMN 文件给外部系统AntV X6中画布加节点边模型简单容易数据直接就是 JSON内部审批流自己定义节点类型纯自研画布低最容易节点类型固定、交互简单时我一般选 AntV X6。bpmn.js 的 XML 格式对自研引擎来说是个黑匣子你画完图还得写一堆转换代码把 modeler 的数据倒腾成引擎认识的 JSON这层转换就是 bug 的温床。X6 没有强语义节点就是矩形加数据边就是连线加条件文本和引擎的 JSON 定义天然对齐。import { Graph } from antv/x6; const graph new Graph({ container: document.getElementById(container), grid: true, connecting: { anchor: center, connector: rounded } }); graph.addNode({ id: manager_approve, shape: rect, x: 120, y: 100, width: 160, height: 44, label: 部门经理审批, attrs: { body: { fill: #fff, stroke: #3471F9 } } });节点 id 必须和引擎 JSON 里的节点 id 严格一致这是前后端约定的关键点。设计器保存时直接导出 X6 的数据结构后端只校验语义不重新生成 id。4.2 从画布数据导出引擎 JSON一个序列化函数搞定function exportFlowDefinition(graph) { const nodes graph.getNodes().map(n { const data n.getData() || {}; return { id: n.id, type: data.type, assigneeType: data.assigneeType, assigneeValue: data.assigneeValue, signType: data.signType }; }); const edges graph.getEdges().map(e { const data e.getData() || {}; return { from: e.getSourceCellId(), to: e.getTargetCellId(), condition: data.condition || }; }); return { key: form.flowKey, name: form.name, nodes, edges }; }导出之后前端要做的最后一件事是把结果 POST 到后端的校验接口而不是直接存库。校验逻辑我放在后面章节讲但必须在前端也展示错误哪条边指向了不存在的节点、哪个审批节点没配审批人红色的报错要能定位到画布上的具体对象。4.3 审批操作页和流程历史两个容易被忽略的细节待办列表的查询不能裸奔。assignee status 是最高频的过滤条件必须建联合索引CREATE INDEX idx_task_assignee_status ON flow_task(assignee, status);历史时间线展示时动作值别只存 approve / reject 这种英文枚举写历史那一刻就把中文动作文本一并存了或者前端做字典映射。否则后面想改成同意、驳回、转交、撤回的展示得翻所有历史数据。5. 审批流避坑实录并发覆盖、角色换人、条件误判、状态漂移5.1 并发提交导致审批状态覆盖现象两个审批人同时点同意一条任务被处理两次实例的当前节点跳了两个历史里出现两条互相矛盾的操作记录。原因典型的先读后写竞态。两个请求都读到 status PENDING各自执行 update后写的覆盖先写的。解决update 语句必须带 status PENDING 条件配合 version 乐观锁。影响行数为 0 时直接抛任务已被处理。这条规则同时约束了 submitTask、transferTask、withdraw 三个入口。5.2 审批人配的是角色人离职后实例悬挂现象节点配的是部门经理这个角色但角色下唯一的人在审批中途离职任务永远停在 PENDING没人能办。原因创建任务时把角色解析成具体用户写死角色变动不会回写到历史任务。解决任务表冗余 assignee 字段的同时把 assignee_type 和 assignee_value 也存下来。另加一个定时任务每小时扫一次 PENDING 任务如果 assignee 对应的用户已离职或不在角色里触发重新解析审批人。更省事的方案是给节点配超时转交超过 48 小时自动转给上一级角色。5.3 条件表达式写错导致流程乱走现象2 天的请假走到 HR 审批10 天的请假反而直接通过审批结果和业务规则完全相反。原因变量名对不上引擎拿到的 variables 里字段叫 dayCount条件表达式里写的是 dayseval 时读到 undefined所有比较都成了 false或者类型不对字符串 5 和数字 3 比较结果不可预期。解决startFlow 入口统一做变量类型转换数值字段强制 Number。条件表达式在保存流程定义时做静态校验变量名必须存在于 schema表达式的括号、比较符必须合法。最稳的是把常见对比词限制白名单不用开放式的 Function 求值。5.4 驳回后重新提交老实例被新规则误判现象流程定义升了版本条件从 days 3 改成 days 5老实例重新提交后走到条件节点用的却是新规则该走的流程走反了。原因实例推进时读的是当前最新流程定义而不是发起那一刻的版本。解决startFlow 时把 flow definition 整个写入 flow_snapshot 字段submitTask 里一律用 instance.flow_snapshot 计算后继节点和线上的最新版本完全隔离。这条规则从一开始就要定死不要留反正先上线再说的侥幸。5.5 流程日志与当前状态不一致现象流程历史已经显示HR 审批通过但待办列表里还有 HR 的任务挂着用户反复刷新都消不掉。原因状态更新和任务创建不在同一个事务里或者是会签节点只更新了当前这条任务没检查同节点其他 PENDING 任务是否该一起取消。解决所有状态变更、任务创建、任务取消、历史写入必须包在同一个数据库事务中。会签和或签的处理逻辑要分开测会签是数剩余或签是主动取消剩余。我后来把这两个分支写成了独立函数避免在 if 里越写越乱。6. 给审批流做体检最小闭环用例、配置校验器与待办索引优化6.1 一条最小闭环用例跑通全流程每上线一个流程定义我都会维护一组 Node.js 测试脚本覆盖正常通过、条件分支、驳回、撤回四条路径。最小闭环用例长这样async function testLeaveApproval() { const instId await startFlow(leave_approval, u_001, { days: 2 }); let tasks await findPendingTasks(instId); assert.equal(tasks.length, 1); await submitTask(tasks[0].id, u_003, approve, 同意); const inst await getInstance(instId); assert.equal(inst.status, COMPLETED); // 2 天以内经理审批后直接结束 }这脚本比 UI 上点十遍靠谱得多。引擎发版前跑一次条件分支改动后跑一次基本能挡住九成的回归。6.2 配置校验器在设计期拦截非法流程图function validateFlowDef(flowDef) { const ids new Set(flowDef.nodes.map(n n.id)); if (!ids.has(start)) throw new Error(缺少开始节点); if (flowDef.nodes.filter(n n.type end).length 0) { throw new Error(至少需要一个结束节点); } for (const e of flowDef.edges) { if (!ids.has(e.from) || !ids.has(e.to)) { throw new Error(边 ${e.from} - ${e.to} 引用了不存在的节点); } } for (const n of flowDef.nodes) { if (n.type approve !n.assigneeType) { throw new Error(审批节点 ${n.id} 缺少审批人配置); } } }校验器放在后端保存接口里前端画布保存时也会先调一次错误信息直接定位到节点 id。合格的设计器交互应该是画完就能跑而不是等流程跑起来才发现定义有洞。6.3 待办索引、超时提醒与版本迁移待办查询务必确认索引建了全表扫描的流程任务表超过十万条后每个接口响应都会肉眼可见地变慢。超时提醒用定时任务扫 PENDING 超过 N 小时的记录触达方式可以是站内信或机器人群机器人。流程定义升级时只影响新发起的实例老实例按快照执行这就是 flow_snapshot 的价值。我现在的习惯是接到审批流需求先画状态图再写引擎最后才碰设计器和页面。状态图没画明白就动代码后面全是给翻车补窟窿功能上线后维护成本会比开发成本高好几倍。希望这些经验帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网