news 2026/10/18 15:49:52

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯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指定具体用户 ID,role按角色查找,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' });

从那以后我每次改流程定义,都强制走一遍“发起-审批-驳回-重新提交”的完整链路,确认没有卡死或走错分支才上线。这个习惯帮我省了至少三次半夜爬起来修流程的麻烦。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 15:49:09

信息化教学设计高端模板:从框架到视觉的实战指南

简介&#xff1a;信息化教学设计高端模板是一份面向教师、教研员及师范生的演示文稿&#xff0c;围绕教学分析、教学设计、教学过程、教学反思四个环节搭建完整备课框架。模板将课程结构、教学内容、教学目标、教学重点、学情分析与教学难点系统梳理&#xff0c;帮助使用者在课…

作者头像 李华
网站建设 2026/10/18 15:47:49

话费充值系统源码拆解:直充、快充、慢充三条链路与订单状态机设计

简介&#xff1a;这是一套面向Java后端开发者与支付系统学习者的手机话费充值系统源码&#xff0c;覆盖直充、快充、慢充等常见充值业务模式&#xff0c;可用于搭建充值平台原型、研究订单与支付链路&#xff0c;或作为毕业设计与二次开发的基础工程。压缩包共约2000个文件&…

作者头像 李华
网站建设 2026/10/18 15:47:43

如何写好CTF Writeup:从解题思路到结构化输出的完整指南

很多人在赛后看完题目&#xff0c;第一反应就是“我这题会做&#xff0c;写个 Writeup 就完事”&#xff1a;贴一段代码&#xff0c;放一张 flag 截图&#xff0c;再甩两行“学到了”。这种写法看着完整&#xff0c;实际上你三个月后回头翻&#xff0c;大概率想不起来当时卡在哪…

作者头像 李华
网站建设 2026/10/18 15:44:38

NSGA-II多目标优化实战:从Pareto前沿到MATLAB完整实现

简介&#xff1a;面向 MATLAB 用户的多目标优化资源包&#xff0c;聚焦工程与建模中常见的多个冲突目标同时优化问题&#xff0c;帮助使用者通过遗传算法求取帕累托最优解集&#xff0c;适用于数学建模竞赛、算法课程设计及科研预研。压缩包共六十四个文件&#xff0c;大小仅二…

作者头像 李华
网站建设 2026/10/18 15:44:06

Linux环境手动安装Go 1.25工具链:从tar.gz到可编译运行的完整方案

简介&#xff1a;go1.25.0.linux-amd64.tar.gz 是 Go 语言官方针对 Linux 平台 amd64 架构发布的二进制发行包&#xff0c;为 64 位 Linux 环境下的开发者、学习者和运维人员提供开箱即用的完整 Go 工具链。Go 语言以简洁的语法、高效的编译速度与强大的高并发支持著称&#xf…

作者头像 李华
网站建设 2026/10/18 15:43:29

CalculiX ccx-2.11开源CAE求解器编译与接触非线性实战指南

简介&#xff1a;Calculix.ccx-2.11 是一款开源、跨平台的通用有限元分析&#xff08;FEA&#xff09;求解器源码包&#xff0c;面向结构力学、热力耦合与多物理场仿真领域的工程师及高校科研人员&#xff0c;适用于CAE建模验证、开源工具链开发与Abaqus输入格式兼容性研究。资…

作者头像 李华