ARTICLE DETAIL

资讯详情

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

掌握JS自研审批流:从状态机到JSON流程定义的关键设计与避坑指南

掌握JS自研审批流:从状态机到JSON流程定义的关键设计与避坑指南 简介JS工作流与审批流实战代码包面向需要实现业务流程管理的前端/全栈开发者可用于快速理解审批流程的设计与编码思路。整个代码包以JavaScript为核心搭配HTML界面、CSS样式、XML流程定义和ASPX后端处理完整覆盖了从流程建模、任务分发、审批操作到状态持久化的关键环节同时包含多语言文件、右键菜单及动态交互效果。压缩包内共49个文件主要类型包括js脚本、html页面、xml定义、aspx/cs后端、css样式以及gif/png图片素材整体仅69KB结构紧凑便于查阅。目前已有1285人学习浏览适合希望从零上手JavaScript工作流/审批流的中级开发者。参考该代码包可掌握基于状态机或BPMN的流程设计方法、任务调度与异常回退机制以及工作流引擎与后台接口的对接方式是一份能直接运行并二次改写的完整示例。1. 用 JS 自研审批流第一版看着简单三个月后就成了黑匣子“js 工作流审批流”这些年被问得最多的解法往往是动力语气用 Node.js 写一套审批后端前端配合画一个流程图然后把“同意/驳回”两个按钮一接——看起来两天就能上线。真实情况是三周后业务方开始提“会签”“或签”“撤回”“退回指定节点”“会签人动不动漏掉一个”你会发现所谓状态机里塞满了补丁代码看着像打补丁的循环修修补补改一个分支参数可能影响十几条既有审批单这就是第一批做 JS 审批流的人最常踩的坑。JavaScript 不是不能做工作流审批流也不是什么玄学它本质上是“流程描述 流程推进器 任务状态存储”。这篇文章不讨论 Activiti、Flowable 那类重型 Java 引擎也不把 AI 编排、Coze 工作流这类工具往审批上套而是给你一条从业者常用的自研路线怎么选型、怎么落表、怎么写流转器、会签怎么处理以及那 5 个不跑一遍根本不会信的边界坑。适合两类人一是团队里没有 Java 基础设施、纯 Node 技术栈想接审批需求的人二是已经有老审批系统、想换掉硬编码状态机的人。读完你能获得一套最小可用的模型以及验证它的方法。2. 审批流建模BPMN 2.0 和轻量状态机选哪个先把这六类场景列出来2.1 审批流到底是什么以及为什么不能用 if else 硬写审批流这个词在工作流里的界定比很多人想的窄。它专指“由人参与的、有多个处理节点、节点之间有先后或并行依赖、同一份业务数据在不同节点被不同角色审核”的任务流转过程。典型例子是员工提交报销单 - 直属主管审批 - 财务复核 - 出纳打款或者采购流程里“部门负责人审批 - 财务审批 - 总经理审批”三个串行节点。它和 CI/CD 流水线工作流的区别在于流水线的节点是自动执行的、做确定性计算审批流的节点大多落在“人处理”处理结果不是计算值而是具有业务语义的“同意 / 驳回 / 退回 / 转办”并且处理状态会持续变化。很多新手的第一版实现是硬编码状态机。比如给订单表加一个 status 字段用 if else 判当前状态、下一个状态、可执行角色然后就是每一笔审批单对应一张业务表——报销单一个状态字段采购单又一个状态字段合同单再来一套。这种做法的直接后果是每个流程各写一套状态流转逻辑字段含义不统一审批历史没处查驳回之后回到哪个节点靠代码里抄硬的“prevStatus 减一”来实现。三个月后一个新的门禁审批又要红通通的逻辑于是开始猛然觉得审批流需要的是一个抽象层——把“流程定义”和“具体业务数据”分开。2.2 三条可行路线的对比BPMN 2.0、JSON 流程定义、一颗状态机的变体从业者做 JS 审批流技术选型时通常面对三条成熟路线完整 BPMN 2.0 方案前端用 bpmn-js 画流程图后端部署 Camunda 或 FlowableNode.js 通过 REST API 请求引擎。阶跃式协同节点多、子流程多、国际标准要求重的场景适合。它的代价是引擎重部署运维成本高JS 团队要维护一个 Java 服务模型修改后的版本管理也成了问题。轻量 JSON 流程定义用自定义 JSON 描述节点、边、分支条件和处理人规则自己实现一个很小的流转器。适合内部 OA、审批链路固定、改动不频繁的中小团队。灵活度高但标准要靠自己维护没有图形化界面时非常依赖人工读配置。纯前端状态机库比如用 xstate 维护审批状态适合纯前端展示、无后端强校验的场景比如“一个单子上只有三个状态”这种极简需求。但它不解决多人会签、持久化、并发提交这类问题最多算状态护城河。我自己的结论是如果你要接的是实时业务系统里的审批流报销、采购、合同、用印而不是页面交互状态机第二条“JSON 流程定义 自研流转器”的性价比最高。原因很直白审批流的业务语义解析退回节点、会签基数、条件分支大多数是“定义时能说清执行时只要确认不崩”的东西标准化的 BPMN 对更多业务来说反而是过度设计。那怎么判定该不该上 BPMN用这张对照表就够判断维度轻量 JSON 流程完整 BPMN 2.0 Camunda串行审批节点数适合 5 个以内的串行无上限并行会签 / 或签自己实现代码量约 200 行原生支持子流程、事件网关需要自行扩展协议标准能力运维成本Node 服务内嵌零额外依赖需要维护一个独立 Java 引擎审批单来源业务系统内嵌需要和外部系统做集成关键痛点规则描述缺少校验配置错了没人告诉你引擎复杂需求变更和部署成本高2.3 落到代码前要画的四张图流程、节点、任务、事件选型确定后不要急着写引擎先画四张图这四张图决定你的数据模型和代码结构。第一张是流程图每个节点只有三种基础类型——开始、审批、结束审批节点再细分为“一人审批”和“多人会签”。节点之间是边边上有条件表达式。第二张是状态图审批实例instance有 running、completed、canceled 三种状态审批任务task有 pending、approved、rejected、canceled 四种状态。第三张是事件与历史每次流转都要落一条 event 记录event 里记录操作人、动作、目标节点、操作时间、附带消息。第四张是数据归属审批实例必须关联业务主键比如报销单号但不能把审批字段写到业务表里两者分离这是后面“数据快照”的前提。我当时没有画事件图结果第一个线上问题就是“谁在什么时间点了同意”查无对证后来花了半天从操作日志里翻。所以这里强烈建议无论多小的审批流事件表在第一天就建好而且不接受临时改需求时不去写事件的做法。3. 最小可用 JS 审批引擎任务表、流转器与或/会签怎么落地3.1 核心数据结构的选型内存嵌套还是平铺两张表实现 JS 审批流最重要的不是引擎代码而是状态存储模型。常见做法是用两张平铺的表审批实例表card/workflow_instance和审批任务表workflow_task业务数据本身留在业务表里。实例表描述“这一笔审批单”的整体状态和控制信息任务表描述“当前有哪些人需要处理”。这种平铺结构有四个好处一是并发更新互不干扰对 task 表做更新时锁粒度小二是可以方便地查询某个人待办的审批单三是驳回的时候可以按任务阶段回溯四是事件表天然独立查询历史不触碰业务主表。我先给你一个能跑的最小内存版后续再补持久化。这个版本用数组模拟存储重点看流转器的控制逻辑。// 例最小审批流转器处理“串行审批 会签” const store { instances: [], // 每个实例含 { id, currentNodeId, status, tasks: [] } events: [], // 审计事件 { id, instanceId, action, operator, time } }; function createInstance(instanceId, flowDef) { const inst { id: instanceId, flowDef, // 流程定义快照 currentNodeId: flowDef.startNodeId, status: running, tasks: [], }; // 启动节点直接出边进入第一个审批节点 activateNextTasks(inst); store.instances.push(inst); } function activateNextTasks(inst) { const node inst.flowDef.nodes[inst.currentNodeId]; if (!node || node.type end) { inst.status completed; return; } if (node.approverType or) { // 或签任一人审批即可生成一个 task const task { id: task_${inst.id}_${node.id}_${Date.now()}, nodeId: node.id, status: pending, assignees: node.assignees, }; inst.tasks.push(task); } else if (node.approverType and) { // 会签每个 assignee 生成一个 task全部同意才过 node.assignees.forEach((user) { inst.tasks.push({ id: task_${inst.id}_${node.id}_${user}_${Date.now()}, nodeId: node.id, assignee: user, status: pending, }); }); } } function onApprove(instId, taskId, operator, comment) { const inst store.instances.find((i) i.id instId); if (!inst || inst.status ! running) return { ok: false, msg: 实例不可用 }; const task inst.tasks.find((t) t.id taskId); if (!task || task.status ! pending) return { ok: false, msg: 任务已处理 }; task.status approved; task.operator operator; task.comment comment; store.events.push({ instanceId: instId, taskId, action: approve, operator, time: Date.now() }); // 判断当前节点是否全部完成 const nodeId task.nodeId; const pendingTasks inst.tasks.filter( (t) t.nodeId nodeId t.status pending ); if (pendingTasks.length 0) { moveToNextNode(inst, nodeId); } return { ok: true }; } function moveToNextNode(inst, fromNodeId) { const edge inst.flowDef.edges.find( (e) e.from fromNodeId (!e.condition || evalCondition(e.condition, inst)) ); if (!edge) { inst.status completed; return; } inst.currentNodeId edge.to; activateNextTasks(inst); }这段代码的运行逻辑分四步创建实例时定位到 startNodeId 之后的第一个审批节点根据节点 approverType 生成任务审批人调用 onApprove 时先校验实例和任务状态再落事件最后判断当前节点所有 pending 任务是否清零清零后才移动到下一条边。需要注意一个关键点activateNextTasks和moveToNextNode的循环在 start 节点和 end 节点之间靠边的条件表达式驱动表达式的求值一旦抛错流程就会中断所以 evalCondition 内部必须有兜底逻辑。关于参数走几个初始配置就能理解行为const flowDef { startNodeId: start, nodes: { start: { type: start }, leader_approval: { type: approval, approverType: or, // or 或签and 会签 assignees: [zhangsan, lisi], }, finance_check: { type: approval, approverType: and, assignees: [wangwu, zhaoliu], }, end: { type: end }, }, edges: [ { from: start, to: leader_approval }, // 金额大于 5000 才走财务复核否则直接结束 { from: leader_approval, to: finance_check, condition: amount 5000 }, { from: leader_approval, to: end }, { from: finance_check, to: end }, ], };这里我故意把条件表达式写成了字符串amount 5000。实际工程里你一定会遇到“表达式的解析问题”这是后面会展开的坑。至少先把节点类型、审批方式、指派人和边关系定下来这套模型已经足够支撑最常用的三类审批串行、或签、会签。驳回和撤回需要额外处理放在避坑章节里讲。3.2 表达式条件求值的兜底写法字符串字面量解析到安全的数值比较很多人把流程定义里的条件写成amount 5000这样的字符串然后在 JS 里直接eval()。这在内部小工具里能跑但在生产环境里等于让流程定义执行任意代码业务配置员改了流程定义实际上就获得了 Node.js 服务进程的命令执行权限安全问题比代码 bug 严重得多。从业界的常见方案看有三层做法如果用表达式字符串必须用沙箱化求值比如把字段值准备好后用一个白名单式的求值函数替代 eval或者用 node-vm2 这类库但尽量少用。更稳妥的是直接不放表达式字符串而是把条件抽象成“比较操作符 字段 期望值”由流程引擎执行 JSON 化的比较不执行任何代码。例如{ field: amount, operator: gt, value: 5000 }。无论哪种做法求值过程必须对大小写做归一化。比如js 判断字符串是否包含经常用str.includes但includes默认区分大小写如果业务字段值大小写不一致条件分支就会在无提示的情况下落入 default 边。这就是“配置了半天流程走了别的分支”最常见的黑匣子原因。我的建议是宁可走第二条JSON 条件表达不要字符串表达式。float 数值比较注意精度用 decimal 类型或比较前做整数化比如金额单位用分来存储字符串比较按业务需求决定大小写规则默认 lowerCase 后比较。条件命中测试单独写一组单元测试不要留给线上验证。3.3 数据快照为什么审批人看到的数字永远不随草稿变化审批流的几大灵异问题里“审批人看到的金额和提交人最后提交的金额不一样”排前三。原因是很多团队把流程字段直接绑定在业务表上订单金额字段被后续编辑覆盖了审批单引用的还是同一个业务对象。解决这个问题不靠状态机靠快照——创建一个审批实例时就把当前需要审批的关键业务数据复制一份存到 workflow_instance 或单独的快照字段里之后流转过程全部引用快照不引用实时业务表。function createInstance(instanceId, flowDef, bizData) { const inst { id: instanceId, flowDef, snapshot: { bizType: reimbursement, bizId: bizData.bizId, amount: Number(bizData.amount), reason: String(bizData.reason), submitter: bizData.submitter, snapshotAt: Date.now(), }, currentNodeId: flowDef.startNodeId, status: running, tasks: [], }; // 后续表达式的字段值统一用 inst.snapshot不访问实时业务数据 activateNextTasks(inst); store.instances.push(inst); }这段代码体现了快照的核心思路取出字段值时复制一份而不是引用传递。审批流程里条件判断用的字段、审批人看到的字段、事件记录里的上下文信息全部从 snapshot 取值。业务方之后改了订单明细不会影响这次审批单。如果需求里需要“审批通过后把最新值回写到业务表”那也是在审批完成的回调里用 snapshot 里的主键做更新而不是让审批流直接操作业务表。3.4 事件表审计怎么顺手写掉最小引擎里我加了一个store.events实际落地时要换成数据库表workflow_event字段至少是 event_id、instance_id、task_id、action、operator、target_node_id、comment、created_at。action 统一用动词枚举start、approve、reject、revoke、transfer、cancel。写事件的操作必须和审批状态更新放在同一个事务里不要只更新任务状态忘写事件也不要先写事件后改状态。一个常见的做法是审批操作统一封装成一个 service 方法方法内 update task 状态 insert event 记录任何一步失败整体回滚。这个约定简单却能省掉后续巨量的对账工作。4. 跑通不算完审批流里最容易翻车的五个边界场景与排查顺序4.1 重复提交与并发审批一个订单被同一个审批人通过两次现象同一笔审批单两个审批人几乎同时点了“同意”数据库里的 task 状态都变成了 approved事件也写了两条业务表回调被执行了两次。一次重复提交可能带来重复打款、重复发货这种严重后果。原因审批动作是“读状态 - 改状态 - 写事件”三步如果没有并发控制两次请求都读到了 pending 状态然后各自执行更新TASK 表最后状态是 approved但回调执行了两次。状态字段本身不会拒绝因为第二个请求执行更新时并没有把 status 作为条件。解决更新语句必须带状态条件让数据库帮我们做原子操作。SQL 写成这样再执行后续回调UPDATE workflow_task SET status approved, operator :operator, finish_time :now WHERE task_id :taskId AND status pending如果这条语句的受影响行数为 0说明任务已处理直接审批失败并返回“任务已处理”的提示。然后再插入事件、执行回调。持久化层去掉后所有“先查再改”的审批操作都不能裸奔。4.2 驳回之后重提不了实例状态停在 completed业务被卡死现象审批人点了驳回实例自动进入 completed 状态提交人想修改后重新提交结果发现没有任何提交入口。原因第一版设计把驳回等同于审批流程结束实例状态置为 completed忽略了“退回重提”这类高频业务语义。驳回实际上有两层含义一是流程终止不再流转二是退回到某个历史节点让某个审批人重审。二者完全不同不能用同一个状态表达。解决在流程定义中增加 rejact 的目标节点配置通常有两种模式。最简单的是驳回即结束在需求评审时和业务方确认“驳回后不允许重提”然后把按钮文案写成“终止流程”避免误导。更常用的是支持重提驳回时把实例状态置为 review_pending并把某个节点上的任务重新生成或者允许提交人从某个可编辑节点重新发起。用事件表和快照配合可以实现“重新提交时沿用实例 ID 但生成新的任务批次”不要让每次重提都新建一个实例否则历史记录撕裂成好几单。业务上喜欢看到“一笔报销单从头到尾的完整轨迹都在同一个实例里”。4.3 会签算人头数算错了总任务数统计子用 task 数量忽略已取消节点现象一个“会签 4 人”的节点实际指派了 4 个审批人其中一人点击了“转办”任务交了出去。引擎判断节点是否完成时用 pending 数量是否为 0 来判断结果活人只剩 3 个 pending等于永远等不到第 4 个审批流程卡住。原因转办、加签、减签这些操作会改变任务实例的数量和状态。如果你统计本节点剩余任务数时把“已转出”的任务也算进去或者把“已取消”的任务排除后没同步生成新任务pending 计数就会失真。解决计算“当前节点是否全部完成”时要以“应当继续处理的 task”集合为准。流程定义里 assignees 是静态配置运行期可能被动态调整转办生成一个新 task 给接收人原 task 标记 canceled。建议引入一个 active 集合维护本节点的活跃任务 ID 集合任何转办动作都做“cancel 原任务 → 创建新任务”两个原子操作判断完成时只看 active 集合元素数是否为 0const activeTasks inst.tasks.filter( (t) t.nodeId nodeId t.status pending ); if (activeTasks.length 0) { moveToNextNode(inst, nodeId); }这里有一个隐含要求所有任务状态变更必须走统一入口不要在业务代码里直接点改 task.status。统一入口里做事件写入、缓存清理、进度通知否则很容易出现任务卡住而事件里看不出原因。4.4 条件表达式大小写导致分支乱走includes默认区分大小写现象流程定义配置了city includes 北京业务数据里存的是“bj”来自上游系统的小写编码条件求值永远返回 false流程每次走到默认分支审批人一脸懵。原因js 判断字符串是否包含这类方法在字符串比较时需要明确大小写策略。JavaScript 的String.prototype.includes是区分大小写的不加处理就直接求值很容易产生隐性 mismatch。解决在表达式求值层统一字符串规范化比较前两边都转小写或定义编码规范城市编码统一大写。如果走 JSON 化条件可以在 operator 层做一层封装把includes的语义升级为caseInsensitiveIncludes的默认值。别忘了处理 null字段不存在或值为 null 的时候条件一律返回 false 而不是抛错并写一条 warning 到日志方便定位配置问题。4.5 超时自动审批的定时任务重复执行把单人任务关闭两遍现象定时任务每 5 分钟扫描一次超时待办发现有任务超过 24 小时未处理自动执行“超时转给上级”。但由于上一次扫描执行到一半服务重启批次未标记完成下一次扫描又把同一个任务重复处理了一遍任务被创建了两个副本。原因定时任务的批次去重和任务状态校验没做好。任务处理时只判断“存在”和“超时”没有把“当前 task 状态”纳入幂等条件。解决扫描逻辑要保持幂等。标准做法是先把扫描批次写入一张 job_batch 表获取任务时用工作机会锁定状态处理时同样使用条件更新。更重要的是任务处理动作必须和 task 状态关联——只有 pending 的任务才能执行超时操作且超时操作生成的新任务要和原任务在同一事务里原任务置为 canceled新任务置为 pending。5. 从像样到能上线给流转器补上日志、版本和超时提醒再去做压测审批流从“能跑通主链路”到“敢接生产业务”有一层细节不补不行。第一是全局追踪链路在事件表里保留 trace_id前端可以在一条审批单的详情页里点开“流转轨迹”看到每个节点何时被谁处理、停留多久这对排障比对 log 有效得多。我刚做审批流那种“线上出问题先导日志再摸事件”的做法后来发现直接从事件表按 instance_id 倒序排一次一眼就能定位到卡住的节点。第二是流程定义版本化。业务方改审批流程是常态今天加一道主管审批下周又去掉。正确的做法是在 createInstance 时把完整 flowDef 存入实例改流程定义不影响已生成的审批单新单才用新版本。如果流程定义改了而历史实例的 currentNodeId 引用了已删除的节点名流转器 move 时会直接找不到节点。所以每次改动流程定义时要额外写一个校验脚本跑全量历史实例确认没有实例处在“旧节点已删除”的中间态。第三是超时提醒和补偿。任务表的 pending 状态必须有一个截止时间字段由定时器扫描超时任务并触发通知。这里要明确“通知”和“自动处理”是两套逻辑多数情况下只要提醒就行自动转办要单独配置且必须做审批权限确认。定时器依赖服务器时区统一用 UTC 存储时间展示层再转本地时区避免不同时区的实例算错超时边界。最后是压测验证。审批流不是高并发系统真正该重点压测的是“并发会签判断的锁竞争”而不是盲目刷 TPS。用一个 Invoice 节点、四个审批人同时同意观察任务状态更新是否有重复回调和卡死。合理预期是单机 Node 服务在这个模型下能扛住几十个并发审批操作更多时会出现任务锁等待这时先把事务粒度缩小到单任务更新再考虑水平扩展。我自己在第二个审批项目里养成了个习惯每次改动流转器核心代码先用同一笔流程定义跑三个角度测试——全部同意、第二节点驳回、会签节点一人超时三条路径的 event 数量、task 状态、instance 终态都要对得上。这个习惯帮我挡掉了至少 5 次线上事故。审批流这东西业务上看着小逻辑上却是典型的“不做不知道做了才知道坑有多深”的方向。如果一个需求能用我的这套模型覆盖那自研就很划算如果业务方一开始就提出“流程图要像 Visio 一样能拖拽、回退要能回到任意历史节点”别犹豫直接评估 Camunda 吧你的 JS 引擎还不到那个成熟度。希望帮到你。本文还有配套的精品资源点击获取
返回列表