← 返回文章列表

轻量工作流引擎

前端
  • # vue
  • # workflow
  • # state-machine

轻量工作流引擎

做过后台系统的同学一定不陌生——审批流、订单状态、多步向导,这些都是前端常见的多步骤流程。审核通过了要变成「已通过」,驳回了要能退回「草稿」重新提交,每一步还有各自的校验规则……

如果用 ref 加一串 if-else 来管理,状态一多就变成了状态管理的面条代码。改一个状态,到处找哪里写了 status.value === 'xxx'

这篇文章分享一个轻量的思路:把状态流转逻辑从组件里抽出来,交给一个框架无关的工作流引擎来驱动


问题长什么样

假设有这样一个审批流程:

  1. 草稿 → 提交审核 → 审核中
  2. 审核中 → 通过 / 驳回
  3. 驳回 → 修改重提 → 回到草稿
  4. 通过 → 发布

如果直接写在组件里,大致会变成这样:

// 典型的状态面条代码
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

下面是实际运行效果,点击按钮触发状态流转,观察表单字段和日志的变化: