轻量工作流引擎
- # vue
- # workflow
- # state-machine
轻量工作流引擎
做过后台系统的同学一定不陌生——审批流、订单状态、多步向导,这些都是前端常见的多步骤流程。审核通过了要变成「已通过」,驳回了要能退回「草稿」重新提交,每一步还有各自的校验规则……
如果用 ref 加一串 if-else 来管理,状态一多就变成了状态管理的面条代码。改一个状态,到处找哪里写了 status.value === 'xxx'。
这篇文章分享一个轻量的思路:把状态流转逻辑从组件里抽出来,交给一个框架无关的工作流引擎来驱动。
问题长什么样
假设有这样一个审批流程:
- 草稿 → 提交审核 → 审核中
- 审核中 → 通过 / 驳回
- 驳回 → 修改重提 → 回到草稿
- 通过 → 发布
如果直接写在组件里,大致会变成这样:
// 典型的状态面条代码
const status = ref('draft')
const canSubmit = computed(() => status.value === 'draft')
const canApprove = computed(() => status.value === 'pending')
const canReject = computed(() => status.value === 'pending')
const canPublish = computed(() => status.value === 'approved')
function handleSubmit() {
if (status.value !== 'draft') return
if (!title.value) { error.value = '请填写标题'; return }
status.value = 'pending'
submittedAt.value = new Date().toLocaleString()
}
function handleApprove() {
if (status.value !== 'pending') return
if (!reviewer.value) { error.value = '请填写审核人'; return }
status.value = 'approved'
approvedAt.value = new Date().toLocaleString()
}
function handleReject() {
if (status.value !== 'pending') return
if (!reason.value) { error.value = '请填写驳回原因'; return }
status.value = 'rejected'
rejectedAt.value = new Date().toLocaleString()
}
function handleRevise() {
if (status.value !== 'rejected') return
status.value = 'draft'
}
function handlePublish() {
if (status.value !== 'approved') return
status.value = 'published'
publishedAt.value = new Date().toLocaleString()
}
每个操作都在重复同一件事:校验当前状态 → 检查条件 → 更新状态 → 执行副作用。每加一个状态或分支,都要新增 computed、新增函数、确保所有旧函数不会误触新状态。
核心思路
把流转拆成五个概念:
| 概念 | 职责 | 类比 |
|---|---|---|
| 状态 (state) | 流程当前所处的节点 | 有限状态机中的状态 |
| 转换 (transition) | 从一个状态到另一个状态的路径 | 状态机的边 |
| 事件 (event) | 触发转换的信号 | 用户操作 / 系统事件 |
| 守卫 (guard) | 转换前的条件检查 | 门卫 / 中间件 |
| 动作 (action) | 转换时的副作用 | 回调函数 |
引擎的工作流程:
事件触发 → 查找匹配转换 → 守卫检查 → 执行动作 → 更新状态 → 触发钩子
引擎实现
下面是完整的引擎类,约 130 行,零依赖:
/**
* 轻量工作流引擎 —— 框架无关的状态机核心
*
* 设计原则:
* 1. 引擎本身不依赖任何 UI 框架,所有副作用通过钩子和事件通知外部
* 2. 支持条件守卫(guard)、副作用(action)、动态目标状态
* 3. 链式 API,配置即代码
*/
export interface TransitionConfig<T extends string> {
from: T | T[]
to: T | ((ctx: Record<string, any>) => T)
event: string
/** 返回 true 放行,返回 string 作为拒绝原因 */
guard?: (ctx: Record<string, any>) => boolean | string
/** 转换时的副作用,返回值会合并到上下文 */
action?: (ctx: Record<string, any>) => Record<string, any> | void
}
export interface WorkflowHooks<T extends string> {
onEnter?: (state: T, ctx: Record<string, any>) => void
onExit?: (state: T, ctx: Record<string, any>) => void
onTransition?: (from: T, to: T, event: string, ctx: Record<string, any>) => void
onGuardFail?: (event: string, reason: string, ctx: Record<string, any>) => void
}
type HistoryEntry<T extends string> = {
from: T
to: T
event: string
time: number
}
export class WorkflowEngine<T extends string = string> {
private transitions: TransitionConfig<T>[] = []
private hooks: WorkflowHooks<T> = {}
private _current: T
private context: Record<string, any> = {}
private history: HistoryEntry<T>[] = []
private listeners = new Map<string, Array<(...args: any[]) => void>>()
constructor(initialState: T, context?: Record<string, any>) {
this._current = initialState
if (context) this.context = { ...context }
}
/** 注册状态转换(链式调用) */
addTransition(config: TransitionConfig<T>): this {
this.transitions.push(config)
return this
}
/** 批量注册 */
addTransitions(configs: TransitionConfig<T>[]): this {
configs.forEach(c => this.addTransition(c))
return this
}
/** 注册生命周期钩子 */
setHooks(hooks: WorkflowHooks<T>): this {
Object.assign(this.hooks, hooks)
return this
}
/** 发送事件,触发状态流转 */
send(event: string, payload?: Record<string, any>): { ok: boolean; error?: string } {
const matched = this.findTransition(event)
if (!matched)
return { ok: false, error: `当前状态 "${this._current}" 不支持事件 "${event}"` }
if (matched.guard) {
const result = matched.guard(this.context)
if (result !== true) {
const reason = typeof result === 'string' ? result : '条件不满足'
this.hooks.onGuardFail?.(event, reason, this.context)
this.emit('guardFail', { event, reason, context: this.context })
return { ok: false, error: reason }
}
}
const from = this._current
if (matched.action) {
const result = matched.action({ ...this.context, ...payload })
if (result && typeof result === 'object')
Object.assign(this.context, result)
}
if (payload)
Object.assign(this.context, payload)
const to = typeof matched.to === 'function'
? matched.to(this.context)
: matched.to
this.hooks.onExit?.(from, this.context)
this._current = to
this.history.push({ from, to, event, time: Date.now() })
this.hooks.onEnter?.(to, this.context)
this.hooks.onTransition?.(from, to, event, this.context)
this.emit('transition', { from, to, event, context: this.context })
return { ok: true }
}
/** 当前状态是否支持某事件 */
canSend(event: string): boolean {
return !!this.findTransition(event)
}
/** 获取当前可用事件列表 */
availableEvents(): string[] {
return this.transitions
.filter(t => {
const fromStates = Array.isArray(t.from) ? t.from : [t.from]
return fromStates.includes(this._current)
})
.map(t => t.event)
}
/** 订阅事件,返回取消函数 */
on(event: string, callback: (...args: any[]) => void): () => void {
if (!this.listeners.has(event))
this.listeners.set(event, [])
this.listeners.get(event)!.push(callback)
return () => {
const list = this.listeners.get(event)
if (list) {
const idx = list.indexOf(callback)
if (idx >= 0) list.splice(idx, 1)
}
}
}
/** 重置引擎 */
reset(state: T) {
this._current = state
this.context = {}
this.history = []
this.emit('reset', { state })
}
// ─── 查询 ─────────────────────────────────────
get current(): T { return this._current }
get ctx(): Record<string, any> { return { ...this.context } }
get snapshot(): HistoryEntry<T>[] { return [...this.history] }
// ─── 内部方法 ─────────────────────────────────
private findTransition(event: string): TransitionConfig<T> | undefined {
return this.transitions.find(t => {
if (t.event !== event) return false
const fromStates = Array.isArray(t.from) ? t.from : [t.from]
return fromStates.includes(this._current)
})
}
private emit(event: string, ...args: any[]) {
this.listeners.get(event)?.forEach(fn => fn(...args))
}
}
三个关键 API
addTransition(config) — 注册转换规则
声明一条转换规则:从哪个状态、到哪个状态、触发事件是什么。from 支持数组(多个源状态共享同一转换),to 支持函数(根据上下文动态决定目标状态)。guard 返回 string 时会被当作拒绝原因,返回 true 放行。
send(event, payload?) — 触发流转
引擎会查找当前状态下匹配该事件的转换,依次执行守卫检查、副作用、状态更新。返回 { ok, error? } 让调用方知道结果。这是引擎唯一的「运行时」入口。
setHooks(hooks) — 注册钩子
在状态进入(onEnter)、退出(onExit)、转换完成(onTransition)时执行回调。适合同步 UI 状态、打日志、触发动画等。onGuardFail 在守卫拒绝时触发,可以用来显示错误提示。
在 Vue 组件中使用
引擎是框架无关的,但需要在框架里「接线」。以下是关键步骤:
1. 创建引擎实例,注册转换规则
type State = 'draft' | 'pending' | 'approved' | 'rejected' | 'published'
const engine = new WorkflowEngine<State>('draft')
engine.addTransitions([
{
from: 'draft', to: 'pending', event: 'submit',
guard: () => form.title.trim() ? true : '请填写标题',
action: () => ({ submittedAt: new Date().toLocaleString() }),
},
{
from: 'pending', to: 'approved', event: 'approve',
guard: () => form.reviewer.trim() ? true : '请填写审核人',
action: () => ({ approvedAt: new Date().toLocaleString() }),
},
{
from: 'pending', to: 'rejected', event: 'reject',
guard: () => form.rejectReason.trim() ? true : '请填写驳回原因',
action: () => ({ rejectedAt: new Date().toLocaleString() }),
},
{
from: 'rejected', to: 'draft', event: 'revise',
},
{
from: 'approved', to: 'published', event: 'publish',
action: () => ({ publishedAt: new Date().toLocaleString() }),
},
])
转换规则是声明式的、集中的——一眼就能看清整个流程的所有合法路径。新增一个分支只需要加一条配置,不用去改已有的函数。
2. 注册钩子,同步 UI 状态
const current = ref<State>('draft')
engine.setHooks({
onTransition(from, to, event) {
current.value = to
},
onGuardFail(_event, reason) {
error.value = reason
setTimeout(() => error.value = '', 3000)
},
})
钩子是引擎和框架之间的桥梁。在 Vue 里操作 ref,在 React 里调 setState,引擎本身不需要知道。
3. 桥接按钮和引擎
const availableEvents = computed(() => engine.availableEvents())
function handleClick(event: string) {
engine.send(event, { ...form })
}
availableEvents() 返回当前状态下可用的事件列表,直接用来控制按钮的显示。引擎会在每次 send 后自动触发钩子链。
DEMO
下面是实际运行效果,点击按钮触发状态流转,观察表单字段和日志的变化: