简介:一份关于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 给每个流转动作写结构化日志
日志不是流水账,每个字段都要能单独检索。推荐把日志字段固定下来:
| 字段 | 示例值 | 用途 |
|---|---|---|
| ts | 1712800000000 | 毫秒时间戳,排序用 |
| instanceId | 7f1c2e8a | 定位到具体审批单 |
| from | approval_manager | 来源节点 |
| to | approval_finance | 目标节点 |
| edge | e2 | 命中的连线 ID |
| action | approve | 触发动作 |
| operator | zhangsan | 操作人 |
| status | pending | 实例当前状态 |
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 是否和日志一致。不一致的节点就是路由问题的根源。
从那以后,我每次改流程定义都强制走一遍这个动作:先用测试数据跑全链路,导出一份日志,再重放一次做对比。这个动作帮我拦下了至少三次条件配置错误,也让我再也不怕审批流出问题。希望帮到你。
本文还有配套的精品资源,点击获取