纯JavaScript工作流引擎:从审批流到自定义节点

📅 发布时间:2026/10/18 15:50:03
纯JavaScript工作流引擎:从审批流到自定义节点
简介这是一份面向前端与全栈开发者的 JavaScript 工作流与审批流实现资源适合需要理解流程引擎原理、搭建审批类业务系统的初中级开发者参考。资源围绕工作流概念、状态机驱动、任务分配与调度、审批界面交互、数据持久化及异常回退等核心环节展开并涉及 BPMN 标准与常见 JavaScript 工作流框架的设计思路。压缩包共 49 个文件约 69KB包含 8 个 js 脚本、8 个 html 页面、6 个 xml 流程定义、2 个 aspx 与 2 个 cs 后端文件以及 css、gif、png、db、xsl、config 等配套资源覆盖流程定义、界面展示与数据存储等模块。目前已有 1286 人学习下载。通过其中的流程配置、步骤对话框与权限页面等示例读者可快速理解审批流的节点流转、角色权限控制与前后端协作方式并借鉴其模块化组织与异常处理思路用于自身业务系统的流程设计与排错参考。1. 从一张请假单说起为什么前端也需要工作流引擎去年接手一个内部审批系统时我天真地以为用 if-else 就能搞定所有流程。结果需求方甩过来一张流程图请假超过 3 天要总监审批超过 7 天要 HR 介入项目紧急时还要并行通知 PM。更离谱的是他们希望流程能随时改今天加个节点明天换个审批人后天又要支持会签。那一刻我才意识到硬编码的审批逻辑就是个无底洞。这个项目就是来解决这个问题的一个纯 JavaScript 实现的工作流与审批流引擎。它把流程定义、节点流转、审批人计算、条件分支这些脏活累活全部抽象出来让业务代码只需要关心“谁在什么条件下审批什么”。适合谁用任何需要审批场景的前端或 Node.js 项目——请假、报销、合同、发布单甚至是你自己写的低代码平台。它不依赖任何后端框架浏览器和 Node 环境都能跑数据格式就是 JSON改流程不用重新部署。2. 核心架构拆解流程定义、节点流转与审批人计算2.1 流程定义用 JSON 描述一张审批图工作流引擎的第一件事是把“流程图”变成代码能理解的数据结构。这个项目采用 JSON 来描述流程定义每个流程包含节点列表和连线规则。节点类型主要有四种开始节点、审批节点、条件节点、结束节点。审批节点里可以配置审批人来源条件节点里写表达式决定走哪条分支。先看一个最简的请假流程定义{ id: leave_flow, name: 请假审批, nodes: [ { id: start, type: start, next: leader_approve }, { id: leader_approve, type: approval, name: 直属领导审批, assignee: { type: role, value: leader }, next: check_days }, { id: check_days, type: condition, branches: [ { expr: days 3, next: director_approve }, { expr: days 3, next: end } ] }, { id: director_approve, type: approval, name: 总监审批, assignee: { type: user, value: director_001 }, next: end }, { id: end, type: end } ] }这段 JSON 定义了一个典型的条件分支流程。nodes数组里每个对象代表一个节点type决定节点行为next指向下一个节点 ID。条件节点用branches数组描述多个分支每个分支有expr表达式和next目标节点。逻辑说明引擎启动时会先解析这份 JSON构建节点索引表然后从start节点开始执行。遇到审批节点就暂停生成待办任务遇到条件节点就计算表达式决定跳转目标。参数方面assignee支持三种类型user指定具体用户 IDrole按角色查找dept按部门查找。实际项目中审批人往往需要动态计算比如“发起人的直属上级”这时候可以扩展assignee类型在引擎里注册自定义解析函数。我一般会把流程定义存到数据库或配置中心前端通过接口拉取后渲染流程图。这样业务人员改流程时只需要更新 JSON不用动一行代码。2.2 节点流转一个请求从发起到归档的完整链路流程定义只是静态描述真正让流程跑起来的是引擎的流转逻辑。这个项目的核心是一个WorkflowEngine类它维护流程实例的状态并驱动节点一步步执行。先看引擎的初始化与启动import { WorkflowEngine } from js-workflow; const engine new WorkflowEngine({ definitions: [leaveFlow], // 流程定义列表 storage: memoryStorage, // 存储适配器可换成数据库 resolvers: { // 自定义审批人解析器 leader: async (context) { const { initiator } context; return await getUserLeader(initiator); } } }); // 发起一个请假流程 const instance await engine.start(leave_flow, { initiator: user_001, variables: { days: 5, reason: 年假 } }); console.log(instance.status); // running console.log(instance.currentNodes); // [leader_approve]这段代码做了三件事创建引擎实例、注册自定义解析器、发起流程。start方法接收流程 ID 和初始上下文返回一个流程实例对象。实例里包含status运行状态、currentNodes当前待处理节点、variables流程变量等字段。逻辑说明引擎启动后会从start节点开始遍历。遇到审批节点时调用resolvers里对应的解析器计算审批人然后生成待办任务并暂停流转。遇到条件节点时用variables里的数据计算表达式决定下一个节点。整个过程是异步的因为审批人解析可能涉及接口调用。参数方面storage是存储适配器默认用内存存储生产环境可以换成 Redis 或数据库。resolvers是审批人解析器注册表key 对应流程定义里的assignee.type。variables是流程变量可以在节点间传递也可以在条件表达式中引用。当审批人处理完任务后调用complete方法继续流转// 领导审批通过 await engine.complete(instance.id, leader_approve, { approver: leader_001, result: approved, comment: 同意 }); // 查看流程状态 const updated await engine.get(instance.id); console.log(updated.currentNodes); // [director_approve] console.log(updated.history); // 包含已完成的节点记录complete方法接收实例 ID、节点 ID 和审批结果。引擎会记录审批历史然后根据next字段或条件分支继续流转。如果下一个节点是结束节点流程状态变为completed。这里有个关键设计引擎不关心审批人是怎么算出来的也不关心审批结果怎么存。它只负责按流程定义驱动节点流转把审批人计算和存储都抽象成可替换的适配器。这种设计让引擎可以适应各种业务场景也方便单元测试。2.3 审批人计算从静态配置到动态解析审批人计算是审批流里最容易翻车的地方。静态配置只能应付 demo真实场景里审批人往往是“发起人的直属上级”“金额大于 5000 时的财务总监”“项目组成员的会签”这类动态规则。这个项目用解析器模式来解决。流程定义里只写assignee.type和assignee.value具体怎么算交给注册的解析器。比如实现一个“直属上级”解析器// 注册自定义解析器 engine.registerResolver(directLeader, async (context) { const { initiator, variables } context; // 调用组织架构接口查询直属上级 const leader await orgApi.getDirectLeader(initiator); if (!leader) { throw new Error(未找到直属上级请检查组织架构); } return leader.id; }); // 流程定义里使用 { id: leader_approve, type: approval, assignee: { type: directLeader }, next: check_days }逻辑说明解析器是一个异步函数接收流程上下文返回审批人 ID 或 ID 数组。返回数组时表示会签需要所有人都审批通过才能继续。解析器里可以做任何事查数据库、调接口、算表达式。引擎只负责调用解析器并处理返回结果。参数方面context包含initiator发起人、variables流程变量、currentNode当前节点定义等字段。解析器可以抛出异常引擎会捕获并记录错误流程实例进入error状态方便排查。我一般会把解析器按业务域分组比如orgResolvers、financeResolvers然后在引擎初始化时统一注册。这样不同流程可以复用同一套解析逻辑改规则时只改解析器不用动流程定义。3. 跑起来环境准备、依赖安装与第一个审批流程3.1 环境准备与依赖安装这个项目是纯 JavaScript 实现没有原生依赖Node.js 14 以上或现代浏览器都能跑。如果你用 npm 管理依赖安装很简单# 创建项目目录 mkdir workflow-demo cd workflow-demo # 初始化 package.json npm init -y # 安装工作流引擎 npm install js-workflow # 安装开发依赖可选用于跑测试 npm install --save-dev jest如果你不想用 npm也可以直接下载源码把src目录拷到项目里。引擎本身不依赖任何第三方库源码里只有几个工具函数读起来很快。安装完成后创建一个index.js文件写入上一章的示例代码。运行node index.js如果看到流程实例状态输出说明环境没问题。提示Node 版本低于 14 时异步迭代器可能不支持建议升级到 16 以上。浏览器环境需要支持 ES2018 语法IE 就别想了。3.2 定义流程与发起审批跑通环境后我们来定义一个完整的报销审批流程。这个流程比请假复杂一点金额小于 500 走自动审批500 到 5000 走部门经理超过 5000 走财务总监最后都要财务专员归档。import { WorkflowEngine, MemoryStorage } from js-workflow; // 定义报销流程 const expenseFlow { id: expense_flow, name: 报销审批, nodes: [ { id: start, type: start, next: check_amount }, { id: check_amount, type: condition, branches: [ { expr: amount 500, next: auto_approve }, { expr: amount 500 amount 5000, next: manager_approve }, { expr: amount 5000, next: director_approve } ] }, { id: auto_approve, type: approval, name: 自动审批, assignee: { type: system }, next: finance_archive }, { id: manager_approve, type: approval, name: 部门经理审批, assignee: { type: deptLeader }, next: finance_archive }, { id: director_approve, type: approval, name: 财务总监审批, assignee: { type: role, value: finance_director }, next: finance_archive }, { id: finance_archive, type: approval, name: 财务归档, assignee: { type: role, value: finance_staff }, next: end }, { id: end, type: end } ] }; // 初始化引擎 const engine new WorkflowEngine({ definitions: [expenseFlow], storage: new MemoryStorage(), resolvers: { system: async () system, deptLeader: async (ctx) { const leader await getDeptLeader(ctx.initiator); return leader.id; } } }); // 发起报销 const instance await engine.start(expense_flow, { initiator: user_001, variables: { amount: 3000, category: 差旅 } }); console.log(instance.currentNodes); // [manager_approve]逻辑说明条件节点的expr表达式里可以直接引用variables里的字段。引擎内置了一个简单的表达式求值器支持 ! ||这些运算符。如果表达式更复杂可以注册自定义函数。参数方面MemoryStorage是内存存储重启后数据丢失适合测试。生产环境可以换成RedisStorage或DatabaseStorage项目里提供了适配器接口实现save、get、list等方法即可。3.3 审批、驳回与流程查询流程跑起来后审批人需要处理待办。引擎提供了complete方法用于审批通过reject方法用于驳回query方法用于查询待办和已办。// 审批通过 await engine.complete(instance.id, manager_approve, { approver: manager_001, result: approved, comment: 同意报销 }); // 驳回 await engine.reject(instance.id, manager_approve, { approver: manager_001, comment: 发票不清晰请重新提交 }); // 查询某人的待办 const todos await engine.query({ assignee: manager_001, status: pending }); // 查询流程历史 const history await engine.getHistory(instance.id); console.log(history); // [{ nodeId: start, ... }, { nodeId: check_amount, ... }, ...]逻辑说明complete和reject都会记录审批历史区别在于reject会把流程状态改为rejected并终止流转。如果流程定义里配置了驳回策略比如“驳回到发起人”或“驳回到上一节点”引擎会按策略处理。参数方面query方法支持按assignee、status、flowId等条件过滤。getHistory返回节点执行记录包含节点 ID、审批人、审批结果、时间戳等字段。这些数据可以用来渲染审批时间线。注意驳回后流程不会自动重新发起需要业务代码调用restart方法重新启动一个新实例或者用rollback方法回退到指定节点。我一般会在业务层封装一个“重新提交”按钮把原实例的变量复制到新实例。4. 避坑指南审批流开发中常见的五个坑4.1 审批人解析返回空导致流程卡死现象流程发起后一直停在某个审批节点待办列表里看不到任务日志里也没有报错。原因审批人解析器返回了null或空数组引擎不知道把任务派给谁只能挂起。很多解析器在查不到审批人时不会抛异常而是静默返回空值。解决在解析器里加断言查不到审批人就抛异常。引擎捕获异常后会把流程实例标记为error状态方便排查。同时在前端加一个“流程异常”列表让管理员能手动干预。engine.registerResolver(deptLeader, async (ctx) { const leader await getDeptLeader(ctx.initiator); if (!leader) { throw new Error(未找到 ${ctx.initiator} 的部门领导); } return leader.id; });4.2 条件表达式里的变量类型不匹配现象条件分支走错了明明amount是 3000却走了amount 5000的分支。原因variables里的amount是字符串3000表达式求值器做的是字符串比较3000 5000在字典序下是false但3000 500是true因为字符串和数字比较时会隐式转换结果不可预测。解决在流程发起前对变量做类型校验或者在表达式求值器里强制类型转换。我一般会在引擎配置里加一个strictMode开启后所有变量必须声明类型表达式求值时按类型转换。const engine new WorkflowEngine({ strictMode: true, variableTypes: { amount: number, days: number, reason: string } });4.3 会签节点部分审批后无法撤回现象会签节点有 3 个审批人其中 1 人审批通过后想撤回发现撤回按钮不可用。原因会签节点的状态是“部分完成”引擎默认不允许撤回已部分完成的节点。这是设计上的取舍避免撤回后已审批的人需要重新审批。解决如果业务上必须支持撤回可以在引擎配置里开启allowPartialRollback撤回时会清空该节点所有审批记录。或者用“加签”代替撤回让已审批的人重新确认。4.4 流程定义更新后旧实例无法继续现象流程定义从 v1 升级到 v2加了一个新节点结果正在运行的旧实例流转到新节点时找不到定义报错。原因引擎默认用最新版本的流程定义驱动所有实例旧实例的节点 ID 在新定义里可能不存在。解决流程定义要带版本号实例启动时记录版本号流转时用对应版本的定义。项目里提供了version字段我一般会在流程定义里加version: 2引擎会根据实例的flowVersion加载对应定义。const expenseFlowV2 { id: expense_flow, version: 2, nodes: [ /* 新节点 */ ] };4.5 存储适配器并发写入导致数据覆盖现象两个审批人同时审批同一个流程后提交的覆盖了先提交的记录历史丢失。原因内存存储或简单的数据库存储在并发写入时没有加锁导致读-改-写竞争。解决存储适配器要实现乐观锁或悲观锁。我一般会在存储层加一个version字段每次更新时检查版本号不匹配就抛异常让上层重试。如果用 Redis可以用WATCH命令实现乐观锁。class RedisStorage { async save(instance) { const key wf:${instance.id}; const current await redis.get(key); if (current JSON.parse(current).version ! instance.version) { throw new Error(并发冲突请重试); } instance.version (instance.version || 0) 1; await redis.set(key, JSON.stringify(instance)); } }5. 二次开发自定义节点、存储适配与流程可视化5.1 扩展自定义节点类型引擎内置了开始、审批、条件、结束四种节点但真实业务里往往需要更多类型比如“抄送节点”“延时节点”“子流程节点”。扩展节点类型只需要继承BaseNode并注册到引擎。import { BaseNode } from js-workflow; class CcNode extends BaseNode { async execute(context) { // 抄送逻辑给指定人发送通知但不阻塞流程 const { assignee } this.definition; const users await resolveUsers(assignee, context); await sendNotification(users, context); // 直接流转到下一个节点 return this.next(context); } } // 注册节点类型 engine.registerNodeType(cc, CcNode);逻辑说明execute方法是节点的入口接收流程上下文返回下一个节点 ID。this.definition是节点定义this.next是辅助方法用于获取下一个节点。抄送节点不生成待办执行完直接流转。参数方面context包含instance流程实例、variables流程变量、engine引擎引用等。自定义节点可以调用引擎的任何方法比如engine.complete来跳过审批。5.2 替换存储适配器接入数据库内存存储只适合测试生产环境需要持久化。项目定义了存储适配器接口实现save、get、delete、list四个方法即可接入任意数据库。class MySQLStorage { constructor(pool) { this.pool pool; } async save(instance) { await this.pool.query( INSERT INTO workflow_instances (id, flow_id, status, data, updated_at) VALUES (?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE status ?, data ?, updated_at NOW(), [instance.id, instance.flowId, instance.status, JSON.stringify(instance), instance.status, JSON.stringify(instance)] ); } async get(id) { const [rows] await this.pool.query( SELECT data FROM workflow_instances WHERE id ?, [id] ); return rows[0] ? JSON.parse(rows[0].data) : null; } async list(filter) { // 按条件查询返回实例列表 } async delete(id) { await this.pool.query(DELETE FROM workflow_instances WHERE id ?, [id]); } }逻辑说明save方法用INSERT ... ON DUPLICATE KEY UPDATE实现 upsert避免先查后写。data字段存整个实例的 JSON方便扩展。如果查询性能要求高可以把status、flow_id、assignee这些字段单独建索引。参数方面pool是数据库连接池可以用mysql2或pg。filter支持status、flowId、assignee等条件具体实现看业务需求。5.3 流程可视化与调试技巧流程定义是 JSON人眼读起来费劲。我一般会写一个简单的可视化工具把 JSON 渲染成流程图方便业务人员确认。用d3或antv都可以核心是把节点和连线画出来。function renderFlow(definition, container) { const nodes definition.nodes.map(node ({ id: node.id, label: node.name || node.id, type: node.type })); const edges []; definition.nodes.forEach(node { if (node.next) { edges.push({ source: node.id, target: node.next }); } if (node.branches) { node.branches.forEach(branch { edges.push({ source: node.id, target: branch.next, label: branch.expr }); }); } }); // 用图形库渲染 nodes 和 edges drawGraph(container, nodes, edges); }逻辑说明先把节点和连线提取成图数据再交给图形库渲染。条件分支的连线要带上表达式标签方便调试时看走哪条分支。调试技巧引擎提供了debug模式开启后会在控制台打印每个节点的执行日志包括节点 ID、审批人、变量快照。我一般在开发环境开启生产环境关闭。const engine new WorkflowEngine({ debug: process.env.NODE_ENV development });从那以后我每次改流程定义都强制走一遍“发起-审批-驳回-重新提交”的完整链路确认没有卡死或走错分支才上线。这个习惯帮我省了至少三次半夜爬起来修流程的麻烦。希望帮到你。本文还有配套的精品资源点击获取