☰
纯JS工作流引擎实战:审批流节点流转与条件分支设计
2026/10/18 13:17:54 网站建设 项目流程

简介:这是一份面向前端与全栈开发者的JavaScript工作流与审批流实现资料,适合需要理解流程建模、任务调度与审批界面交互的中级开发者参考。资源包共49个文件,以8个js脚本、8个html页面、6个xml流程定义文件为主,辅以gif演示、css样式、aspx与cs后端页面、db数据文件及xsl转换文件,压缩包约69KB,结构紧凑便于快速查阅。内容围绕工作流引擎、状态机驱动、任务分配与权限控制、审批界面构建、数据持久化及异常回退等核心环节展开,xml文件可用于定义流程模型与多语言配置,html与js配合实现拖拽式流程操作与实时状态更新。目前已有1286人学习下载,适合希望掌握审批流设计思路、流程引擎执行逻辑与前后端集成方式的读者对照研究,也可作为搭建轻量级业务流程管理系统的参考素材。

1. 从一张请假单说起:这套 js 工作流到底能扛住什么

如果你做过 OA、CRM 或者任何带审批环节的后台,大概率遇到过这种需求:一张请假单,先直属主管审,再 HR 备案,金额超过某个阈值还要加一级总监。业务同事随口一句“流程改一下”,你就要回去改 if-else,改完还得担心历史单据的状态会不会错乱。这套 js 工作流资源,解决的就是把“流程怎么走”从代码里抽出来,变成可配置、可回溯、可动态调整的数据结构。

它适合两类人:一是正在做审批流、工单流、状态机相关功能的前后端开发者,二是想理解工作流引擎内部怎么运转、但又不想一上来就啃重型 BPMN 规范的人。资源本身是一套纯 JavaScript 实现的工作流核心,包含节点定义、流转规则、条件分支、审批人解析和流转日志。你拿到手能直接跑一个最小审批链路,也能把它嵌进现有的 Node 服务里当引擎用。下面我按“先跑通、再拆解、最后避坑”的顺序,把这份资源从头到尾过一遍。

2. 工作流引擎的骨架:节点、流转与上下文怎么设计

2.1 为什么不用状态机硬写

很多人第一反应是用状态机(state machine)来实现审批流,状态就是“待主管审”“待 HR 审”“已通过”,事件就是“同意”“驳回”。小流程没问题,但审批流有两个状态机很难优雅处理的特性:一是动态加签,审批过程中临时插入一个人;二是条件分支,同一个节点根据表单金额走不同后续路径。状态机的转移表会迅速膨胀成一张巨大的二维矩阵,维护成本极高。

这套 js 工作流换了个思路:把流程定义成一串节点(node),每个节点声明自己的类型、审批人解析规则和出口条件。引擎只做一件事——拿到当前节点和上下文,算出下一个节点是谁。流程的“形状”是数据,不是代码。这样改流程就是改配置,不用动引擎本身。

常见做法是定义一个ProcessDefinition,里面包含nodes数组和startNodeId。每个节点长这样:

// 节点定义示例:一个审批节点的最小结构 const approveNode = { id: 'manager_approve', // 节点唯一标识 type: 'approval', // 节点类型:审批 / 条件 / 开始 / 结束 name: '直属主管审批', assignee: { // 审批人解析规则 type: 'role', value: 'direct_manager' // 从组织架构里找当前提交人的直属主管 }, next: 'hr_record', // 默认出口 conditions: [ // 条件出口,按顺序匹配,命中即走 { expression: 'form.amount > 5000', next: 'director_approve' } ] };

这里的关键参数是assignee和conditions。assignee决定了“谁来审”,conditions决定了“审完去哪”。引擎在流转时会把表单数据、当前节点、历史记录打包成一个context传进去,条件表达式就是在这个 context 上求值。

2.2 上下文与流转日志:别让流程变成黑匣子

工作流最怕的不是跑不通,而是跑通了但没人知道为什么走到这一步。这套资源里有个context对象贯穿始终,结构大致是:

// 流转上下文:引擎每一步都读写这个对象 const context = { form: { amount: 8000, days: 3, reason: '年假' }, // 业务表单数据 currentNodeId: 'manager_approve', // 当前节点 history: [], // 流转历史,每步追加 variables: {} // 引擎内部变量,如计数器 };

每次流转,引擎往history里 push 一条记录,包含from、to、operator、timestamp和decision。这份历史就是审批轨迹的数据来源,前端画流程图、后端做审计都靠它。我一般会额外在 history 里塞一个snapshot,把当时的表单关键字段存一份,防止后续表单被改导致历史对不上。

提示:history 只追加不修改,这是排查问题时唯一的“后悔药”。如果发现流程走错了,先看 history 里每一步的 decision 和条件求值结果,基本能定位到是配置问题还是数据问题。

2.3 最小可跑示例:三节点审批链路

光看结构不够,直接跑一个最小链路。假设流程是:提交 → 主管审批 → 结束。主管同意就结束,驳回就回到提交人。

// 最小工作流引擎:注册节点、启动流程、驱动流转 class WorkflowEngine { constructor(definition) { this.nodes = new Map(); definition.nodes.forEach(n => this.nodes.set(n.id, n)); this.startNodeId = definition.startNodeId; } // 启动流程,返回初始上下文 start(formData) { const context = { form: formData, currentNodeId: this.startNodeId, history: [], variables: {} }; this._record(context, null, this.startNodeId, 'system', 'start'); return context; } // 驱动一步:根据当前节点和决策,算出下一个节点 step(context, decision, operator) { const node = this.nodes.get(context.currentNodeId); if (!node) throw new Error(`节点不存在: ${context.currentNodeId}`); let nextId = node.next; // 驳回逻辑:审批节点驳回时回到提交节点 if (node.type === 'approval' && decision === 'reject') { nextId = node.rejectTo || 'start'; } // 条件分支:按顺序匹配 expression if (node.conditions && decision === 'approve') { for (const cond of node.conditions) { if (this._eval(cond.expression, context)) { nextId = cond.next; break; } } } this._record(context, context.currentNodeId, nextId, operator, decision); context.currentNodeId = nextId; return context; } _record(context, from, to, operator, decision) { context.history.push({ from, to, operator, decision, timestamp: Date.now() }); } // 极简表达式求值:生产环境建议用沙箱或专用表达式引擎 _eval(expr, context) { const fn = new Function('form', 'variables', `return ${expr};`); return fn(context.form, context.variables); } } // 使用 const engine = new WorkflowEngine({ startNodeId: 'start', nodes: [ { id: 'start', type: 'start', next: 'manager_approve' }, { id: 'manager_approve', type: 'approval', name: '主管审批', assignee: { type: 'role', value: 'direct_manager' }, next: 'end', rejectTo: 'start', conditions: [ { expression: 'form.amount > 5000', next: 'director_approve' } ] }, { id: 'director_approve', type: 'approval', name: '总监审批', next: 'end' }, { id: 'end', type: 'end', next: null } ] }); let ctx = engine.start({ amount: 8000, days: 3 }); ctx = engine.step(ctx, 'approve', 'zhangsan'); // 主管同意,金额>5000,走总监 console.log(ctx.currentNodeId); // director_approve console.log(ctx.history); // 完整流转记录

这段代码里_eval用了new Function,图省事可以,但生产环境有注入风险,后面避坑章节会展开。step方法只做一件事:根据当前节点、决策和条件算出下一个节点,然后记录历史。引擎本身不关心审批人是谁、表单长什么样,这些都在 context 里。

3. 审批人解析与条件分支:把“谁来审”和“往哪走”拆开

3.1 审批人解析的四种常见策略

审批人解析是工作流里最容易写乱的部分。我见过把组织架构查询、角色判断、特殊规则全塞在一个getApprover()函数里的,改一个规则要动全身。这套资源的做法是把解析策略抽象成assignee对象,引擎根据type分发到不同的解析器。

策略类型配置示例适用场景注意点
固定人{type:'user', value:'u1001'}财务复核等固定岗位人员离职需同步改配置
角色{type:'role', value:'direct_manager'}直属主管、部门负责人需组织架构接口支持
表单字段{type:'field', value:'form.reviewer'}提交人自选审批人需校验字段合法性
脚本{type:'script', value:'fn(ctx)'}复杂规则如“金额>1万找总监”脚本要沙箱隔离

实际项目里我一般会把这四种做成可注册的 resolver,引擎启动时注册进去,节点配置里只写 type 和 value。这样新增一种策略不用改引擎核心,符合开闭原则。

// 审批人解析器注册表:按 type 分发 const resolvers = { user: (value) => [value], role: (value, context) => { // 常见做法:调组织架构服务,拿当前提交人的对应角色 return orgService.getUsersByRole(context.form.submitter, value); }, field: (value, context) => { const path = value.replace('form.', ''); return [context.form[path]]; }, script: (value, context) => { // 脚本解析:传入 context,返回审批人数组 const fn = new Function('ctx', `return (${value})(ctx);`); return fn(context); } }; function resolveAssignees(node, context) { const { type, value } = node.assignee; const resolver = resolvers[type]; if (!resolver) throw new Error(`未知审批人类型: ${type}`); return resolver(value, context); }

参数说明:context.form.submitter是提交人标识,组织架构服务根据它查角色对应的人。field策略直接从表单取值,适合“提交人指定审批人”的场景,但一定要做权限校验,否则提交人可以把自己写成审批人。

3.2 条件分支的表达式设计

条件分支的核心是表达式求值。上面最小示例用了new Function,简单但危险。更稳妥的做法是限定表达式语法,只允许比较运算和逻辑运算,用解析器而不是 eval。如果项目里已经有表达式引擎(比如某些规则引擎库),直接复用。

条件匹配的顺序很重要。引擎按conditions数组顺序逐个求值,命中第一个就跳出。所以配置时要把最特殊的条件放前面,最宽泛的放后面。我踩过一次坑:把“金额>1000”放在“金额>5000”前面,结果所有超过 1000 的单子都走了低级别审批,高级别分支永远命中不了。

// 条件求值:按顺序匹配,命中即返回 function evaluateConditions(node, context) { if (!node.conditions || node.conditions.length === 0) { return node.next; // 无条件则走默认出口 } for (const cond of node.conditions) { if (safeEval(cond.expression, context)) { return cond.next; } } return node.next; // 所有条件都不命中,走默认出口 } // 安全求值:只允许白名单操作符,避免任意代码执行 function safeEval(expr, context) { // 常见做法:用表达式解析库,或正则校验后限制字符集 const allowed = /^[\s\w.><=!&|()'"]+$/; if (!allowed.test(expr)) { throw new Error(`表达式含非法字符: ${expr}`); } const fn = new Function('form', 'variables', `return ${expr};`); return Boolean(fn(context.form, context.variables)); }

safeEval做了字符白名单校验,挡掉明显的注入。但白名单不是万能的,生产环境建议用专门的表达式解析库,把变量访问限制在 context 范围内。

3.3 会签与或签:一个节点多人审批怎么算通过

审批流里绕不开会签(所有人都同意才通过)和或签(一人同意即通过)。这套资源把这两种模式做成节点的approveMode参数:

const countersignNode = { id: 'committee_review', type: 'approval', name: '委员会评审', approveMode: 'all', // 'all' 会签,'any' 或签 assignee: { type: 'role', value: 'committee' }, next: 'end', rejectTo: 'start' };

引擎在处理审批节点时,需要维护一个“当前节点已审批人”列表。会签模式下,只有列表覆盖全部审批人才算通过;或签模式下,第一个人同意就通过。这个状态存在context.variables里,key 用节点 id 区分。

// 会签/或签判定:返回 'approve' / 'reject' / 'pending' function judgeApproval(node, context, operator, decision) { const key = `approval_${node.id}`; if (!context.variables[key]) { context.variables[key] = { approved: [], rejected: [] }; } const record = context.variables[key]; if (decision === 'approve') { if (!record.approved.includes(operator)) record.approved.push(operator); } else { if (!record.rejected.includes(operator)) record.rejected.push(operator); } const allAssignees = resolveAssignees(node, context); if (node.approveMode === 'any') { if (record.approved.length > 0) return 'approve'; if (record.rejected.length > 0) return 'reject'; return 'pending'; } // 会签:所有人都表态且都同意才算通过 if (record.rejected.length > 0) return 'reject'; if (record.approved.length >= allAssignees.length) return 'approve'; return 'pending'; }

这里有个细节:会签模式下如果有人驳回,是立即终止还是等所有人表态?我一般做成立即终止,因为驳回后流程已经确定走向,再等其他人表态没有意义。这个行为可以通过节点参数rejectFast控制,默认 true。

4. 避坑与排查:审批流上线后最容易翻车的五个点

4.1 现象:流程走到某个节点卡住,没有任何报错

原因通常是审批人解析返回了空数组。引擎拿到空审批人列表,不知道该等谁,流程就停在 pending 状态。排查时先看resolveAssignees的返回值,再往上查组织架构接口是否正常、角色配置是否存在。

解决:在引擎里加一道校验,审批节点解析出空列表时直接抛错或走异常分支,别让它静默卡住。我一般会在节点配置里加onEmptyAssignee: 'error' | 'skip' | 'fallback',默认 error,强制暴露问题。

4.2 现象:条件分支永远走默认出口,conditions 像没生效

先检查表达式里的字段路径。form.amount要求 context.form 里确实有 amount,如果表单字段名是totalAmount,表达式写form.amount永远返回 undefined,比较结果 false,自然走默认出口。另一个常见原因是类型不匹配,表单里 amount 是字符串"8000",表达式form.amount > 5000在 JS 里会做隐式转换,但某些表达式引擎不会,导致结果不符合预期。

解决:在条件求值前做一次类型规范化,或者表达式里显式转换Number(form.amount) > 5000。调试时把每个条件的求值结果打到日志里,一眼就能看出哪个条件没命中。

4.3 现象:驳回后流程回到提交人,但提交人重新提交时历史记录乱了

这是驳回逻辑没处理好。驳回时如果只是把 currentNodeId 改回 start,但没有清理当前节点的审批状态(variables.approval_xxx),重新提交后引擎会读到旧的审批记录,导致会签人数算错。

解决:驳回时清理当前节点及后续节点的 variables。我一般写一个resetFromNode(context, nodeId),把该节点之后的所有审批状态清空。另外 history 不要清,那是审计记录,清了就真成黑匣子了。

4.4 现象:并发审批时同一节点被两个人同时操作,状态覆盖

两个审批人同时点“同意”,引擎各自读到相同的 context 副本,各自更新 variables,后写的覆盖先写的,导致会签人数少算。这是典型的并发问题。

解决:在引擎层加乐观锁。context 里维护一个version字段,每次 step 前检查 version 是否变化,变化则拒绝本次操作并提示刷新。或者把审批操作串行化,用队列处理同一流程实例的请求。常见做法是数据库行锁加 version 字段,简单有效。

4.5 现象:表达式求值报错导致整个流程崩溃

new Function遇到语法错误的表达式会直接抛异常,如果没捕获,整个 step 调用栈就挂了。更危险的是恶意表达式可能访问全局对象。

解决:safeEval外面包 try-catch,求值失败时记录错误并走默认出口或抛业务异常。字符白名单只能挡一部分,生产环境强烈建议换成沙箱化的表达式引擎,把变量访问限制在传入的 form 和 variables 上。我见过因为表达式里写了process.exit()导致服务挂掉的案例,血泪经验。

5. 进阶技巧:把流转日志变成可回放的时间线

工作流跑起来之后,最有价值的副产品是 history。它不只是审计日志,还能做成流程回放。我的习惯是每次流转时往 history 里多存一个snapshot,记录当时的表单关键字段和审批人列表。这样前端可以按时间线回放整个审批过程,产品经理提“为什么这单走了总监”时,直接拉时间线给他看,比翻代码快得多。

// 增强版记录:带快照的流转日志 function recordWithSnapshot(context, from, to, operator, decision) { const snapshot = { form: JSON.parse(JSON.stringify(context.form)), // 深拷贝,防止后续修改 assignees: context.variables[`assignees_${from}`] || [], conditions: context.variables[`conditions_${from}`] || [] }; context.history.push({ from, to, operator, decision, timestamp: Date.now(), snapshot }); }

深拷贝这里别省,我一开始用浅拷贝,结果后续表单被改,历史里的 snapshot 跟着变,回放时数据对不上,排查了半天才发现是引用共享。另外 snapshot 只存关键字段,别把整个表单塞进去,不然 history 会膨胀得很快。

验证流程配置是否正确,我一般写一个dryRun方法,不实际流转,只模拟走一遍所有可能路径,把每条路径的节点序列打出来。这样配置改完先 dryRun,确认分支覆盖没问题再上线。

// 干跑:模拟所有条件分支,输出可能路径 function dryRun(engine, formData) { const paths = []; function walk(nodeId, path, ctx) { const node = engine.nodes.get(nodeId); if (!node || node.type === 'end') { paths.push([...path, nodeId]); return; } const nextPath = [...path, nodeId]; if (node.conditions) { for (const cond of node.conditions) { // 模拟条件命中,走对应分支 walk(cond.next, nextPath, ctx); } } if (node.next) walk(node.next, nextPath, ctx); } walk(engine.startNodeId, [], { form: formData, variables: {} }); return paths; }

dryRun 会把所有可能的分支路径列出来,配置改完跑一遍,看看有没有死循环、有没有永远到不了的节点。从那以后我每次改流程配置,都强制走一遍 dryRun 加历史回放验证,确认每条路径都能走通、每个节点的审批人都能解析出来,才敢推到线上。希望帮到你。

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

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

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

立即咨询