
简介一份关于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 是实例 IDaction 是动作码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 旧值更新的同时 version1。如果更新影响行数是 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命中的连线 IDactionapprove触发动作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 是否和日志一致。不一致的节点就是路由问题的根源。从那以后我每次改流程定义都强制走一遍这个动作先用测试数据跑全链路导出一份日志再重放一次做对比。这个动作帮我拦下了至少三次条件配置错误也让我再也不怕审批流出问题。希望帮到你。本文还有配套的精品资源点击获取