简介:面向Web开发者的JavaScript工作流与审批流示例包,适合需要在网页端实现任务提交、审核、驳回、流程可视化等场景的技术人员。包内演示了基于状态机驱动的前端流程引擎,包含流程设计界面、步骤跳转和上下文菜单等交互,并结合角色权限与多语言配置,展示了轻量级审批系统从界面到数据交互的完整思路。整套资源共49个文件,以JS脚本、HTML页面、XML流程定义和CSS样式为主,辅以ASPX后端与数据库文件,压缩包仅69KB,结构紧凑,便于快速阅读和二次修改。已有1285人浏览学习。通过这份示例可以快速搭起一个可运行的工作流前端原型,后续接上自己的后端接口即可用于实际项目,适合学习JS流程管理的中级开发者参考。
1. 审批流不是画流程图,是写状态机:JS 轻量方案的定位
有位同事拿需求来找我,说用 JS 做个请假审批的工作流,跟钉钉差不多,但排期只有一周。如果你一上来就钻进 bpmn.js 的节点和 XML,大概率翻车。我做过几轮请假、报销、合同审批这类轻量级工作流,最深体会是:审批流的主体是状态机,流程图只是给人看的壳。同一套引擎,换一组流程定义,就能支撑简历筛选、采购审批;它和 n8n、Dify 那类自动化编排不是一回事,也不是 Flowable 那种 Java 重引擎。中小团队、几十个流程以内的内部 OA 场景,用「流程定义 JSON + 实例表 + 任务表」三件套最划算。下文按数据模型、引擎实现、前端设计、踩坑实录、验证手段往下拆。
2. 三张表把状态机说清楚:审批流的数据模型设计
2.1 审批流和 n8n 那种工作流不是一回事
现在网上到处是 n8n工作流、Dify 工作流、扣子(Coze)工作流,那是"自动化编排":节点之间传数据、调接口,跑完就结束,没有人等审批这回事。审批流完全相反,核心约束是"人"的介入——一个审批节点可能悬挂三天,等某个具体的人点同意或驳回,流程才继续。所以设计引擎时,脑子里要装的是状态机,不是画布。
状态机落到数据上就三个问题:实例整体停在哪(实例状态)、流程走到哪个节点(当前节点)、这个节点上有哪些人在等(任务状态)。把这三个问题答清楚,其余表单、通知、日志都是附属品。
2.2 流程定义:节点、边、条件是配置不是代码
流程定义是一份 JSON,它是引擎的输入,也是前端设计器保存的产物。下面是一份请假审批的定义,三类节点加一组条件边:
{ "key": "leave_approval", "name": "请假审批", "nodes": [ { "id": "start", "type": "start" }, { "id": "manager_approve", "type": "approve", "assigneeType": "role", "assigneeValue": "dept_manager" }, { "id": "days_gate", "type": "condition" }, { "id": "hr_approve", "type": "approve", "assigneeType": "role", "assigneeValue": "hr" }, { "id": "end", "type": "end" } ], "edges": [ { "from": "start", "to": "manager_approve" }, { "from": "manager_approve", "to": "days_gate" }, { "from": "days_gate", "to": "end", "condition": "days <= 3" }, { "from": "days_gate", "to": "hr_approve", "condition": "days > 3" }, { "from": "hr_approve", "to": "end" } ] }节点类型我一般只用四种:start(发起)、approve(审批)、condition(条件网关)、end(结束)。assigneeType 区分 user 和 role,role 的好处是换人不用改流程。edge 上的 condition 是表达式字符串,值为空表示无条件直达。这个 JSON 是配置不是代码,意味着改流程不用发版本、不用改代码,刷新一下配置就能生效。
2.3 实例表与任务表:状态位和乐观锁是关键
流程定义存一份,剩下的核心是两张表。我常用的结构:
CREATE TABLE flow_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flow_key VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL, current_node_id VARCHAR(64), variables JSON, initiator VARCHAR(64) NOT NULL, flow_snapshot JSON, version INT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL ); CREATE TABLE flow_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_id VARCHAR(64) NOT NULL, assignee VARCHAR(64), status VARCHAR(16) NOT NULL, action VARCHAR(16), comment TEXT, version INT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, acted_at DATETIME );说三个容易被忽略的字段。version 是乐观锁,后面并发避坑全靠它。flow_snapshot 在创建实例时把 flow definition 整个冗余进来,以后流程版本升级,老实例照旧按老规则跑,不会走着走着规则变了。status 用字符串枚举就行,RUNNING / COMPLETED / REJECTED / CANCELED 四个值,别用数字,排查问题时一眼能看出状态。
2.4 引擎的心脏:从当前节点算后继节点
推进逻辑是引擎里最容易写错的地方。顺序流转还好说,遇到条件分支就要小心:先看有没有条件边,有就逐条 eval,谁先命中走谁;没有条件边的多条出边,当作并行分支同时创建任务。为了防止条件节点之间互相指导致死循环,递归解析时要加一个步数上限:
function getNextNodes(flowDef, currentNodeId, variables) { const edges = flowDef.edges.filter(e => e.from === currentNodeId); if (edges.length === 0) return []; const withCond = edges.filter(e => e.condition); const withoutCond = edges.filter(e => !e.condition); if (withCond.length === 0) { return withoutCond.map(e => e.to); } for (const e of withCond) { if (evalCondition(e.condition, variables)) return [e.to]; } // 条件全不命中时走默认边,没有默认边就报错 if (withoutCond.length === 1) return [withoutCond[0].to]; throw new Error(`节点 ${currentNodeId} 没有命中的条件分支`); } function resolveNextNode(flowDef, fromNodeId, variables) { let current = fromNodeId; let guard = 0; while (guard++ < 50) { const node = flowDef.nodes.find(n => n.id === current); if (node.type !== 'condition') return current; const nexts = getNextNodes(flowDef, current, variables); if (nexts.length === 0) throw new Error(`条件节点 ${current} 没有命中任何分支`); current = nexts[0]; } throw new Error('条件节点链过长,疑似流程定义死循环'); }resolveNextNode 的作用是跳过连续的条件节点,让调用方拿到的永远是一个真实要创建任务的审批节点。guard 上限 50 是防呆:配置人员在画布上把两个条件节点互相指,没有这个上限,引擎会递归到栈溢出。
3. 用 Node.js 落地审批引擎:推进、会签、驳回与事务边界
3.1 从定义 JSON 到可运行引擎:六步走完最小闭环
第一步,确定 flow_key 的编码规则。工作流编码从第一天就要定规范,我习惯用小写蛇形,比如 leave_approval、purchase_contract,这个值会写进实例表、任务表、日志表,改起来是全局的事。第二步,把 startFlow 跑通:插入实例、解析第一个审批节点、创建初始任务。第三步,实现 submitTask 推进。第四步,补 reject、withdraw、transfer 三个动作。第五步,写待办、已办、发起三个查询接口。第六步,用脚本把"发起→审批→结束"整条链路跑一遍。
startFlow 是入口,逻辑很直白:
async function startFlow(flowKey, initiator, variables) { const flowDef = await flowRepository.get(flowKey); const firstNodeId = resolveNextNode(flowDef, 'start', variables); const instanceId = await db.insert('flow_instance', { flow_key: flowKey, status: 'RUNNING', current_node_id: firstNodeId, variables, initiator, flow_snapshot: flowDef, // 快照,防版本升级影响老实例 version: 1, created_at: new Date() }); await createTasksForNode(instanceId, firstNodeId, flowDef, variables); return instanceId; }注意 current_node_id 存的是 resolveNextNode 返回的节点,不是 start。这样实例一创建,待办列表就能查到第一个审批人的任务,不出现"流程启动了但没人可办"的空窗。
3.2 会签、或签和条件表达式的实现差别
审批节点常见两种并发模式。会签(AND):同节点给多个人都派任务,必须全部同意才向下走,任何一人驳回就整节点驳回。或签(OR):多个人都可办,第一个同意的人说了算,其余人的任务直接取消。数据模型上不需要新表,给节点配置加一个 signType 字段就行。
async function submitTask(taskId, userId, action, comment) { const task = await db.findOne('flow_task', { id: taskId, status: 'PENDING' }); if (!task) throw new Error('任务不存在或已被处理'); const instance = await db.findOne('flow_instance', { id: task.instanceId }); const flowDef = instance.flow_snapshot; const node = flowDef.nodes.find(n => n.id === task.nodeId); await db.withTransaction(async (tx) => { const updated = await tx.update( 'flow_task', { status: action === 'approve' ? 'APPROVED' : 'REJECTED', action, comment, acted_by: userId, acted_at: new Date() }, 'id = ? AND status = "PENDING" AND version = ?', [task.id, task.version] ); if (updated === 0) throw new Error('任务已被其他审批人处理,请刷新'); await tx.insert('flow_history', { instance_id: instance.id, node_id: task.nodeId, node_name: node.name, actor: userId, action, comment, created_at: new Date() }); if (action === 'reject') { await rejectInstance(tx, instance, task, flowDef); return; } if (node.signType === 'OR') { // 或签:第一人同意,取消同节点其余待办,直接推进 await tx.update('flow_task', { status: 'CANCELED', comment: '或签已由其他审批人处理' }, 'instance_id = ? AND node_id = ? AND status = "PENDING"', [instance.id, task.nodeId]); await advanceInstance(tx, instance, flowDef); } else { // 会签:更新完之后数剩余待办 const remain = await tx.count('flow_task', 'instance_id = ? AND node_id = ? AND status = "PENDING"', [instance.id, task.nodeId]); if (remain === 0) await advanceInstance(tx, instance, flowDef); } }); }条件表达式用 Function 构造器包一层求值,注意这是有安全边界的做法:只有后台管理员配置的流程定义能进这里,任何用户输入都不该走到 eval 这一层,否则就是代码注入漏洞。
function evalCondition(condition, variables) { const keys = Object.keys(variables); const values = keys.map(k => variables[k]); try { return new Function(...keys, `return ${condition};`)(...values); } catch (e) { throw new Error(`条件表达式解析失败: ${condition}`); } }这里有个血泪经验:variables 里 days 可能是字符串 "5",表达式里写 days > 3 会得到 false,因为字符串和数字比较规则和你想的不一样。建议 startFlow 入口处强制做一次类型转换,days、amount 这类数值字段统一转 Number。
3.3 驳回、撤回、转交:三个边界动作的参数设计
驳回的目标不能写死,我习惯在流程定义里加 rejectTargets 映射:manager_approve 驳回到 start,hr_approve 驳回到 manager_approve。这样设计人员画图时就能在每个审批节点上指定"驳回给谁"。驳回到 start 的语义是整单作废,实例直接置为 REJECTED;驳回到中间节点则重新给那个节点创建任务。
撤回的约束只有一个:实例上没有任何任务被处理过,也就是还停在第一个审批节点且无人点击。只要有人批过,就不允许撤回,这是审批留痕的基本要求。转交则是换人不换节点:
async function transferTask(taskId, fromUserId, toUserId) { const updated = await db.update('flow_task', { assignee: toUserId, version: db.raw('version + 1') }, 'id = ? AND assignee = ? AND status = "PENDING"', [taskId, fromUserId] ); if (updated === 0) throw new Error('任务不存在或该用户已不是当前审批人'); await db.insert('flow_history', { instance_id: instanceId, node_id: nodeId, node_name: nodeName, actor: fromUserId, action: 'transfer', comment: `转交给 ${toUserId}`, created_at: new Date() }); }转交只允许当前 assignee 本人操作,update 条件里带上 assignee = fromUserId,既能防止转交给自己的同事时误伤他人,又天然处理了并发。
3.4 事务边界:状态更新、任务创建、历史写入必须同生共死
前面代码里多次出现 db.withTransaction,这不是摆设。审批推进这个动作至少改三处:实例的 current_node_id、任务的状态、历史表的新记录。任何一处失败,另外两处必须回滚。我见过线上翻车案例:实例状态改成了 COMPLETED,但任务表还剩一条 PENDING,待办列表永远挂着一个幽灵任务。原因就是没包事务,状态更新先写了,创建任务时报错没人管。
锁的选择上,审批系统并发量不大,乐观锁足够。update 语句带 status = "PENDING" 和 version = ? 两个条件,返回值是 0 就说明被并发改了,直接抛"任务已被处理"。不用 select for update,那会放大锁粒度,万一会签节点多人同时点,反而把自己锁死。
4. 前端怎么配流程:设计器选型与审批页面的展示细节
4.1 流程设计器选型:bpmn.js、AntV X6 还是自研
前端最纠结的就是设计器。我按自己的经验给个对比:
| 方案 | 学习成本 | 与自研引擎的对接 | 适用场景 |
|---|---|---|---|
| bpmn.js | 高,要懂 BPMN 2.0 语义 | 难,要处理 XML 与 JSON 互转 | 必须导出标准 BPMN 文件给外部系统 |
| AntV X6 | 中,画布加节点边模型简单 | 容易,数据直接就是 JSON | 内部审批流,自己定义节点类型 |
| 纯自研画布 | 低 | 最容易 | 节点类型固定、交互简单时 |
我一般选 AntV X6。bpmn.js 的 XML 格式对自研引擎来说是个黑匣子,你画完图还得写一堆转换代码把 modeler 的数据倒腾成引擎认识的 JSON,这层转换就是 bug 的温床。X6 没有强语义,节点就是矩形加数据,边就是连线加条件文本,和引擎的 JSON 定义天然对齐。
import { Graph } from '@antv/x6'; const graph = new Graph({ container: document.getElementById('container'), grid: true, connecting: { anchor: 'center', connector: 'rounded' } }); graph.addNode({ id: 'manager_approve', shape: 'rect', x: 120, y: 100, width: 160, height: 44, label: '部门经理审批', attrs: { body: { fill: '#fff', stroke: '#3471F9' } } });节点 id 必须和引擎 JSON 里的节点 id 严格一致,这是前后端约定的关键点。设计器保存时直接导出 X6 的数据结构,后端只校验语义,不重新生成 id。
4.2 从画布数据导出引擎 JSON:一个序列化函数搞定
function exportFlowDefinition(graph) { const nodes = graph.getNodes().map(n => { const data = n.getData() || {}; return { id: n.id, type: data.type, assigneeType: data.assigneeType, assigneeValue: data.assigneeValue, signType: data.signType }; }); const edges = graph.getEdges().map(e => { const data = e.getData() || {}; return { from: e.getSourceCellId(), to: e.getTargetCellId(), condition: data.condition || '' }; }); return { key: form.flowKey, name: form.name, nodes, edges }; }导出之后,前端要做的最后一件事是把结果 POST 到后端的校验接口,而不是直接存库。校验逻辑我放在后面章节讲,但必须在前端也展示错误:哪条边指向了不存在的节点、哪个审批节点没配审批人,红色的报错要能定位到画布上的具体对象。
4.3 审批操作页和流程历史:两个容易被忽略的细节
待办列表的查询不能裸奔。assignee + status 是最高频的过滤条件,必须建联合索引:
CREATE INDEX idx_task_assignee_status ON flow_task(assignee, status);历史时间线展示时,动作值别只存 approve / reject 这种英文枚举,写历史那一刻就把中文动作文本一并存了,或者前端做字典映射。否则后面想改成"同意、驳回、转交、撤回"的展示,得翻所有历史数据。
5. 审批流避坑实录:并发覆盖、角色换人、条件误判、状态漂移
5.1 并发提交导致审批状态覆盖
现象:两个审批人同时点同意,一条任务被处理两次,实例的当前节点跳了两个,历史里出现两条互相矛盾的操作记录。
原因:典型的先读后写竞态。两个请求都读到 status = PENDING,各自执行 update,后写的覆盖先写的。
解决:update 语句必须带 status = "PENDING" 条件,配合 version 乐观锁。影响行数为 0 时直接抛"任务已被处理"。这条规则同时约束了 submitTask、transferTask、withdraw 三个入口。
5.2 审批人配的是角色,人离职后实例悬挂
现象:节点配的是"部门经理"这个角色,但角色下唯一的人在审批中途离职,任务永远停在 PENDING,没人能办。
原因:创建任务时把角色解析成具体用户写死,角色变动不会回写到历史任务。
解决:任务表冗余 assignee 字段的同时,把 assignee_type 和 assignee_value 也存下来。另加一个定时任务,每小时扫一次 PENDING 任务,如果 assignee 对应的用户已离职或不在角色里,触发重新解析审批人。更省事的方案是给节点配超时转交,超过 48 小时自动转给上一级角色。
5.3 条件表达式写错导致流程乱走
现象:2 天的请假走到 HR 审批,10 天的请假反而直接通过,审批结果和业务规则完全相反。
原因:变量名对不上,引擎拿到的 variables 里字段叫 dayCount,条件表达式里写的是 days,eval 时读到 undefined,所有比较都成了 false;或者类型不对,字符串 "5" 和数字 3 比较,结果不可预期。
解决:startFlow 入口统一做变量类型转换,数值字段强制 Number。条件表达式在保存流程定义时做静态校验:变量名必须存在于 schema,表达式的括号、比较符必须合法。最稳的是把常见对比词限制白名单,不用开放式的 Function 求值。
5.4 驳回后重新提交,老实例被新规则误判
现象:流程定义升了版本,条件从 days > 3 改成 days > 5,老实例重新提交后走到条件节点,用的却是新规则,该走的流程走反了。
原因:实例推进时读的是"当前最新流程定义",而不是发起那一刻的版本。
解决:startFlow 时把 flow definition 整个写入 flow_snapshot 字段,submitTask 里一律用 instance.flow_snapshot 计算后继节点,和线上的最新版本完全隔离。这条规则从一开始就要定死,不要留"反正先上线再说"的侥幸。
5.5 流程日志与当前状态不一致
现象:流程历史已经显示"HR 审批通过",但待办列表里还有 HR 的任务挂着,用户反复刷新都消不掉。
原因:状态更新和任务创建不在同一个事务里;或者是会签节点只更新了当前这条任务,没检查同节点其他 PENDING 任务是否该一起取消。
解决:所有状态变更、任务创建、任务取消、历史写入必须包在同一个数据库事务中。会签和或签的处理逻辑要分开测:会签是"数剩余",或签是"主动取消剩余"。我后来把这两个分支写成了独立函数,避免在 if 里越写越乱。
6. 给审批流做体检:最小闭环用例、配置校验器与待办索引优化
6.1 一条最小闭环用例跑通全流程
每上线一个流程定义,我都会维护一组 Node.js 测试脚本,覆盖"正常通过、条件分支、驳回、撤回"四条路径。最小闭环用例长这样:
async function testLeaveApproval() { const instId = await startFlow('leave_approval', 'u_001', { days: 2 }); let tasks = await findPendingTasks(instId); assert.equal(tasks.length, 1); await submitTask(tasks[0].id, 'u_003', 'approve', '同意'); const inst = await getInstance(instId); assert.equal(inst.status, 'COMPLETED'); // 2 天以内,经理审批后直接结束 }这脚本比 UI 上点十遍靠谱得多。引擎发版前跑一次,条件分支改动后跑一次,基本能挡住九成的回归。
6.2 配置校验器:在设计期拦截非法流程图
function validateFlowDef(flowDef) { const ids = new Set(flowDef.nodes.map(n => n.id)); if (!ids.has('start')) throw new Error('缺少开始节点'); if (flowDef.nodes.filter(n => n.type === 'end').length === 0) { throw new Error('至少需要一个结束节点'); } for (const e of flowDef.edges) { if (!ids.has(e.from) || !ids.has(e.to)) { throw new Error(`边 ${e.from} -> ${e.to} 引用了不存在的节点`); } } for (const n of flowDef.nodes) { if (n.type === 'approve' && !n.assigneeType) { throw new Error(`审批节点 ${n.id} 缺少审批人配置`); } } }校验器放在后端保存接口里,前端画布保存时也会先调一次,错误信息直接定位到节点 id。合格的设计器交互应该是"画完就能跑",而不是等流程跑起来才发现定义有洞。
6.3 待办索引、超时提醒与版本迁移
待办查询务必确认索引建了,全表扫描的流程任务表超过十万条后,每个接口响应都会肉眼可见地变慢。超时提醒用定时任务扫 PENDING 超过 N 小时的记录,触达方式可以是站内信或机器人群机器人。流程定义升级时,只影响新发起的实例,老实例按快照执行,这就是 flow_snapshot 的价值。
我现在的习惯是:接到审批流需求,先画状态图,再写引擎,最后才碰设计器和页面。状态图没画明白就动代码,后面全是给翻车补窟窿,功能上线后维护成本会比开发成本高好几倍。希望这些经验帮到你。
本文还有配套的精品资源,点击获取