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

发布时间:2026/10/25 16:43:55
纯前端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 条件表达式求值别直接上 evalexpression.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就是formDataexpr来自流程定义两者都不可信时比如流程定义允许用户上传就得换成白名单解析器这是安全底线。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表达式求值时变量是undefinedundefined 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和approvedListnext()里判断是否集齐。动态审批人则是把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)的事但能让整个项目从「示例」往「框架」靠一步。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询