← 返回文章列表

表单联动规则引擎

前端
  • # vue
  • # rule-engine
  • # form-state

表单联动规则引擎

做过后台系统的同学应该都有体会——表单联动是前端最繁琐的事情之一。选了 A 字段,B 字段要显示;B 选了某个值,C 又要切换成多选模式……几个字段下来,v-ifwatch 堆成面条代码,改一处牵一片。

这篇文章分享一个轻量的思路:把联动逻辑从组件里抽出来,交给一个框架无关的规则引擎来驱动


问题长什么样

假设有这样一个筛选表单:

  1. 选择「过滤类型」→ 根据类型显示不同字段
  2. 选择「操作符」(等于/大于/…)→ 切换输入模式(单值 or 多选标签)
  3. 每种类型对应的参数 key、单位、placeholder 都不一样

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

// 典型的面条代码
watch(action, (val) => {
  if (val === 'system') {
    showSystem.value = true
    showOperator.value = false
  } else if (val === 'pay') {
    showOperator.value = true
    currentUnit.value = '元'
    currentPlaceholder.value = '请输入金额'
  } else if (val === 'role_level') {
    showOperator.value = true
    currentUnit.value = '级'
    currentPlaceholder.value = '请输入角色等级'
  }
  // ... 更多分支
})

watch(operator, (val) => {
  if (val === 'eq' || val === 'neq') {
    showMultiSelect.value = true
    showSingleInput.value = false
  } else {
    showMultiSelect.value = false
    showSingleInput.value = true
  }
})

每加一个字段或条件,都要钻进 watch 里加 if-else。测试的时候漏改一个分支就是 bug。


核心思路

把联动拆成三个概念:

概念职责类比
字段 (field)被监听的表单字段事件源
规则 (rule)字段值变化时执行的策略函数回调 / watcher
动作 (action)策略函数派发的具体操作命令

引擎的工作流程:

字段值变化 → 中间件检查 → 匹配规则 → 策略函数执行 → 派发动作 → 处理器执行副作用

引擎实现

下面是完整的引擎类,不到 120 行,零依赖:

/**
 * 表单联动规则引擎 —— 框架无关的核心类
 *
 * 设计原则:
 *   1. 引擎本身不依赖任何 UI 框架,所有副作用通过 onAction 注册的处理器派发
 *   2. 策略函数接收 (value, formState, dispatch),通过 dispatch 派发联动动作
 *   3. 同一字段可注册多条规则,按注册顺序依次执行
 */
export class FormRuleEngine {
  private rules = new Map<
    string,
    Array<
      (
        value: any,
        state: Record<string, any>,
        dispatch: FormRuleEngine['dispatchAction'],
      ) => void
    >
  >()
  private actionHandlers: Record<
    string,
    (targetField: string, payload: any, engine: FormRuleEngine) => void
  > = {}
  private middlewares: Array<
    (
      field: string,
      value: any,
      prev: any,
      state: Record<string, any>,
    ) => boolean | void
  > = []

  formState: Record<string, any> = {}

  /** 注册联动规则(同一字段可注册多条) */
  registerRule(
    field: string,
    strategyFn: (
      value: any,
      state: Record<string, any>,
      dispatch: FormRuleEngine['dispatchAction'],
    ) => void,
  ) {
    const list = this.rules.get(field) || []
    list.push(strategyFn)
    this.rules.set(field, list)
  }

  /** 注册动作处理器 */
  onAction(
    actionType: string,
    handler: (
      targetField: string,
      payload: any,
      engine: FormRuleEngine,
    ) => void,
  ) {
    this.actionHandlers[actionType] = handler
  }

  /** 注册中间件(在策略执行前执行,返回 false 中断) */
  use(
    middleware: (
      field: string,
      value: any,
      prev: any,
      state: Record<string, any>,
    ) => boolean | void,
  ) {
    this.middlewares.push(middleware)
  }

  /** 统一的状态更新入口,内部触发联动链条 */
  updateField(field: string, value: any) {
    const prev = this.formState[field]
    this.formState[field] = value

    for (const mw of this.middlewares) {
      if (mw(field, value, prev, this.formState) === false) {
        return
      }
    }

    const strategies = this.rules.get(field)
    if (strategies) {
      for (const fn of strategies) {
        fn(value, this.formState, this.dispatchAction.bind(this))
      }
    }
  }

  /** 派发联动动作 */
  dispatchAction(targetField: string, actionType: string, payload?: any) {
    const handler = this.actionHandlers[actionType]
    if (handler) {
      handler(targetField, payload, this)
    } else {
      console.warn(`[RuleEngine] 未注册的动作类型: ${actionType}`)
    }
  }

  /** 批量更新 */
  batchUpdate(updates: Record<string, any>) {
    for (const [field, value] of Object.entries(updates)) {
      this.updateField(field, value)
    }
  }

  /** 重置状态 */
  reset() {
    this.formState = {}
  }
}

三个关键 API

registerRule(field, strategyFn) — 注册规则

字段值变化时,引擎会找到该字段对应的所有策略函数,依次执行。策略函数签名是 (value, state, dispatch),你可以读取全局状态,也可以通过 dispatch 派发动作。

onAction(actionType, handler) — 注册动作处理器

策略函数不直接操作 DOM 或状态,而是通过 dispatch 派发一个动作字符串。处理器负责执行真正的副作用(比如显示/隐藏字段)。这样做的好处是:引擎和 UI 框架解耦——在 Vue 里你可以操作 reactive,在 React 里你可以 setState,引擎本身不需要知道。

use(middleware) — 中间件

中间件在策略执行之前运行,返回 false 可以中断整条链。适合做日志、权限检查、或者「值没变就不触发」之类的短路逻辑。


在 Vue 组件中使用

引擎是框架无关的,但需要在框架里「接线」。以下是关键步骤:

1. 创建引擎实例,注册动作处理器

const engine = new FormRuleEngine()

// 动作:显示/隐藏字段 → 操作 Vue reactive 对象
engine.onAction('SHOW', (target: string) => {
  visible[target] = true
})
engine.onAction('HIDE', (target: string) => {
  visible[target] = false
})
engine.onAction('HIDE_ALL', () => {
  for (const key in visible) visible[key] = false
})
// 动作:动态切换字段配置
engine.onAction('SET_FIELD', (_, config) => {
  Object.assign(currentField, config)
})

2. 用声明式的方式注册规则

// 规则:action 变更 → 显隐字段
engine.registerRule('action', (val, _state, dispatch) => {
  dispatch('', 'HIDE_ALL')

  if (val === 'system') {
    dispatch('system', 'SHOW')
    return
  }

  const def = numericFieldDefs[val]
  if (def) {
    dispatch('operator', 'SHOW')
    dispatch('', 'SET_FIELD', { ...def })
  }
})

// 规则:operator 变更 → 切换输入模式
engine.registerRule('operator', (val, _state, dispatch) => {
  dispatch('valueInput', 'HIDE')
  dispatch('valueMultiSelect', 'HIDE')

  const isEq = val === 'eq' || val === 'neq'
  dispatch(isEq ? 'valueMultiSelect' : 'valueInput', 'SHOW')
})

对比最开始的 watch + if-else 面条代码,规则的声明是扁平的、追加式的——新增一个联动只需要加一条 registerRule,不用去改已有的逻辑。

3. 桥接 v-model 和引擎

function onActionChange() {
  engine.updateField('action', filter.value.action)
}

function onOperatorChange() {
  engine.updateField('operator', filter.value.parameters.operator)
}

在模板里把 @change 绑到这些桥接函数就行,引擎会自动触发规则链。


DEMO

下面是实际运行效果,选择不同的过滤类型和操作符,观察表单字段的联动变化: