☰
JS轻量级审批工作流实战:状态机建模与路由设计
2026/9/26 11:53:02 网站建设 项目流程

简介:一份关于JavaScript工作流与审批流实现的完整示例包,面向Web前端开发、业务流程管理及需要设计审批系统的技术人员。内容涵盖工作流概念、状态机驱动、任务分配、权限控制、异常回退等核心机制,并结合BPMN/XML建模与Ajax异步通信,帮助理解从流程定义到界面交互再到数据持久化的落地方式。压缩包共49个文件,其中包含8个js脚本、8个html页面、6个xml流程定义、2个aspx及2个cs后端代码,另有gif演示、png图片和样式文件,整体仅69KB,结构紧凑,适合快速参考。目前已有1285人学习浏览。通过研读该示例,可以掌握JavaScript工作流引擎的基本设计思路,学会用JS构建可视化审批界面、配置流程节点与状态迁移,并将前端流程与后端服务集成,是一份轻量但完整的入门参考。

1. JS 工作流与审批流:轻量级方案为什么更扛得住实际需求

接手过一套内部采购审批系统,最初用 Flowable,流程引擎本身没问题,但一台 4G 内存的服务器跑上 MySQL、Redis 和流程引擎,光部署就吃掉一半资源,改一条审批条件还得重新发版。后来我把整套流程改成 JS 实现的轻量级工作流:前端画节点、配连线条件,后端 Node.js 维护状态机,审批动作只做状态流转和路由判断,单次审批响应从 800ms 降到 60ms 左右。这套方案的边界很明确,适合内部管理系统、订单审核、人事审批这类节点少、规则不复杂的场景,不能取代重型引擎,但能解决“明天就要上线一个审核流”的刚性需求。写法从建模开始,一路到代码骨架、踩坑记录和排查技巧,最后聊聊怎么用日志重放验证流程正确性。

2. 审批流建模:从状态机到节点-连线模型的三个关键选择

做 JS 审批流之前,先别急着写代码。工作流的本质是一台状态机加上一组路由规则:审批实例有状态,动作改变状态,状态决定下一步走到哪个节点。把这层理清,后面的实现才有底气。很多人在这一步就翻车,一上来就画界面,结果节点、边、条件全混在一起,改一处动全身。

2.1 先定状态集合,再定节点类型

审批流的节点类型不需要照搬 BPMN 那套完整标准。实际业务里常见的就五种:开始、审批、条件分支、会签、结束。节点越少,前端画布和后端路由的实现成本越低。

节点类型代码标识职责
开始节点start流程入口,没有审批人
审批节点approval单人审批,通过、驳回、转办
条件分支condition根据上下文选择唯一出口
会签节点countersign多人按规则汇总结果
结束节点end流程终止

实例状态和节点状态要分开理解。节点的 type 描述“这一步做什么”,实例的 status 描述“整个流程现在处于什么阶段”。我一般只保留五个实例状态,再多就是自找麻烦。

状态值含义触发动作
pending待审批,流程进行中创建实例、审批通过但未到结束节点
approved流程通过走到结束节点
rejected流程驳回驳回动作
canceled流程取消发起人取消
waiting等待子流程或会签完成进入会签节点

选型时很多人问为什么不直接用 n8n、coze 这类工具。它们是偏自动化的工作流工具,适合接口编排和 AI 流程,但审批流强依赖“人审 + 状态落库 + 操作留痕”,用 JS 自研更加可控,也方便和现有业务表共用事务。轻量级 JS 方案的核心就是状态机足够小,路由规则足够清晰。

2.2 条件写在线边,不写进节点里

审批流最常见的错误是把路由条件写死在节点逻辑里。比如“金额大于一万走财务审批”,写在 approval 节点的代码里。这样做第一次没问题,第二次加了“金额大于五万走总监审批”,就要改代码、发版。

正确做法是条件挂在边上。边是一条有方向的连线,包含 from、to 和 condition 三个字段。节点只负责“做什么”,边负责“下一步去哪”。这样流程定义可以整体存成 JSON,前端画布改完配置,后端直接加载运行。

const workflow = { nodes: [ { id: 'start', type: 'start' }, { id: 'approval_manager', type: 'approval', approver: 'manager' }, { id: 'approval_finance', type: 'approval', approver: 'finance' }, { id: 'end', type: 'end' } ], edges: [ { id: 'e1', from: 'start', to: 'approval_manager', condition: null }, { id: 'e2', from: 'approval_manager', to: 'approval_finance', condition: { field: 'amount', op: 'gte', value: 10000 } }, { id: 'e3', from: 'approval_manager', to: 'end', condition: { field: 'amount', op: 'lt', value: 10000 } } ] };

这段代码里,nodes 数组定义节点,edges 数组定义路由。e2 表示经理审批通过后,如果实例数据里的 amount 大于等于 10000,就走到财务审批;e3 是互补条件,金额小于 10000 直接结束。condition 里 field 字段名对应实例 data 里的键,op 是操作符,value 是阈值。

注意这里 e2 和 e3 的条件是互斥且完备的,路由函数必须保证任何输入都能匹配到一条边,匹配不到就抛异常,防止流程静默卡死。

2.3 条件表达式别直接 eval,用白名单解析

JS 里一个偷懒的写法是用 eval 直接执行条件字符串,比如eval("context.amount >= 10000")。这在本地跑通很容易,但放到生产环境就是黑匣子加后门:用户提交的数据进入 eval,等于把代码执行权交了出去。哪怕只有内部对象使用,也扛不住字段里带恶意负载。

推荐做法是把条件收敛成一个白名单解析函数,只支持固定的操作符集合。业务上足够用了,常见的就是大于、小于、等于、在集合里、包含。

function evaluateCondition(condition, context) { if (!condition) return true; const { field, op, value } = condition; const actual = context[field]; switch (op) { case 'gte': return actual >= value; case 'lte': return actual <= value; case 'eq': return actual === value; case 'in': return Array.isArray(value) && value.includes(actual); case 'contains': return String(actual).includes(value); default: throw new Error('unsupported op: ' + op); } }

这个函数先取 context 里对应 field 的值,再按操作符比较。遇到不认识的 op 直接抛异常,而不是返回 false。返回 false 会让路由函数以为“这条边不匹配”,可能错误地走到另一条边;抛异常则能让问题第一时间暴露。

使用这个方案时,条件定义就变成了纯 JSON,前端流程图保存的配置可以直接传给后端,不再需要传输函数字符串。字段名映射必须在流程启动前做好约束,否则就会出现配置里写 amount、实例里存 totalAmount 这类问题,后面避坑章节会专门讲。

3. JS 实现审批流转:流程定义、路由函数与并发控制

建模完成后进入实现阶段。这一章给出一套可以直接复制的代码骨架,核心是三个函数:创建实例、处理审批动作、路由定位下一节点。再补上前端联调方式和操作记录落库。

3.1 流程定义与实例上下文分开存

流程定义是一份静态 JSON,描述节点和连线;实例是运行时的动态数据,记录当前走到哪个节点、提交了什么内容、状态是什么。两者必须分开存。如果混在一个对象里,流程一改版,老实例全部失效。

// flow-instance.js const crypto = require('crypto'); function createInstance(workflowId, initData, operator) { return { instanceId: crypto.randomUUID(), workflowId, status: 'pending', currentNode: 'start', data: { ...initData, applicant: operator }, history: [], createdAt: new Date().toISOString() }; }

createInstance 接收三个参数:workflowId 标识用的是哪份流程定义,initData 是业务数据,operator 是发起人。currentNode 指向开始节点。data 里除了业务字段,还会塞入发起人账号,因为条件路由里经常要判断“发起人是不是部门负责人”。

实例创建后要立即落库,后续每次动作都在同一个事务里读取、修改、写回。这套骨架没有接入数据库,但函数返回值就是纯 JSON,序列化后存 MySQL、PostgreSQL 或者 MongoDB 都行。

3.2 审批动作处理:approve、reject、transfer 与自动路由

审批动作是工作流的核心入口。一次审批动作必须同时完成三件事:写历史记录、更新状态、定位下一个节点。下面这个 handleAction 函数覆盖了常见的四个动作。

function handleAction(workflow, instance, action, extra) { const node = findNode(workflow, instance.currentNode); if (!node) throw new Error('node not found: ' + instance.currentNode); if (node.type !== 'approval') { throw new Error('current node is not approvable: ' + node.id); } pushHistory(instance, action, extra); if (action === 'reject') { instance.status = 'rejected'; return instance; } if (action === 'cancel') { instance.status = 'canceled'; return instance; } if (action === 'transfer') { instance.currentNode = findNextNodeId(workflow, instance); return instance; } const next = findNextNodeId(workflow, instance); instance.currentNode = next; if (findNode(workflow, next).type === 'end') { instance.status = 'approved'; } return instance; }

handleAction 的逻辑是:先校验当前节点能不能审批,然后写历史,再按动作分支。reject 和 cancel 直接置终态,transfer 转交后照样要定位下一个节点;approve 是默认路径,找到下一节点后,如果下一节点是 end,就把实例状态置为 approved。

这里有个关键设计:transfer 不会改变实例状态,只是更换审批人。实现上可以把审批人信息放在节点的 assignee 字段,transfer 就是更新 assignee。上面的骨架省略了这一步,落到实际项目时在 pushHistory 之后更新 instance.data 里的审批人字段即可。

findNextNodeId 负责根据连线和条件找到目标节点。

function findNextNodeId(workflow, instance) { const outgoing = workflow.edges.filter(e => e.from === instance.currentNode); if (outgoing.length === 0) { throw new Error('no outgoing edge at node: ' + instance.currentNode); } const matched = outgoing.find(e => evaluateCondition(e.condition, instance.data)); if (!matched) { throw new Error('no matched route at node: ' + instance.currentNode); } return matched.to; }

findNextNodeId 先拿当前节点的所有出边,再用 evaluateCondition 按顺序匹配第一条为 true 的边。注意这里用的是 find 而不是 filter,意味着同一节点多条出边时,只要第一条匹配就返回,后面的不再判断。所以条件边的排序很重要,优先级高的条件必须放在数组前面。

参数说明:outgoing 是无条件或条件未匹配的边集合,matched 是第一条匹配成功的边。instance.data 是路由判断的上下文,条件字段必须在这个对象里存在,否则 evaluateCondition 取到 undefined,比较结果会变得不可预测。

3.3 前端流程画布与后端联调

前端画布可以接入成熟的 JS 流程渲染库,比如 AntV X6、LogicFlow,它们都能拖拽节点、连线、配置属性。核心约定是:画布保存出来的 JSON 必须和后端 workflow 定义的格式一致,nodeId 以后端为准,画布只是编辑器。

async function submitApproval(instanceId, action, comment) { const res = await fetch('/api/approval/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instanceId, action, comment }) }); if (!res.ok) { const err = await res.json(); throw new Error('submit failed: ' + (err.message || res.status)); } return res.json(); }

submitApproval 是前端提交审批的入口。instanceId 是实例 ID,action 是动作码,comment 是审批意见。后端收到后在事务里执行 handleAction,把新的实例状态返回给前端,前端根据返回的 currentNode 刷新画布高亮位置。

联调时最容易出问题的是节点 ID 的映射。前端画布每次新建节点都会生成一个临时 ID,比如c9f3a1b2,如果保存时直接用这个临时 ID 发给后端,后端路由就会全断。解决方案是前端加载流程定义时,只展示和编辑,真正保存时不再重新生成 ID,而是沿用后端下发的节点 ID。

3.4 审批意见与操作记录落库

审批流必须有完整记录,出了问题能追溯到人。实践里我习惯把所有节点和动作都写进同一个 history 数组,这样流程重放时不用去多个表拼数据。

function pushHistory(instance, action, extra) { instance.history.push({ fromNode: instance.currentNode, action, operator: extra.operator, comment: extra.comment || '', contextSnapshot: { ...instance.data }, at: new Date().toISOString() }); }

pushHistory 里最关键的是 contextSnapshot。它把当时实例的所有业务数据做一次浅拷贝,存进历史记录。为什么要快照?因为审批流是链式的,到了财务环节,工单金额可能已经被经理改过,没有快照就只能看到最终态,中间过程全丢。有了快照就能回答“当时看到的金额是多少”这类审计问题。

contextSnapshot 是浅拷贝,如果 data 里有嵌套对象,改动内层字段快照也会变。要完整快照得用结构化克隆,比如structuredClone(instance.data),生产环境强烈建议用这个,代价是存储空间更大,但换来的是审计上的确定性。

4. 避坑与常见问题:状态覆盖、死循环与并发审批的坑

JS 审批流做起来不难,难的是踩坑之后不慌。这一章把我在真实项目里遇到最多的五类问题列出来,每一条都是“现象、原因、解决”三段结构。这些问题网上很少有人讲透,但生产环境里几乎都会遇到。

4.1 同一订单重复提交,审批状态被覆盖

现象:用户手快点了两次“提交”,后端两条请求几乎同时到达,一条设置 status 为 pending,另一条也设置 pending,看起来没区别。但后续审批时,第一条流程的 history 混进了第二条的操作,状态链条完全错乱。

原因:创建实例的接口没有做幂等控制。同一个业务单号可以创建出多个流程实例,或者两次请求都更新了同一条记录。

解决:在数据库层面给业务单号加唯一约束,或者在后端判断“该 orderId 是否已存在未结束的实例”。更稳妥的是创建实例时把业务单号作为参数传入,实例表里加一个业务键唯一索引。

注意:幂等判断不能用“状态是否等于 pending”来兜底,因为流程完成后业务单号可能再次提交新审批。唯一键要设计成“业务单号 + 流程类型”,才能兼容同一订单多次走流程的场景。

4.2 条件分支死循环,流程在节点之间弹跳

现象:服务器 CPU 突然飙高,日志里同一个 instanceId 在 A、B 两个节点之间反复流转,停不下来。

原因:流程定义里 A 到 B 有一条边,B 到 A 也有一条边,而且两边条件都匹配当前数据。路由函数不会主动感知自己是否走过这个节点,条件满足就一直往下跳。这种情况最容易出现在条件分支和自动路由节点组合的地方。

解决:给实例加一个 visitedNodes 数组,每次路由时检查下一个节点是否已经在集合里,如果存在就抛异常。再兜底一个深度限制,比如单次流转最多跳 30 次,超过直接终止流程并告警。

4.3 并发审批时两个操作互相覆盖

现象:经理和财务同时打开同一份审批单,经理先点了“通过”,财务后点了“驳回”。从数据库结果看,最后的操作覆盖了前一个,经理的通过操作完全丢失。

原因:读取实例、修改状态、写回数据库这三个步骤不是原子操作。两个请求都先读到同一份实例,各自改完后写回,后写的覆盖先写的。

解决:用乐观锁。实例表加 version 字段,更新时带上WHERE version = 旧值,更新的同时 version+1。如果更新影响行数是 0,说明版本冲突,返回“审批已被他人处理”的提示。代码骨架里没有实现版本字段,落地时必须补上。

4.4 条件表达式字段对不上,路由全部落到错误分支

现象:前端配置了一条amount >= 10000的条件,后端实例 data 里存的确切字段名是totalAmount,路由函数 evaluateCondition 匹配不到,流程走到了默认分支。

原因:字段命名没有统一约束。前端画布是自由配置的,后端代码里字段名是写死的,两边各有一套命名。这类问题在条件越多时越严重,三条边可能错两条。

解决:在流程定义里维护一个字段映射表,或者把条件字段做成下拉选择器,只允许选择后端定义的合法字段。流程启动时做一次全量校验,把每条边 condition 里引用的字段和实例 data 的键逐一比对,缺失的直接拦截。

4.5 前端流程图的节点 ID 和后端不一致

现象:前端画布保存的 nodeId 是临时生成的,后端流程定义里的 nodeId 是预设的字符串,审批动作提交后后端报 node not found,流程无法继续。

原因:前端编辑器保存时默认生成新的 ID,没意识到这个 ID 要带给后端做路由。这是一个约定问题,不是技术问题。

解决:前端只加载后端下发的流程定义 JSON,画布编辑完保存时保留原 nodeId,只更新位置、条件和连线。后端新增节点时才由后端生成 nodeId,前端不做 ID 生成。这样能保证流程定义始终以数据库为准。

5. 进阶技巧:结构化日志与流程重放,五分钟定位流转错乱

审批流排错最怕的就是状态已经错乱,但不知道在哪一步错的。我的经验是:给每个流转动作写结构化日志,再把日志做一次重放验证。

5.1 给每个流转动作写结构化日志

日志不是流水账,每个字段都要能单独检索。推荐把日志字段固定下来:

字段示例值用途
ts1712800000000毫秒时间戳,排序用
instanceId7f1c2e8a定位到具体审批单
fromapproval_manager来源节点
toapproval_finance目标节点
edgee2命中的连线 ID
actionapprove触发动作
operatorzhangsan操作人
statuspending实例当前状态
function appendFlowLog(instance, fromNode, toNode, matchedEdgeId, action) { console.log(JSON.stringify({ ts: Date.now(), instanceId: instance.instanceId, from: fromNode, to: toNode, edge: matchedEdgeId, action, operator: instance.data.operator, status: instance.status })); }

appendFlowLog 在 handleAction 里每次路由确定后调用。日志统一输出到标准日志平台,线上排查时直接按 instanceId 查时间线。很多人只记操作日志不记路由日志,结果审批通过了却不知道走的是哪条边,这等于没有日志。

5.2 用日志重放验证流程正确性

重放的含义是:把一次审批从开始到结束的所有日志按 ts 排序,在测试环境里重新执行一遍,比对每个节点的预期状态和实际状态。我在发布新流程定义前都会跑一遍重放脚本,用真实日志数据验证条件路由是否和设计一致。

具体步骤是:导出线上某一条审批的完整日志,按时间排序;用日志里的 action 序列重新驱动 handleAction;每走一步,比对实例的 currentNode 和 status 是否和日志一致。不一致的节点就是路由问题的根源。

从那以后,我每次改流程定义都强制走一遍这个动作:先用测试数据跑全链路,导出一份日志,再重放一次做对比。这个动作帮我拦下了至少三次条件配置错误,也让我再也不怕审批流出问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询