
简介面向Web前端与全栈开发者的JavaScript工作流/审批流示例代码包完整演示了在浏览器中实现审批流程的核心思路覆盖任务分配、状态机转移、角色权限校验、异常回退与审计日志等典型环节。压缩包共49个文件大小69KB主要文件类型包括js脚本、html页面、xml流程定义、aspx后端处理、css样式及gif演示截图另附带数据库文件与配置文件构成从流程建模、界面交互到数据持久化的闭环样例。已有1285人学习下载适合需要快速掌握审批流开发的中级开发者参考。通过阅读源码可理解基于状态机的工作流引擎如何驱动节点流转学习如何用XML定义流程模型并通过JavaScript解析执行同时借鉴其异步通信、角色权限分配和异常处理设计为实际业务系统提供可扩展的基础模块。1. js 工作流指的是什么审批流不用一上来就上 BPM 中间件如果你和我一样在一家中小型公司维护内部系统需求往往不是从「流程引擎选型调研」开始的而是从一条消息开始的「给请假审批做个页面主管过了人事确认三天以上还要走副总。」这类诉求就是典型的 js 工作流与审批流场景流程不算复杂但状态多、角色多、变更多。很多人第一反应是上 Activiti、Flowable 这类 Java 重量级工作流框架。但 JS 项目里真正划算的路径往往是用 JavaScript 自己维护一套「节点 边 条件」的数据结构把流程定义当成静态 JSON 存起来再用状态机去驱动实例。做到了这一步你就会发现所谓工作流搭建本质上不是写流程逻辑而是设计流程元数据谁能审批、按什么顺序、卡在哪个条件上、驳回回到哪一层。这就是我接下来要讲的方案一套能在 JS 前后端跑通、不依赖重型中间件的轻量级审批流实现适合所有想让非技术人员自己维护流程的团队。2. 审批流的数据模型节点、边和条件表达式就是全部家当进入实现之前我们必须先把「工作流」这个词拆成能落库的数据。这是 js 工作流方案里最关键的一步因为后端代码可以随时重构但流程数据结构一旦定错了后面所有审批任务都会跟着翻车。2.1 流程定义怎么存用 JSON 描述节点与边的拓扑结构我见过不少团队把审批流直接写死在 if/else 里比如「if (days 3) 走副总审批」。三个以内分支还看得懂等到节点超过十个、角色开始重叠这段代码就变成谁都不敢动的黑匣子。正确做法是把流程定义与执行逻辑彻底分离。一个流程定义里只有两种对象节点Node表示环节边Edge表示流转关系。我把它们约定成一个 JSON Schema类似下面这样const leaveDef { id: leave_approval, name: 员工请假审批, version: 3, nodes: [ { id: start, type: start, name: 发起申请 }, { id: apply, type: fill, name: 填写请假单 }, { id: leader, type: approve, name: 主管审批, assignee: leader, policy: any }, { id: hr, type: approve, name: 人事确认, assignee: hr, policy: any }, { id: end, type: end, name: 结束 } ], edges: [ { from: start, to: apply }, { from: apply, to: leader }, { from: leader, to: hr, condition: { field: days, op: , value: 3 } }, { from: leader, to: end, condition: { field: days, op: , value: 3 } }, { from: hr, to: end } ] };这段数据的逻辑很直白流程从 start 节点进走到 apply 填单然后 leader 审批审批结果根据 days 这个变量的值决定去 hr 还是直接到 end。节点数组里每一个对象的 type 字段决定了它是什么环节approve 是人工审批condition 是条件分支start/end 是边界节点。这里有一个值得强调的约定条件必须挂在边上而不是挂在节点上。因为同一个节点可以有不同的后缀去向比如主管审批后既可以转人事也可以直接结束条件放在边上才不破坏这种多路结构。流程定义版本号 version 字段也必不可少它是之后做流程平滑升级的后悔药后面会专门讲。2.2 或签、会签和并行分支审批策略的三种基本组合节点和边只能解决「往哪走」的问题不能解决「走到这个节点后任务怎么算通过」。这就是审批策略要管的范畴。现实中我们最多见到三种策略我一般把它们建模成 approve 节点的两个字段assignee 和 policy。第一种是或签policy: any意思是审批人列表里任何一个人处理完就算这个节点结束适合主管、总监这类单一角色审批。第二种是会签policy: all要求列表里所有人都同意才放行适合财务报销、合同会签这种需要多方背书的场景。第三种是并行分支type: parallel它不是多人审同一个节点而是从一个网关节点同时派生出多个子流程比如采购申请同时发给技术部评审和财务部估价。// 会签场景assignees 数组里的两人都批完节点才流转 const node { id: finance, type: approve, name: 财务会签, assignees: [zhang, li], policy: all }; // 运行时任务里要维护每人的审批结果 const task { id: t_1001, nodeId: finance, status: active, approvals: {} };policy 这个参数决定引擎在任务流转时看什么any 只需 approvals 里有任意一个通过记录all 则要遍历完整个列表。我建议在前端管理后台把策略和审批人配置做成下拉框让业务人员自己选而不是每改一次策略就发一次版本。2.3 和 Activiti/Flowable 这类 Java 框架比JS 工作流的取舍边界有 Java 背景的读者可能会质疑直接上 Flowable 不更省事这里我必须把边界讲清楚免得你听完本文方案上了生产环境才发现选错了。Activiti、Flowable 这类框架解决的是高并发、复杂编排、分布式事务、消息补偿这类企业级问题它们的部署和运维成本也对应地高要配数据库表结构、要学习 BPMN 2.0 规范、要写自定义 Java 类。而大部分内部管理系统的审批流节点数不超过二十个并发量一天几百条顶天了让运维为一个审批流扛一套 Activiti纯属杀鸡用牛刀。JS 工作流方案的优势在轻量流程定义就是一份 JSON执行器就是几百行遍历代码前端画布序列化后能直接生成同一份 JSON。这特别适合微前端架构和 Node.js 全栈项目。它的短板也很明确没有内置的死信队列、没有完善的分布式锁、事务只能靠数据库自身保证。所以我的选型建议是流程节点多、并发要求高、需要复杂定时提醒的去用成熟的 Java 工作流引擎业务系统内部嵌一个轻流程、需要快速响应业务变化、希望前端也能编辑流程的就用自研 JS 引擎这也是我做得最多的落地路径。3. 用 JS 从零搭审批流引擎核心遍历器、状态机和 API 设计数据结构定好之后引擎本身的实现反而不难。我用 Node.js 写一个最小可用的审批流引擎核心就三个方法start 发起流程、complete 完成任务、disptach 根据完成的节点计算下一个任务。需要说明的是下面的代码是可运行的最小骨架负责讲透原理生产环境在此基础上加持久化和日志即可。3.1 引擎初始化把节点数组和边数组预处理成邻接表为了在流转时快速找到某个节点的所有出边我通常把 edges 数组转成邻接表。这样每次查「leader 节点接下来可以去哪」都只要做一次 Map 查找而不是每次遍历整个边数组。class WorkflowEngine { constructor(def) { this.def def; this.nodeMap new Map(def.nodes.map(n [n.id, n])); this.adj new Map(); for (const edge of def.edges) { if (!this.adj.has(edge.from)) this.adj.set(edge.from, []); this.adj.get(edge.from).push(edge); } } getNode(nodeId) { const node this.nodeMap.get(nodeId); if (!node) throw new Error(节点 ${nodeId} 不存在请检查流程定义); return node; } getOutgoingEdges(nodeId) { return this.adj.get(nodeId) || []; } }nodeMap 解决「根据 ID 拿节点详情」的问题adj 解决「从一个节点出发有哪些边」的问题。这两个预处理做好之后后面写流转逻辑会非常顺手。注意构造函数里对空出边的处理返回空数组而不是 undefined避免在后续遍历里还要做一层判空。3.2 start 与 complete流程实例和任务实例的生命周期流程引擎面向运行时有两个核心对象流程实例Process Instance和任务实例Task Instance。前者是一次具体的请假申请后者是当前待办事项。我用一个流程实例对象装住所有运行时状态包括业务变量 vars、当前活动任务、流程状态。start(initVars) { const startNode this.def.nodes.find(n n.type start); const instance { id: inst_${Date.now()}, defId: this.def.id, defVersion: this.def.version, vars: { ...initVars }, tasks: [], status: running }; this.dispatch(instance, startNode.id); return instance; } complete(instance, taskId, operator, passedVars) { const task instance.tasks.find(t t.id taskId t.status active); if (!task) throw new Error(任务 ${taskId} 不存在或已完成); task.status done; task.operator operator; task.result passedVars?.approved; instance.vars { ...instance.vars, ...passedVars }; this.dispatch(instance, task.nodeId); return task; }start 方法负责初始化流程实例并触发第一个环节complete 方法把某个 active 状态任务标记为 done同时将用户填写的字段并进流程变量最后调用 dispatch 看下一步该去哪。我认为这里最容易出错的是「任务完成」和「流程推进」没有分离很多新手会在 complete 里直接把实例状态改为完成但实际流程可能还要继续往下走所以 complete 的结果一律由 dispatch 决定。3.3 核心 dispatch 方法遇到审批人进任务遇到分支进递归dispatch 是整个引擎的心脏。它的输入是当前完成的节点 ID输出是下一批活动任务。它对审批节点生成任务对条件分支节点做路径选择对结束节点置完成状态。因为流程可能连续经过多个非人工节点比如条件节点后面又是条件节点所以用递归处理。dispatch(instance, nodeId) { const node this.getNode(nodeId); switch (node.type) { case end: { instance.status done; instance.tasks []; break; } case approve: { const task this.createApproveTask(instance, node); instance.tasks.push(task); break; } case condition: { const edge this.evalEdges(nodeId, instance.vars); if (!edge) throw new Error(流程在节点 ${nodeId} 没有任何路径可走); this.dispatch(instance, edge.to); break; } default: // start / fill 这类非人工节点直接顺边流转 this.setDefaultNext(instance, nodeId); } } setDefaultNext(instance, nodeId) { const edges this.getOutgoingEdges(nodeId); const firstEdge edges.find(e !e.condition) || edges[0]; if (!firstEdge) throw new Error(节点 ${nodeId} 没有出边流程无法继续); this.dispatch(instance, firstEdge.to); } evalEdges(nodeId, vars) { const edges this.getOutgoingEdges(nodeId); return edges.find(e this.matchCondition(e.condition, vars)); }这里有个约定condition 类型的节点必须有条件边不允许无条件的默认出边否则流程就会变成「靠数组顺序猜路径」出线 bug 极难排查。而 start、fill 这类节点则相反它们的出边通常没有条件所以取第一条即可。递归的终止条件有两个遇到 end 节点或者生成新的审批任务。注意 dispatch 到 approve 时不能继续递归要等用户处理完任务再走。3.4 条件表达式的安全求值用操作符结构替代 eval 字符串刚学 JS 工作流的人最容易犯的一个错误是把条件表达式直接拼字符串丢给 eval 或 new Function 执行比如把「days 3」动态计算出来。这在后台引擎里是非常危险的用户一旦能在表单里注入代码引擎就成了任意代码执行的黑匣子。安全做法是把条件拆成结构化对象字段、操作符、比较值由引擎自己解析。matchCondition(cond, vars) { if (!cond) return true; const actual vars[cond.field]; switch (cond.op) { case : return Number(actual) cond.value; case : return Number(actual) cond.value; case : return actual cond.value; case in: return Array.isArray(cond.value) cond.value.includes(actual); default: console.warn(未知操作符 ${cond.op}); return false; } }这种设计天然限制了可执行的操作范围不涉及代码注入而且前端流程设计器下拉框里的操作符可以直接对应到这里不用做繁琐的字符串转枚举。代价是表达式能力弱复杂逻辑在 JSON 表达很笨拙。我的建议JS 审批流第一版先用结构式条件等真正出现多条件嵌套需求时再接一个表达式解析库也比一开始就裸上 eval 稳妥得多。追加一条边界禁止对流程定义里不可信字段调用任何动态函数这是底线。4. 工作流搭建的前端侧流程设计器与动态表单后端引擎跑通只是第一步工作流搭建真正炫技的地方在前端让业务方能在画布上拖出一个审批流再给每个审批节点配置对应表单。这部分我拆成三个环节画布与流程定义的互转、动态表单配置、流程版本管理。4.1 画布选型与序列化流程图 JSON 与引擎 JSON 的同构设计前端画布目前常见的选择有两类一类是成熟的流程图库如 LogicFlow、AntV X6内置节点连线交互另一类是自研的轻量画布用绝对定位的 div 加 SVG 连线。我一般倾向于第一类因为审批流设计器的核心工作量在属性面板和业务配置重造画布轮子不划算。关键设计在于画布的图数据结构和引擎 JSON 结构要能双向转换。拿 LogicFlow 举例它的图数据是 nodes 数组加 edges 数组我把节点 type 和边条件塞进自定义属性最终序列化成引擎识别的内容function graphToEngineJSON(graphData) { return { id: graphData.id || def_${Date.now()}, name: graphData.name, nodes: graphData.nodes.map(n ({ id: n.id, type: n.properties.nodeType, name: n.text?.value || , assignee: n.properties.assignee, policy: n.properties.policy })), edges: graphData.edges.map(e ({ from: e.sourceNodeId, to: e.targetNodeId, condition: e.properties?.condition || null })) }; }我建议把流程图上每个节点的坐标 x、y 也保存下来存进流程定义的单独字段 layout 里。这样下次打开设计器还能还原位置否则用户每次改流程都要重新拖一遍图。对用户而言能自动还原上次编辑的位置比少几个字段「干净」重要得多。4.2 节点级动态表单审批字段由节点配置驱动不写死在前端页面动态表单是审批流区别于普通 CRUD 页面的核心差异点。同一个请假流程「普通员工发起节点」要填天数、事由、照片附件「HR 确认节点」只需要填备注。如果把表单固定写死每加一个流程就要加一个页面工作流搭建就失去意义了。正确抽象是表单 schema 与流程节点绑定。每个节点可以配置自己的字段列表前端表单渲染引擎根据 schema 自动渲染控件const formSchema { nodeId: leader, fields: [ { name: approved, label: 是否同意, widget: radio, options: [同意, 驳回] }, { name: comment, label: 审批意见, widget: textarea, rules: [{ required: true }] }, { name: days, label: 可批天数, widget: number, rules: [{ required: false }] } ] };这里有一个非常需要注意的点审批人提交的字段在什么范围内能覆盖流程变量很多人直接把表单所有字段都并入 vars结果申请人在表单里改了请假天数主管也改了天数最终变量被后提交者覆盖。我的经验是对每个节点做字段白名单控制哪些字段可写、哪些只读、哪些禁止提交否则业务数据越到后面越乱。4.3 让非开发人员安全地发布流程草稿、版本和上线隔离画布拖好了、表单配好了接下来就是发布问题。没有版本管理的流程定义上线就是灾难。常见做法是以「草稿 → 发布 → 新版本」三个状态管理流程定义草稿可以随意改发布后立即生成新版本号正在运行的流程实例不受影响。这样万一改崩了也只是影响新发起的流程旧实例还能按老版本走完。前端管理后台建议至少提供两个操作按钮「保存草稿」和「发布新版本」。保存草稿只更新草稿区的 JSON发布新版本则复制草稿到正式版并把 version 加一同时保留历史正式版本。这一点在下一章的坑里还会展开。5. 审批流常见 bug 与避坑排查版本、驳回、并发和表达式求值后端引擎和前端设计器都写完并不代表能安稳上线。我把自己在多个项目里踩过的坑总结成四类按「现象 → 原因 → 解决」的格式写清楚这些坑在面试和真实交付里出现的概率都很高。5.1 流程分支条件匹配不到路径任务卡死无人处理现象是审批流莫名其妙停在某个节点不动了查看流程实例日志发现 dispatch 抛了异常提示「没有任何路径可走」。这种情况多发于条件配置阶段业务人员在设计器里填了一个不存在的字段名或者条件边的操作符写错导致运行时变量值永远匹配不上任何一条边。原因在于引擎对条件求值太宽容。我在上面 matchCondition 的 default 分支里让未知操作符返回 false这本来是为了容错却也掩盖了配置错误——条件永远为 false流程自然死路。解决方法是两件事第一在发布流程定义的校验阶段就检查条件字段是否存在于字段字典中第二matchCondition 遇到未知操作符时改为抛异常而不是返回 false宁可流程启动失败也不能让任务悄悄卡死。5.2 驳回和撤回功能缺失流程一旦提交就一路走到黑用户提的第一个需求往往是「主管批错了要驳回给申请人修改」第二个是「申请人提交后发现填错了要撤回」。而很多自研 js 工作流第一版只做了正向流转完全没有反向流动的能力。一旦误操作只能让管理员手工改数据库体验非常糟糕。原因是数据模型里没存「从哪来」。如果只保存边那么走到 leader 节点时引擎根本不知道它是从 apply 来的还是从条件分支拐来的。解决有两种简单版是在任务对象上记录 previousNodeId驳回时把任务状态置回上一节点复杂版是维护一条访问路径栈支持多级驳回。我建议第一版至少实现 previousNodeId并把驳回操作做成一个独立接口让申请人撤回、审批人驳回走同一套逻辑。5.3 流程定义修改没留版本所有运行中的实例全乱套现象是某天管理员修改了请假审批流程中的一个节点配置结果发现所有进行中的请假单都开始按新规则走有的实例找不到了审批人有的实例走到了从未定义过的节点。原因前面已经提到代码里 processDef 用的是同一份全局对象新流程发布后实例读取时没锁定版本。解决方式是在流程实例里固化 defVersion我上面代码里已经埋了 instance.defVersion 字段实例启动时把当时的流程定义快照存入历史表。后续查询该实例的下一步永远从快照中读取而不是从最新定义读取。如果实现不了快照至少要在流程定义表用版本号软删除旧版本保证新实例不会串到旧规则。5.4 并行分支处理不当出现重复任务或提前结束有段时间我用 Promise.all 处理并行网关结果在极低概率下出现重复生成任务或流程提前结束的现象。查到最后发现是两个并行的异步回调同时走到 dispatch由于实例对象是共享引用两路回调各自 push 了一次任务。这个坑的教训是流程实例的状态变更必须串行化。解决思路是把流程实例的状态更新放进一个同步队列或者用数据库行锁保护实例状态。JS 单线程并不意味着异步回调不会并发修改共享状态。如果你用了 Node.js 集群还要考虑分布式锁。我这里给的建议是任何节点流转的入口都走同一个 process() 函数内部用实例上的 mutation 锁串行操作不要在每个分支里直接改实例字段。6. 验证一条审批流值不值得上线路径覆盖测试和引擎边界审批流上线前我做的最有价值的一件事不是写一堆单元测试而是做「路径覆盖」验证。流程引擎本质是一个有向图只要把每条从 start 到 end 的路径跑一遍就能覆盖到绝大多数分支逻辑。做法是把流程定义喂给一个测试用小引擎模拟不同 vars 组合断言最终状态是 done、中途没有未处理任务。const testCases [ { vars: { days: 2 }, expectNodes: [start, apply, leader, end] }, { vars: { days: 5 }, expectNodes: [start, apply, leader, hr, end] } ]; testCases.forEach(tc { const engine new WorkflowEngine(leaveDef); const instance engine.start(tc.vars); // 自动完成所有 task收集经过节点 const visited [instance.tasks[0].nodeId]; // ... 省略自动完成逻辑 assert.deepStrictEqual(visited, tc.expectNodes); });我日常验证的习惯是先人工走通流程设计器里所有导出 JSON再检查条件字段名与表单 schema 字段是否一致最后用上面的自动遍历确保每条边都被触发过。至于引擎边界我的明确建议是别什么都自己写定时提醒、到期自动转交、消息通知这几个能力用现成库和定时任务补不要把引擎无限扩大。毕竟审批流的核心价值是把状态流转讲清楚让业务方自己维护流程超出这个范围的需求用外部服务反而比自己造轮子稳妥。这套方案我反复用在不同项目里最深的教训是「宁可在流程定义结构上多花一星期设计也别在踩版本坑后熬夜打补丁」。希望帮到你。本文还有配套的精品资源点击获取