纯前端JS工作流引擎与审批流源码解析:从JSON定义到二次开发

发布时间:2026/10/25 16:44:52
纯前端JS工作流引擎与审批流源码解析:从JSON定义到二次开发

简介:面向Web前端与全栈开发者的一份JavaScript工作流与审批流实践资料,聚焦如何在浏览器端组织任务分配、状态流转与多人协作审批,适合已具备一定JS基础、希望理解审批流程实现思路的中级学习者参考。压缩包共49个文件,约69KB,以js脚本、html页面、xml流程定义与gif示意图为主,辅以css样式、aspx与cs后端页面、db数据文件及xsl、config等配置,覆盖流程设计、界面交互与数据持久化等环节。资源中可见流程定义文件、步骤与动作对话框页面、多语言资源及右键菜单、拖拽移动步骤等交互脚本,便于读者对照理解状态机驱动的流程推进、角色权限控制与异常回退处理。目前已有1288人学习下载,可作为梳理审批流前后端协作与流程引擎设计思路的轻量参考。

1. 从一张审批单说起:这个 js 工作流项目到底解决什么问题

去年接手一个内部审批系统时,我第一反应是找现成的流程引擎,结果发现要么是 Java 生态的重型方案,要么是拖拽式低代码平台,前端想改一个节点样式都得翻半天文档。后来拿到这份 js 工作流、审批流源码,才算把「流程定义」和「页面渲染」这两件事拆开了看。它本质上是一套纯前端可跑的工作流引擎加审批流界面:用 JSON 描述节点和连线,引擎负责推进状态、判断分支、记录流转历史,页面负责把当前节点、待办人、审批意见渲染出来。适合两类人:一是要在管理后台里嵌审批功能的前端开发者,二是想搞懂工作流引擎内部怎么跑、准备自己改一版的中级选手。下面按「能干什么 → 怎么跑 → 核心怎么实现 → 怎么改」的顺序拆。

2. 项目结构与技术选型:一个纯前端工作流引擎的骨架

2.1 目录分层与职责边界

拿到源码先别急着 npm install,花十分钟把目录扫一遍,后面改代码能少走很多弯路。这类 js 工作流项目通常按「引擎核心 / 流程定义 / 视图层 / 工具函数」四块分,我按实际拆过的结构给你还原一下:

workflow-demo/ ├── src/ │ ├── engine/ # 引擎核心,与 UI 无关 │ │ ├── Workflow.js # 流程实例,负责 start/next/back │ │ ├── Node.js # 节点模型,类型、审批人、条件 │ │ ├── Condition.js # 分支条件求值 │ │ └── History.js # 流转历史记录 │ ├── definition/ # 流程定义(JSON 模板) │ │ └── leave.json # 请假审批示例流程 │ ├── views/ # 页面层 │ │ ├── FlowDesigner.js # 简易流程设计器 │ │ └── ApprovalPanel.js # 审批操作面板 │ ├── utils/ │ │ └── expression.js # 条件表达式解析 │ └── index.js # 对外入口 ├── public/ │ └── index.html ├── package.json └── README.md

关键点在于engine/目录不依赖任何 DOM 和框架,纯 JS 类,这意味着你可以把它抽出来放到 Vue、React 甚至小程序里跑。views/才是和页面绑定的部分。我第一次改的时候把审批逻辑写进了视图层,后来换框架重构,血泪经验就是:引擎和视图一定要守住边界,引擎只认数据,不认页面。

2.2 技术栈与依赖取舍

这类项目一般走「原生 JS + 轻量构建」路线,不绑死框架。典型依赖如下:

依赖作用是否可替换
vite / webpack本地开发与打包可换,看团队习惯
原生 DOM 或轻量模板视图渲染可换 Vue/React
自研 expression.js条件表达式求值不建议换,和引擎耦合
localStorage / 内存流程实例持久化可换后端接口

为什么不用现成流程引擎?因为审批流的核心诉求其实很窄:串行审批、条件分支、会签、驳回、历史留痕。重型引擎带了一堆你用不上的能力,反而让前端改一个节点字段都要穿透好几层。这个项目选择自己实现,代价是功能没那么全,好处是每一行都能改。我一般会建议:流程节点不超过 20 个、审批人规则不复杂的场景,自研这套完全够用;一旦涉及复杂会签和动态加签,再考虑接后端引擎。

2.3 流程定义的 JSON 长什么样

整个项目的「配置入口」就是流程定义 JSON,引擎读它、设计器改它、页面渲染它。先看一个请假审批的示例:

{ "id": "leave-flow", "name": "请假审批", "start": "apply", "nodes": [ { "id": "apply", "type": "start", "name": "提交申请", "next": "leader" }, { "id": "leader", "type": "approval", "name": "直属领导审批", "assignee": "leaderId", "next": "checkDays" }, { "id": "checkDays", "type": "condition", "name": "天数判断", "branches": [ { "expr": "days <= 3", "next": "hr" }, { "expr": "days > 3", "next": "manager" } ] }, { "id": "hr", "type": "approval", "name": "HR 备案", "assignee": "hrId", "next": "end" }, { "id": "manager", "type": "approval", "name": "部门经理审批", "assignee": "managerId", "next": "end" }, { "id": "end", "type": "end", "name": "结束" } ] }

字段含义:type决定节点行为,start是入口,approval是人工审批节点,condition是自动分支节点,end是终点。assignee存的是变量名而不是具体人名,运行时从表单数据里取值,这样同一份流程定义能复用给不同的人。branches里的expr是条件表达式,由expression.js求值。改流程基本就是改这份 JSON,不用动引擎代码,这是这套设计最舒服的地方。

3. 跑起来:从安装到走通一条审批流

3.1 本地启动与最小验证

先把项目跑起来,别一上来就读源码。标准流程:

# 安装依赖 npm install # 启动开发服务器(vite 项目默认 5173 端口) npm run dev # 生产构建,验证打包是否正常 npm run build

启动后浏览器打开本地地址,应该能看到一个流程设计器加一个审批面板。如果页面空白,先看控制台有没有Cannot read properties of undefined,多半是流程定义 JSON 路径没对上。我一般会先手动走一遍:提交申请 → 领导审批通过 → 天数判断 → 对应分支 → 结束,确认引擎能推进,再去看代码。这一步能帮你快速判断「是环境问题还是逻辑问题」。

3.2 引擎启动与节点推进的核心代码

跑通之后,重点看engine/Workflow.js。这是整个项目的心脏,核心方法就三个:start、next、back。下面是我按这类项目常见实现还原的核心片段:

// engine/Workflow.js class Workflow { constructor(definition, formData = {}) { this.definition = definition; this.formData = formData; // 表单数据,条件求值和审批人取值都靠它 this.currentNodeId = null; this.history = []; // 流转历史,驳回和追溯要用 } // 启动流程,定位到 start 节点 start() { this.currentNodeId = this.definition.start; this.record('start'); return this.getCurrentNode(); } // 推进到下一个节点,审批通过时调用 next(comment = '') { const node = this.getCurrentNode(); if (node.type === 'end') return null; let nextId; if (node.type === 'condition') { // 条件节点:逐条求值,命中第一条即走 const branch = node.branches.find(b => evaluate(b.expr, this.formData) ); nextId = branch ? branch.next : null; } else { nextId = node.next; } if (!nextId) throw new Error(`节点 ${node.id} 没有可走的下一节点`); this.record('approve', comment); this.currentNodeId = nextId; return this.getCurrentNode(); } // 驳回:回退到上一个审批节点 back(comment = '') { const last = [...this.history].reverse() .find(h => h.action === 'approve'); if (!last) throw new Error('没有可回退的节点'); this.record('reject', comment); this.currentNodeId = last.nodeId; return this.getCurrentNode(); } getCurrentNode() { return this.definition.nodes.find(n => n.id === this.currentNodeId); } record(action, comment = '') { this.history.push({ nodeId: this.currentNodeId, action, comment, time: Date.now() }); } }

逐段说明:构造函数接收流程定义和表单数据,formData是条件求值和审批人解析的数据源,必须提前传进来。start()只做定位和记录,不推进。next()是核心,先判断当前节点类型,条件节点走branches求值,普通节点直接取next;找不到下一节点就抛错,这是防止流程定义写错导致死循环的兜底。back()从历史里倒着找最近一次审批通过的节点回退,注意这里用的是history而不是节点定义里的prev,因为条件分支下「上一个节点」不唯一,只能靠历史记录。record()统一写历史,驳回、追溯、审计都靠它。

参数上要留意:comment是审批意见,会进历史;formData里的字段名必须和 JSON 里assignee、expr引用的变量名一致,对不上就是undefined,条件求值会静默走错分支,这个坑后面单独说。

3.3 条件表达式求值:别直接上 eval

expression.js负责把days <= 3这种字符串算出布尔值。新手最容易图省事直接eval,能跑但埋雷。常见做法是用new Function包一层,或者写个极简解析器:

// utils/expression.js // 用 Function 构造求值函数,比 eval 作用域更可控 export function evaluate(expr, context) { try { const keys = Object.keys(context); const values = keys.map(k => context[k]); // 只把上下文变量注入,避免访问全局对象 const fn = new Function(...keys, `return (${expr});`); return Boolean(fn(...values)); } catch (e) { console.error(`表达式求值失败: ${expr}`, e); return false; // 求值失败默认不走该分支,防止误放行 } }

逻辑说明:把formData的键作为函数参数名、值作为实参传进去,表达式里就能直接用days、amount这些变量。用new Function而不是eval,是因为前者不会继承当前作用域的变量,注入面更小。求值失败返回false而不是抛出去,是为了让流程在脏数据下也能继续,但一定要打日志,否则条件写错了你根本不知道。参数上,context就是formData,expr来自流程定义,两者都不可信时(比如流程定义允许用户上传),就得换成白名单解析器,这是安全底线。

3.4 审批面板如何绑定当前节点

视图层ApprovalPanel.js的职责很单一:拿到引擎当前节点,渲染审批人、按钮、意见框,点通过就调workflow.next(),点驳回就调workflow.back()。典型写法:

// views/ApprovalPanel.js import { Workflow } from '../engine/Workflow.js'; import definition from '../definition/leave.json'; const wf = new Workflow(definition, { days: 5, leaderId: 'u1001' }); wf.start(); function render() { const node = wf.getCurrentNode(); document.querySelector('#nodeName').textContent = node.name; // 审批人从 formData 里按 assignee 变量名取 const assignee = node.assignee ? wf.formData[node.assignee] : ''; document.querySelector('#assignee').textContent = assignee || '系统自动'; } document.querySelector('#approve').onclick = () => { const comment = document.querySelector('#comment').value; wf.next(comment); render(); }; document.querySelector('#reject').onclick = () => { const comment = document.querySelector('#comment').value; wf.back(comment); render(); }; render();

这段代码的关键是「引擎状态驱动视图」:每次操作后重新render(),而不是手动改 DOM 状态。assignee用变量名去formData里取值,所以表单里必须有leaderId这个字段。如果你接后端,把formData换成接口返回的数据即可,引擎不用动。这也是前面强调引擎和视图分离的回报——换 UI 只改这一层。

4. 核心模块拆解:节点模型、历史记录与持久化

4.1 节点类型与扩展点设计

引擎里所有节点共用一个模型,靠type区分行为。这种「一个模型 + 类型分发」的设计,扩展新节点类型成本很低。比如要加一个「抄送节点」,只需在next()里加一个分支:

// 在 Workflow.next() 中扩展节点类型 switch (node.type) { case 'condition': nextId = resolveCondition(node); break; case 'cc': // 抄送节点:记录后自动往下走 this.record('cc', `抄送给 ${node.assignee}`); nextId = node.next; break; default: nextId = node.next; }

为什么用switch而不是给每个节点写一个类?因为审批流的节点行为差异很小,大多是「记录 + 取 next」,上类继承反而增加理解成本。真正需要独立逻辑的是条件节点和会签节点,这两个单独抽函数就够了。我一般会建议:节点类型超过 6 种、且每种都有独立校验逻辑时,再考虑策略模式重构,否则switch更直白。

4.2 历史记录与驳回的可靠性

驳回是审批流里最容易翻车的地方。前面back()依赖history倒查,这里有个隐含前提:历史必须完整且有序。如果中途有人手动改了currentNodeId却没记历史,驳回就会跳错节点。所以record()必须在每次状态变更时调用,且currentNodeId的赋值只能出现在start/next/back三个方法里。这是纪律问题,不是技术问题,但恰恰是二次开发时最容易破坏的约定。

历史记录的结构建议固定成{ nodeId, action, comment, time, operator },operator是当前操作人,审计要用。如果接后端,历史表就按这个结构建,前端只负责展示。别把历史塞进流程定义 JSON 里,定义是模板,历史是实例数据,混在一起后面做多实例就崩了。

4.3 持久化:内存、localStorage 还是后端

示例项目默认用内存存流程实例,刷新就没了。要落地必须持久化,三种选择:

方案适用场景注意点
内存演示、单次会话刷新丢失,不能用于生产
localStorage纯前端 Demo、离线工具容量有限,多标签页会冲突
后端接口真实审批系统需处理并发和幂等

我一般会先把Workflow实例序列化成{ definitionId, currentNodeId, formData, history }存起来,恢复时用Object.assign还原。注意definition不要跟着实例存,只存definitionId,恢复时重新加载定义,否则流程定义一改,老实例就带着旧定义跑了,这是版本管理的坑。

5. 避坑与排查:审批流落地时最容易踩的五个坑

5.1 条件分支静默走错

现象:明明days = 5,却走了days <= 3的分支。原因:formData里字段名是day而不是days,表达式求值时变量是undefined,undefined <= 3为false……等等,这里更隐蔽的是字符串比较,"5" <= 3会走类型转换。解决:求值前对formData做类型规整,数字字段强制Number(),并在evaluate里对undefined变量直接抛错而不是静默返回false。

5.2 驳回后历史错乱

现象:连续驳回两次,第二次跳到了错误的节点。原因:back()每次都在history里找最近一次approve,但驳回本身也记了历史,倒查逻辑没排除驳回记录。解决:倒查时过滤action === 'approve'且节点类型为approval的记录,或者单独维护一个「审批栈」,进栈出栈更清晰。

5.3 流程定义改了老实例跑不动

现象:流程加了一个节点,之前跑到一半的实例点「通过」报「没有下一节点」。原因:实例存的是旧定义快照或旧currentNodeId,新定义里节点next变了。解决:实例只存definitionId + 版本号,加载时按版本取定义;定义变更走新版本,老实例继续用老版本跑完。

5.4 会签节点被当成普通节点

现象:要求三个人都审批,结果第一个人点通过就往下走了。原因:next()对approval节点一律直接推进,没判断会签。解决:会签节点加mode: 'all'字段,维护一个approvedList,集齐才推进,否则只记录当前人通过。这个功能示例项目没内置,二次开发高频需求。

5.5 刷新页面流程丢失

现象:审批到一半刷新,回到初始节点。原因:只用了内存存储。解决:每次next/back后把实例序列化存 localStorage 或调后端保存接口,页面加载时先尝试恢复。注意恢复时要校验currentNodeId在新定义里是否还存在,不存在就提示流程已变更。

6. 二次开发:把示例引擎改成能上生产的审批流

示例跑通只是起点,真正落地要补三块:会签、动态审批人、流程版本。会签前面提了思路,核心是给节点加mode和approvedList,next()里判断是否集齐。动态审批人则是把assignee从「变量名」升级成「规则」:可以是{ type: 'role', value: 'manager' },运行时查组织架构解析成人。这块建议单独抽一个resolveAssignee(node, formData, orgService)函数,引擎只调不实现,保持核心干净。

流程版本管理我踩过最狠的一次:直接改线上流程定义 JSON,结果在途的 30 多单全部卡死。从那以后我每次改流程定义都强制走一遍「复制新版本 → 灰度验证 → 老实例锁旧版本」的流程。具体做法是定义文件按leave.v1.json、leave.v2.json命名,实例存definitionId + version,加载时精确匹配。验证方法也简单:写一个脚本,用历史表单数据把每个分支都跑一遍,断言最终节点符合预期,跑通了再发布。

// 流程定义回归脚本示例 import { Workflow } from '../src/engine/Workflow.js'; import def from '../src/definition/leave.v2.json' assert { type: 'json' }; const cases = [ { form: { days: 2 }, expect: 'hr' }, { form: { days: 7 }, expect: 'manager' } ]; cases.forEach(({ form, expect }) => { const wf = new Workflow(def, form); wf.start(); wf.next(); // 领导审批通过 const node = wf.getCurrentNode(); console.assert(node.id === expect, `期望 ${expect},实际 ${node.id}`); });

这个脚本每次改定义都跑一遍,比手点靠谱得多。参数上form要覆盖所有条件分支的边界值,expect写分支后的第一个节点 id。断言失败直接看是哪个分支的表达式写错了。

二次开发还有个小技巧:把Workflow类的事件回调留出来,比如onNodeChange、onHistoryAdd,视图层订阅这些事件而不是每次手动render(),后面接 WebSocket 推送待办也方便。引擎里加一行this.emit('nodeChange', node)的事,但能让整个项目从「示例」往「框架」靠一步。希望帮到你。

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