简介: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 或 Flowable,Node.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 集合元素数是否为 0:
const 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 引擎还不到那个成熟度。希望帮到你。
本文还有配套的精品资源,点击获取