
简介JS工作流与审批流程开发资源包面向需要构建Web端审批功能的前端及全栈开发者覆盖流程建模、任务分配、状态流转、审批意见处理等关键环节可帮助快速理解工作流引擎与状态机设计。压缩包内共49个文件约69KB以js脚本、html页面、xml流程定义为主另含aspx/cs服务端示例、css样式、演示图片等前端交互、流程配置与数据持久化均能对照学习。其中HTML页面负责展示审批操作界面XML文件配置流程节点与分支条件JS脚本处理交互和状态判断aspx/cs则演示服务端数据存取。已有1285人学习下载。资源包含可运行的审批页面与流程示例以及语言包、右键菜单、步骤移动等辅助脚本并附带服务端逻辑便于掌握前后端协同推进审批节点的方法。对于希望优化既有系统审批体验、实现流程回退与审计追踪的开发者是一份轻量而完整的实践素材。1. 用 JavaScript 自己搭一套审批流为什么说前端能独立承接提到 js 工作流与审批流很多团队的第一反应是上 Flowable、Activiti 这类后端工作流引擎前端只负责画页面。但真实业务里大量审批需求是“节点不超过 10 个、每天几百单、主要规则就是按条件找人”这种规模下用 JavaScript 自己实现一套审批流完全可行而且迭代成本会明显低于集成重型引擎。它不是一个低代码平台而是由流程定义 JSON、状态机引擎和拖拽设计器组成的前端审批流体系适合 B 端项目组、中后台产品团队以及想把“写死的审批代码”改成配置化的小团队。下面从设计模型开始一步步讲到能跑的引擎和踩坑排查。2. 审批流的前端设计模型状态机、节点表与条件路由2.1 用状态机映射审批状态六种状态与合法的状态迁移审批流最容易被低估的部分是状态。很多初版实现把状态写成字符串字段页面上到处if (status PENDING)等驳回、撤回、终止叠加进来状态就开始乱套。把状态收拢成状态机是所有设计的基础。我一般会预先定义六个状态状态码含义是否终态触发动作DRAFT发起人草稿否submitPENDING审批中否approve / reject / cancel / terminateAPPROVED已通过是无REJECTED已驳回否resubmitCANCELLED发起人撤回是无TERMINATED管理员终止是无考虑到会签会存在“需要多人逐个同意”的情况PENDING - APPROVED不能设计成“调一次性同意就完成”而是要由引擎内部的子任务完成度来决定。因此状态机里只描述宏观状态真正的审批移动逻辑放在节点层const STATE_MACHINE { DRAFT: { submit: PENDING }, PENDING: { reject: REJECTED, cancel: CANCELLED, terminate: TERMINATED }, REJECTED: { resubmit: PENDING }, APPROVED: {}, CANCELLED: {}, TERMINATED:{} }; function canTransition(current, action) { return Object.prototype.hasOwnProperty.call(STATE_MACHINE[current] || {}, action); }这段代码的要点是STATE_MACHINE里没有approve动作因为它不是宏观状态迁移条件。会签节点可能需要 5 个人逐一操作只有最后一个子任务完成才把实例从PENDING推入APPROVED。把所有非法迁移掐死在状态机层界面上的按钮显隐、接口的权限校验都可以直接基于canTransition生成不会出现“某个按钮该出现但没出现”的玄学问题。2.2 节点类型与动作拆分审批、抄送、条件分支、会签怎么建模流程定义里节点类型不宜多常见的四类即可覆盖绝大多数 OA 审批需求加一个起止节点节点类型含义典型配置出口start流程发起发起人固定 nextapproval审批指定人/角色/表单字段选人同意后走 next驳回即终态cc抄送被抄送人列表自动走 nextcondition条件分支conditions 数组 defaultNext按规则挑一条边end结束无无节点上的动作也要拆成枚举不能把逻辑散到按钮的 onClick 里。我一般固定为六个submit、approve、reject、transfer、cancel、terminate再加一个urge催办动作它不改变状态只发通知。会签和或签这里要说得细一点不要把会签建模成“一个人审批后生成一条新链路”那样会签汇聚时会算错。正确做法是把会签当成一个节点的一张子任务表每个审批人生成一个子任务所有人完成才推进。这个模型在后面实现approve时会反复出现。2.3 用 JSON 描述流程一份可执行的工作流编码 Schema相比 BPMN 2.0 的 XML 和完整规范前端团队更适合用一份精简 JSON 表达流程这就是把业务工作流编成一组纯数据前后端、设计器、执行引擎之间只认这份 JSON。我给请假审批定义的最小 Schema 长这样{ flowKey: leave, name: 请假审批, fields: [ { name: leaveType, label: 请假类型, type: select }, { name: days, label: 请假天数, type: number }, { name: reason, label: 请假原因, type: textarea } ], nodes: [ { id: start, type: start, next: leader }, { id: leader, type: approval, assignee: { type: role, key: leader }, next: checkDays }, { id: checkDays, type: condition, conditions: [ { field: days, op: , value: 3, next: hr } ], defaultNext: end }, { id: hr, type: approval, assignee: { type: role, key: hr }, next: end }, { id: end, type: end } ] }这份 JSON 的设计取舍是所有节点都用“固定 next 条件覆盖特殊路径”的方式而不是完整的有向图边集合。好处是大部分审批流程是树状或单链的读起来直观坏处是遇到并行分支会暴露短板后面会讲会签问题。字段fields是给表单渲染用的引擎执行时只读nodes这样表单改版不会污染流程逻辑。注意条件路由的conditions数组顺序就是优先级第一条命中即生效所有条件都不命中才走defaultNext。不要设计隐式“最后一个 else”规则让流程定义在界面上就能看懂。3. 实现最小可跑的 JS 审批流引擎状态机驱动、分支解析与 SDK 封装3.1 初始化流程实例从流程定义创建待办的第一步引擎的对外 API 越少越好至少能覆盖创建、审批、撤回、催办四件事。先看createInstance它接收流程定义和表单数据生成一个流程实例并落到数据层class ApprovalEngine { constructor({ flowDef, dataStore }) { this.flowDef flowDef; this.dataStore dataStore; // 抽象数据层只需 get / save } async createInstance(formData, starter) { const now Date.now(); const instance { id: generateId(inst), flowKey: this.flowDef.flowKey, status: PENDING, currentNodeId: null, formData, starter, logs: [], createdAt: now, updatedAt: now }; const first this._nextNode(this.flowDef.nodes.find(n n.type start).id, formData); instance.currentNodeId first.id; await this._createTodo(instance, first); await this.dataStore.save(instance); return instance; } }这个方法的逻辑是实例创建后先把状态置成PENDING然后用_nextNode解析 start 节点后面的真实首个节点给它生成待办。currentNodeId不是 start 的 id而是第一个真正要处理的人的节点 id这样前端拿到的数据永远可以渲染成“当前待办是谁”。参数说明formData是表单提交原始数据不要和流程实例字段混在一起后续条件分支要读它dataStore抽象成 get/save 两个方法是为了把 localStorage、IndexedDB、后端接口或内存数组都当成同一个存储实现。演示场景里可以用 localStorage但生产环境如果纯前端跑建议至少走 IndexedDB 或服务端接口持久化否则清缓存等于丢流程。3.2 核心审批动作同意、驳回、转交与轨迹记录approve是引擎最核心的方法。它要解决三件事检查操作人是否有权限、记录审批轨迹、根据动作决定节点移动方向。async approve(instanceId, operator, action, comment , transferTo null) { const instance await this.dataStore.get(instanceId); const node this._findNode(instance.currentNodeId); const assignee this._resolveAssignee(node, instance); if (assignee.type person assignee.id ! operator) { throw new Error(非当前处理人不能审批); } instance.logs.push({ at: Date.now(), nodeId: node.id, operator, action, comment }); instance.updatedAt Date.now(); if (action reject) { instance.status REJECTED; instance.currentNodeId null; await this.dataStore.save(instance); return { ok: true, terminal: REJECTED }; } if (action transfer) { const targetId transferTo || comment.replace(/^/, ); node.assignee { type: person, id: targetId }; await this._createTodo(instance, node); await this.dataStore.save(instance); return { ok: true, node: node.id }; } const next this._nextNode(node.id, instance.formData); if (next.type end) { instance.status APPROVED; instance.currentNodeId null; } else { instance.currentNodeId next.id; await this._createTodo(instance, next); } await this.dataStore.save(instance); return { ok: true, next: next.id }; }逻辑说明reject直接进入终态并清空当前节点因为业务上驳回后要么发起人重新提交要么作废不会继续往下走transfer把当前节点的 assignee 改掉再重新生成一次待办正常情况下approve走_nextNode找下游。参数说明action只接收固定枚举界面按钮不要传自由字符串transferTo独立成参数而不是从 comment 里解析是为了避免审批意见里出现userId这种需要二次解析的格式。会签节点的多人处理不在这个方法里展开它会在后续子任务模型处理。3.3 条件分支解析比较运算符、函数分支与默认出口兜底_nextNode的实现决定了流程能不能正确到达下一个审批人。条件节点的 rule 不是if/else写死而是由配置数据驱动_nextNode(nodeId, formData) { const node this._findNode(nodeId); if (node.type condition) { for (const cond of node.conditions) { if (this._match(cond, formData)) { return this._findNode(cond.next); } } return this._findNode(node.defaultNext); } return this._findNode(node.next); } _match(cond, formData) { if (cond.op func typeof cond.handler function) { return cond.handler(formData); } const actual formData[cond.field]; switch (cond.op) { case : return String(actual) String(cond.value); case : return Number(actual) Number(cond.value); case : return Number(actual) Number(cond.value); case in: return cond.value.includes(actual); default: return false; } }这里要强调条件分支的执行顺序conditions数组按优先级从上到下匹配第一条命中的生效全都不命中时defaultNext是最后的兜底不能省略。实际项目里常见一个坑是只配置了条件边没配默认边流程运行时直接抛“找不到下一个节点”所以要养成在流程定义校验阶段就强制检查defaultNext的习惯。op func是我比较喜欢留的口子当常规比较满足不了比如“请假日期跨过节假日”或“金额包含在多个区间”流程定义里挂一个函数名或函数引用由前端实现避免为了一个特殊情况改引擎结构。3.4 封装成前端 SDK在 React 与 Vue 里只暴露四个方法引擎本身不依赖任何框架接入层把引擎实例化和四个业务动作暴露给组件即可。以 React 为例// approvalSdk.js let engine null; export function initApproval(config) { engine new ApprovalEngine(config); } export function useApproval() { return { submit: (formData) engine.createInstance(formData, currentUser()), approve: (instanceId) engine.approve(instanceId, currentUser(), approve), reject: (instanceId, comment) engine.approve(instanceId, currentUser(), reject, comment), cancel: (instanceId) engine.approve(instanceId, currentUser(), cancel) }; }接入 Vue 时可以用provide/inject把同一个实例下发逻辑完全一致。核心原则是流程实例的状态永远在引擎里组件只负责把用户动作传进去。很多人把 instance 塞进组件 state单据换个页面打开状态就丢了这是审批流项目最常见的架构性翻车点。提示不要在组件里new ApprovalEngine()。一个流程定义对应一个引擎实例应用启动时初始化一次跨页面共享否则待办列表和详情页会各持一份状态互相覆盖。4. 给审批流装上可视化设计器拖拽编排、JSON 序列化与流程预览4.1 手写 JSON、自研设计器、开源图形库怎么选流程定义如果只给开发维护手写 JSON 尚且能接受但审批流的维护者往往是产品甚至行政就必须有可视化设计器。常见做法有三种方案优点缺点适用手写 JSON零依赖、可控非技术人员无法维护流程固定且不常改自研拖拽设计器完全可控、交互贴合业务开发量中等节点类型会持续增加引入开源前端图形库拖拽、连线、画布现成样式和语义要二次封装团队人力不足或场景标准如果团队从零开始我的建议是先别急着引完整 BPMN 建模器BPMN 的网关、事件、泳道概念对审批业务是过度设计反而让非技术维护者困惑。自研一个“节点面板 画布 属性侧栏”的迷你设计器通常只需要 5 到 7 天。4.2 最小可跑的拖拽实现节点落画布、连线与属性面板拖拽的关键是区分“从面板拿节点类型”和“在画布上创建节点”两个阶段。用 HTML5 原生拖放就能跑通const paletteItems document.querySelectorAll([draggabletrue]); paletteItems.forEach(item { item.addEventListener(dragstart, e { e.dataTransfer.setData(nodeType, item.dataset.nodeType); }); }); canvas.addEventListener(dragover, e e.preventDefault()); canvas.addEventListener(drop, e { e.preventDefault(); const type e.dataTransfer.getData(nodeType); const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; const node createNode(type, x, y); designerModel.nodes.push(node); renderNode(node); });这里最容易踩的坑是坐标偏移drop事件的clientX/clientY是相对视口的必须减去画布本身的getBoundingClientRect().left/top否则节点会随着页面滚动位置乱跑。如果画布内部还有缩放或滚动坐标还要再除以缩放比例。连线的实现可以用 SVG 在两个节点之间画箭头记录source和target两个节点 id不要在连线上保存坐标坐标只属于节点。属性侧栏在选中节点时展示对应配置审批节点填审批人类型、条件节点填条件和出口、抄送节点填抄送人。所有配置先写进节点的config字段后续序列化阶段再提取成引擎要的结构。4.3 从设计器到引擎可运行 JSON清理坐标与还原条件出口设计器数据不能直接交给引擎执行。它里面有x/y、宽高、样式这类渲染信息还有连线的坐标点位引擎只关心节点类型、审批人和分支规则。所以要做一次清洗转换我习惯用toEngineDef负责这件事function toEngineDef(designerJson) { const edges designerJson.edges; const nodes designerJson.nodes.map(n { const outEdges edges.filter(e e.source n.id).map(e e.target); const base { id: n.id, type: n.type }; if (n.type condition) { return { ...base, conditions: n.config.conditions.map(cond ({ ...cond, next: cond.next })), defaultNext: n.config.defaultNext || outEdges[0] }; } return { ...base, assignee: n.config.assignee, next: outEdges[0] }; }); return { flowKey: designerJson.flowKey, name: designerJson.name, nodes }; }这个函数的边界条件值得讲讲条件节点的conditions里每一项的next是产品在侧栏手选的出口和连线的 target 可能不一致转换时以config里的显式配置优先普通节点的next取当前节点唯一出线。如果一个普通审批节点画出两条线这里就会因为outEdges[0]只取第一条而产生隐患所以设计器本身要限制普通节点只能有一条出线条件节点要求连线数量等于条件数加默认数。完成这一步后之前第 2.3 节的 JSON Schema 就是设计器落库的标准产物。设计器可以存在草稿状态流程发布时再执行toEngineDef避免“画到一半的流程被线上实例读到”。4.4 审批前预览当前节点高亮与历史轨迹渲染流程预览不是静态图。用户在待办列表打开一张单子要能一眼看到流程走到哪一步、下一步是谁。常见做法是根据实例的currentNodeId和logs渲染节点状态function renderFlowStatus(flowDef, instance) { const doneNodeIds new Set(instance.logs.map(log log.nodeId)); return flowDef.nodes.map(node { const isCurrent node.id instance.currentNodeId; const isDone doneNodeIds.has(node.id); return { id: node.id, type: node.type, cls: isCurrent ? current : isDone ? done : todo, history: instance.logs.filter(log log.nodeId node.id) }; }); }renderFlowStatus返回的节点数组可以直接驱动任何组件渲染。点击某个节点时把该节点的history展示为“谁、什么时间、同意或驳回、意见是什么”。当前节点用明显颜色高亮已完成节点显示对勾或半透明未到达节点灰色。如果流程预览嵌入的是 iframe 页面可以用window.postMessage把“点击节点查看轨迹”的事件抛给父页面父页面再去打开单据详情不需要把整张大图做成业务组件。注意高亮的是“当前节点”不是“当前节点的人”。会签节点会有多个人在处理中节点状态仍然是current此时应把子任务里已完成的人数列到节点气泡上而不是把节点标记成完成。5. 审批流常见翻车现场5 个资深前端都踩过的状态、条件与汇聚坑5.1 用本地时间判断超时改一次系统时间流程就乱跳现象审批节点设了 48 小时超时自动转交给上级前端用setTimeout或Date.now()计算结果。某位同事把电脑系统时间往后改了几天待办列表里出现大量“已超时转交”的记录流程状态和实际处理时间完全对不上。原因审批流是有状态约束的业务时间必须是统一权威来源。浏览器本地时间可以因用户改时区、调系统时间、休眠唤醒而漂移任何依赖它的状态迁移都不可靠。解决所有超时判断改由服务端时间戳驱动。前端收到待办时记录下serverTime轮询或每次操作都用服务端时间纯前端落地方案也要把“当前时间”抽象成一个timeProvider()函数统一从接口或可信时钟源取而不是散落各处的new Date()。5.2 表单字段改名后历史审批单的条件全失灵现象上线三个月后产品把表单里的“请假天数”从days改成了duration正在审批的单子走到条件节点时全部默认走defaultNext该送人事审批的没送。原因流程定义里的condition.field是硬编码字符串字段重命名不会触发同步。这种事等线上单子出问题才被发现因为历史实例的formData里存的是旧字段名。解决表单字段引入fieldCode作为持久层标识展示名随意换code 一经发布不允许修改。流程定义和实例表单都引用fieldCode引擎匹配时不碰label。已经上线并埋了雷的旧实例写一个迁移脚本扫描所有未完成的流程实例把days映射回duration再落到formData一次跑完不留存量。5.3 驳回后重新提交直接复用旧实例状态和轨迹全乱现象单据被驳回发起人修改后再次提交代码直接复制原 instance 把 status 从REJECTED改回PENDING。界面显示正常但历史日志里保留着一条reject记录审计时说不清这次到底是通过还是驳回过。原因把“流程实例”当成了“一次业务单据”。一张请假单可以有多次审批生命周期每次重新提交意味着一个新的执行回合原实例的状态语义已经被污染。解决给 instance 加revision字段每次重新提交revision 1旧日志通过revision分段保存或者坚持“驳回即重新发起新实例”用业务单号把多段实例串起来。我一般选前者因为用户可以继续查看同一张单据的完整修改历史体验更好但痕迹结构必须按revision分开渲染。5.4 会签节点五个人全同意流程却不推进了现象会签节点配置了 5 名审批人5 个人先后都点了同意节点气泡里的计数也显示 5/5但流程就是停在原地没有任何报错。原因这是最经典的汇聚判断错误。把会签实现成“每个人各自 create 一条子链路”或者“所有人的操作都覆盖同一个 count 字段”最后一个人写入时要么没触发完成逻辑要么并发写把计数覆盖掉了。会签节点的推进条件不是“有人同意”而是“所有子任务全部完成”。解决把会签节点的处理人改为子任务集合每个子任务有自己的approver / status / handledAt。节点完成判定改为“查看所有子任务的 status 是否全部等于 agreed”全部完成才调用节点的_nextNode。前端要注意幂等第 5 个子任务完成时节点推进逻辑只能执行一次。可以给 instance 加一个processedAction标记处理完立刻落库防止重复触发。5.5 撤销与终止共用一个终态单子死在半路没人追责现象发起人想撤回自己的申请页面按钮写的是“终止”管理员也想“终止”一张异常单两个动作共用TERMINATED状态。事后查历史分不清单子是主动撤回还是被干预终止业务方互相甩锅。原因状态模型里偷懒把语义不同的两个终态合并成一个按钮的权限也没有按角色收敛。撤销是发起人的权利终止是管理员的处置权它们应该从状态、权限和轨迹三个层面隔离。解决状态拆分CANCELLED与TERMINATEDcancel动作校验操作人是发起人且当前节点尚未被处理terminate动作要求管理员身份且必填终止原因。前端按钮显隐用第 2 章的状态机迁移表自动生成而不是写死在组件里这样以后新增终态也不会漏改按钮。6. 给审批流做体检状态机单测、轨迹回放与幂等提交审批流这类组件测试收益极高因为它是纯状态逻辑。至少覆盖四类用例状态迁移合法性、条件路由是否命中正确出口、会签汇聚是否只推进一次、同一节点重复操作是否被幂等拦截。测试场景输入期望结果正常审批链days1 提交最终得到 APPROVED条件分支命中days5 提交经过 hr 节点后 APPROVED会签汇聚5 人逐一同意第 5 人操作后恰好推进一次幂等重复同一 operateKey 重复 approve第二次直接返回当前实例轨迹回放是排错利器。instance.logs 本身就是一条完整时间线我一般会做一版只读的“流程回放”视图按时间把“谁在哪个节点执行了哪个动作、带了什么意见”渲染成列表。线上单子出问题时打开回放就能定位是不是条件设计错了而不是去翻数据库。幂等提交值得单独写一句。审批按钮很容易被用户双击接口慢时重试也会重复提交。在 approve 入口检查instance.processedAction opKey命中就直接返回实例不执行逻辑。这个opKey每次客户端操作生成一次写入时和实例一起落库比在按钮上做 disabled 更可靠。我最后的习惯是先把状态机画在白板上再写流程 JSON最后才碰界面。有一次线上超时异常查了三小时发现是浏览器休眠挂起了计时器从那以后所有和状态相关的判断都收进引擎界面只做展示和触发。希望帮到你。本文还有配套的精品资源点击获取