从零做一个可视化规则引擎:Vue3 递归条件树 + 双引擎结果对比

从零做一个可视化规则引擎:Vue3 递归条件树 + 双引擎结果对比

面向有 Vue 基础、想了解规则引擎落地的开发者。全文约 8000 字,含 4 张流程图与 7 张实现截图。

业务规则一变,就要找开发改代码、重新发版------这个场景在电网、金融、IoT 这些行业里每天都在发生。这次我们花时间做了一件事:把规则的编写权从开发手里交还给业务人员。

成品是一个可视化规则编辑器:左侧编排条件、右侧实时看生成的 JSON 和执行结果,规则改完立刻能验证对不对。技术栈是 Vue 3 + TypeScript + Vite + Pinia + Element Plus。

这篇文章不讲空泛的架构,只讲我在实现过程中真正卡过的那些地方和最后选择的解法。如果你也要做一个类似的东西,希望能帮你少踩几个坑。


目录


一、先想清楚一件事:前端到底输出什么

这是整个项目最关键的一个决策,做错了后面全是返工。

我们后端用的是 Drools。最初的想法很自然:前端拼 DRL 字符串,直接丢给后端执行。

想了两天放弃了。原因是 DRL 的语法会随 Drools 版本演进,把生成逻辑放前端等于让两端一起维护同一套语法;更麻烦的是前端根本没法本地执行 DRL 验证,写完只能是"盲发"。

最后定下的方案是:

前端只产标准 JSON,DRL 的转换和执行全部交给后端。

这个方案还有个额外的好处------前端可以选择一个 JSON 规则引擎做本地预览,编辑时立刻能看到结果,不用等后端。我们选了 json-rules-engine-upgraded。

于是整条链路变成这样:

① 内部编辑模型 → ② 纯函数转换器 → ③ 引擎原生 JSON,然后这份 JSON 同时喂给浏览器本地求值器和后端 DoorLS。

关键在于第 ③ 步的产出必须是引擎原生结构,不是我们自定义的中间格式。也就是说:

jsonc 复制代码
{
  "name": "严重告警立即升级",
  "priority": 10,
  "conditions": {
    "all": [
      { "fact": "alarmLevel", "operator": "equal", "value": "严重" },
      {
        "any": [
          { "fact": "alarmValue", "operator": "greaterThan", "value": 50 },
          { "fact": "duration", "operator": "greaterThanInclusive", "value": 60 }
        ]
      },
      { "not": { "fact": "isTestDevice", "operator": "equal", "value": true } }
    ]
  },
  "event": { "type": "upgrade", "params": { "channel": "短信+电话" } }
}

这份 JSON 拿去 new Engine(rules).addRule() 是零转换能直接加载的。后端将来转 DRL 也只需要认这一套结构,不用再理解一层我们发明的方言。

这里有个建议:在选择方案前,先拿真实数据把两条路都写一遍。 我们当时把一条"220kV 主变过载保护"规则分别用 JSON 和 DRL 描述了一遍,DRL 版本要处理包声明、import、salience、规则名唯一性,还要考虑 dialect------而 JSON 版本一行 all 就完事了。这个对比让决策变得毫无悬念。


二、条件树:递归模型 + 不可变更新

规则条件的本质是一棵可以无限嵌套的树。叶子是条件,枝干是 AND / OR / NOT 分组。

数据结构直接照着最终 JSON 设计:

ts 复制代码
type ConditionNode = LeafCondition | ConditionGroup

interface LeafCondition {
  id: string
  fact: string          // 字段名
  path?: string         // JSONPath 子路径,可选
  operator: OperatorKey
  value: unknown
}

interface ConditionGroup {
  id: string
  logic: 'all' | 'any' | 'not'
  children: ConditionNode[]
}

两个模型之间的映射关系是完全同构的:

一个很容易被忽略的细节:编辑态和输出态必须是两套模型 。编辑态带 id(要用它定位节点做增删改),输出态不能带任何 UI 字段。

所以我们写了一个纯函数 toRuleSetJSON(),负责剥离全部 id、把 logic/children 递归翻译成 all/any/not、剔除空组。反向的 fromJSON() 用于文件导入,递归补回 id。

因为是个纯函数,它非常好测------输入一棵树,输出一份 JSON,没有副作用。这里也顺手埋了个持续验证:把输出的 rules 过滤掉禁用项后喂给真实的 Engine,命中集合必须和我们的求值器完全一致。这把"引擎能零转换加载"从一句口头约定变成了每次 CI 都会跑的测试。

树的增删改我用的是结构共享的不可变更新。比如删除一个节点,只沿命中路径重建节点,没被触碰的子树直接返回原引用:

ts 复制代码
function removeIn(group: ConditionGroup, id: string): ConditionGroup {
  let changed = false
  const nextChildren: ConditionNode[] = []
  for (const child of group.children) {
    if (child.id === id) { changed = true; continue }
    if (isGroup(child)) {
      const next = removeIn(child, id)
      if (next !== child) changed = true
      nextChildren.push(next)
    } else nextChildren.push(child)
  }
  return changed ? { ...group, children: nextChildren } : group
}

这样 Vue 的依赖追踪更精准,撤销栈里存的快照也不会共享可变引用。

实际渲染出来的条件树长这样------注意每层组卡片左侧的色条会随层级换色,深嵌套时一眼能看出自己在哪一层:

规则集本身的配置(名称、描述、冲突策略)在顶部一个横条里:


三、NOT 组只能有一个孩子,但用户不会乖乖听话

JSON 引擎的 not 语义是单操作数 的:{ not: 某个条件 },不是数组。但用户在界面上点两下就很容易往 NOT 组里塞进第二个条件。

两种处理方式:报错拦住,或者自动适配。我们选了后者------当 NOT 组已经有 1 个孩子、用户又点"添加条件"时,自动把两个条件包裹进一个 AND 子组,再加提示告诉用户"NOT 对整体取反"。

ts 复制代码
export function addChildSmart(group: ConditionGroup, child: ConditionNode): ConditionGroup {
  if (group.logic === 'not' && group.children.length > 1) {
    throw new Error(`NOT group must have at most one child, got ${group.children.length}`)
  }
  if (group.logic !== 'not' || group.children.length === 0) {
    return { ...group, children: [...group.children, child] }
  }
  // 自动包裹
  const wrapped = createGroup('all', [group.children[0], child])
  return { ...group, children: [wrapped] }
}

界面上的交互成本为零,输出的 JSON 结构依然合法。

深嵌套时用户很容易迷路,所以每个子组顶部有一条吸顶的面包屑,滚动时会一直贴在容器顶部:

这里有个设计原则值得展开说:约束应该内化,而不是弹给用户。 我们一开始的做法是弹出提示"NOT 组只允许一个条件",结果测试时发现业务人员会反复触发这个错误,然后卡在那里不知道怎么办。改成自动包裹之后,同样的操作路径变成了"点什么就是什么",用户完全不需要知道 JSON 里 not 是单操作数的。


四、调试期最痛的问题:我想要看到每一个条件的结果

这一节是整篇文章里我最想分享的部分。

json-rules-engine 的 engine.run(facts) 只返回 { passed: true }------它短路求值。AND 组遇到第一个 false 就停了,后面的条件压根不求值。

但用户需要的是"为什么这条规则没命中",也就是要看到每一个叶子条件的真假。短路求值给不了这个信息。

试过用引擎的 onSuccess / onFailure 钩子,还是绕不开短路;也想过直接改引擎源码,但那样就丧失了升级能力。

最后的解法是:自己写一个非短路求值器。

它直接吃我们定义的那套原生 JSON 条件结构,算子实现从注册表里取,遍历所有叶子节点,产出带路径的 trace 树:

ts 复制代码
export interface TraceLeaf {
  kind: 'leaf'
  jsonPath: string      // $.conditions.all[1].any[0]
  fact: string
  operator: OperatorKey
  result: boolean
  state: 'ok' | 'missing' | 'error'
  message?: string
}

jsonPath 是关键------结果面板可以直接把每个叶子条件钉在对应的 UI 位置上。

那真正的引擎呢?它不参与业务求值,只出现在契约对等测试里。这个取舍我觉得比"为了用而用"更诚实:引擎负责证明我们的输出格式是标准的,我们的求值器负责提供更好的调试体验。

如果你也在做类似的技术选型,我建议先写一个"最坏情况"的验收用例。 我们当时写的是:"一条 AND 规则有 5 个条件,第 2 个为 false,后续 3 个条件的结果必须仍然可见。"这个用例直接判了 engine.run() 的死刑------它连基本信息都给不了。用这种方式做技术选型,比看文档里列的功能表可靠得多。


五、三态语义:missing 和 error 必须分开

叶子求值结果不能只有 true / false 两态。实际写规则时最容易出问题的是另外两种情况:

  • missing :字段不存在。用户写了个 alarmVal(少个 u),或者 facts 里根本没这个字段。这时候数值比较得到 undefined > 50 是 false,但"字段缺失"和"条件不成立"是两回事,必须分开提示。
  • error :算子本身出错。比如 between 的值配成了 [1] 这种非二元数组,求值直接抛异常。

完整的叶子求值状态流转如下:

ts 复制代码
if (value === undefined && !NULLABLE_OPERATORS.has(leaf.operator)) {
  return { ...base, state: 'missing', message: `字段 ${leaf.fact} 缺失` }
}
return { ...base, result: operatorFns[leaf.operator](value, leaf.value), state: 'ok' }

注意 isNull / isNotNull 是白名单:它们对 undefined 的判断本来就是合法语义,不该被标成 missing。

单个叶子报错时,不能中断其他叶子的求值------用户需要看到全貌才能定位问题。

结果面板里每个叶子右边都会标出状态,绿色「正常」就是 ok:

你会发现路径列直接显示了 $.conditions.all[1].any[0] 这样的 JSONPath。这个设计让用户和开发者能用同一种语言沟通问题------用户说"第 2 个条件的 OR 组第 1 条不对",开发者可以直接去 JSON 里定位。


六、fail-closed:错误绝不允许被翻成"命中"

这是我在实际测试中撞出来的一个 bug,想单独拎出来讲。

考虑这条规则:NOT(between 配了个坏值)。

坏值导致叶子求值 error,result 为 false。然后 NOT 组对它取反------布尔结果变成了 true。

规则"命中"了。但实际上它什么都没检查,是个幽灵触发。如果这条规则的事件是"切负荷",后果不用我多说。

修法是给每个组节点加一个后序聚合的 hasError:只要子树里存在任意 error 叶子(或空组),这个标记就为 true,并且透传穿过 NOT。

最终命中判定标准统一改成:

ts 复制代码
const isMatched = nodeResult(trace) && !nodeHasError(trace)

布尔结果和错误状态是两个正交的维度,任何布尔运算都不应该翻转错误的语义。

顺带处理了空组:{ all: [] } 在引擎构造期就会抛 "must contain at least one child condition",所以我们的求值器也对齐成 result = false 且标 error,同时导入校验对任意层级的空组报 EMPTY_CONDITIONS。

这个 bug 给我们的教训是:错误处理不能只做在"正常路径的旁边",它得是求值模型里的一等公民。 我们后来把这条规则写进了团队规范:任何布尔聚合函数,都必须显式声明它对错误输入的处理策略,是透传、短路还是降级。不能默认"错误会被上层自然处理掉"。


七、编辑即预览:300ms 防抖 + 手动对比

本地求值不需要用户点任何按钮。规则或 facts 一变,300ms 防抖后自动跑一次,防抖期间显示"求值中"。

耗时显示实测是 0.7~0.9ms(见截图里右上角的数字),因为纯内存计算,不需要等网络。

注意结果区中间的被滤除标记。规则集有四种冲突策略:

策略 行为 适用场景
PRIORITY_ONLY 只保留优先级最高的匹配规则,同优先级取顺序靠前者 分级告警(默认)
FIRE_ALL 全部匹配规则都执行,按 priority 降序 多个动作可叠加
FIRST_WIN 规则顺序里第一条命中的,不看 priority 短路的告警分流
LAST_WIN 规则顺序里最后一条命中的 覆盖式语义

截图里"220kV主变过载保护"其实是匹配上了的,但被 PRIORITY_ONLY 策略滤掉了。这个信息必须展示出来------否则用户会困惑"我这条规则明明满足条件为什么不生效"。

冲突筛选的完整流程:

筛选我写成了一个纯函数,本地预览和后端 Mock 共用同一份实现,保证两边结果永远一致:

ts 复制代码
export function resolveConflicts(matched: MatchedRule[], strategy: ConflictStrategy) {
  // 前置:规则名唯一性由校验层(DUP_RULE_NAME)保证
  // PRIORITY_ONLY → 取优先级最高(同优先级取 order 靠前者)
  // FIRST_WIN / LAST_WIN → 按 order 取首/末
  // FIRE_ALL → 全部保留,按 priority 降序
}

这里踩过一个坑:suppressed 是按规则名去重统计的。 如果规则集里出现两条同名规则,被滤除的统计就会错乱。所以我们在校验层加了 DUP_RULE_NAME,把"规则名唯一"变成整个冲突策略模块的前置条件。这类隐式依赖如果不写下来,后面接手的人很容易破坏它------我们在函数注释里专门标注了这一点。


八、双引擎并排对比:提前发现语义差异

前面说了本地引擎和后端 Drools 是两套执行链路。它们的操作符语义并不完全等价------这才是这个项目最大的风险点。

所以对比面板不是"顺便做的功能",而是核心设计目标。

后端接口没就绪之前,用 axios-mock-adapter 在浏览器里模拟实现,返回完全同构的响应(包括 50~150ms 随机延迟、示意性的 DRL 文本):

每条规则按名字并排展示本地和远程的判定结果,一致标"一致",不一致的行高亮并给出可能原因。

之所以能做到"给出可能原因",是因为我们维护了一张操作符注册表------单一事实源:

ts 复制代码
{ key: 'contains', label: '数组包含', factTypes: A, valueKind: 'scalar',
  drools: 'contains', compatibility: 'caution',
  note: '方向:fact 为数组、value 为标量' }

每个操作符记录:适用的字段类型、值的形状、Drools 等价写法、兼容等级(一致/需注意/不支持)和差异说明。这张表同时被三处使用:

  1. 条件行的操作符下拉(按字段类型过滤)
  2. 导入时的值形状校验(valueKind → BAD_VALUE)
  3. 对比面板的差异归因

改一处,三处同步。

Mock 里还内置了一个 diff 场景开关,会故意注入一条语义差异,方便演示和测试对比高亮的效果。

关于"两端语义差异"这件事,我的建议是别指望测试能兜住。 我们的做法是:把兼容等级 caution 的操作符在界面上直接标出来,让业务人员在写下这条规则的那一刻就知道"这个写法在后端可能表现不同"。把风险暴露在编辑期,比上线后 catch 一个静默错误划算太多。


九、其他几个值得一说的细节

撤销重做用快照而非命令模式。 深树操作的逆命令实现成本太高且容易出错,而规则集体积是 KB 级的,50 份 structuredClone 快照内存开销可以忽略。连续输入不逐字符入栈,值编辑在 500ms 防抖后合并成一次快照。

输入防抖要防止"写回后又触发一次"。 我们写了个 useDebouncedCommit,内置一个 lastWritten 基线:

ts 复制代码
const shouldWrite = (): boolean =>
  !equals(draft.value, read()) && !equals(draft.value, lastWritten)

用来识别"内容其实已经写回过了",避免撤销/重做之后重新编辑同一个值被静默吞掉。外部源变化时同步重置基线。这个坑不实际写一遍是想不到的。

导入必须可撤销。 用户导入 JSON 文件时给了"覆盖当前"和"合并追加"两个选项,重名规则自动加序号后缀,并且整次导入作为一次结构性编辑压入撤销栈。误点导入的代价太大了。

JSONPath 解析只访问自有属性。 resolvePath 用 hasOwnProperty 判定,constructor / __proto__ 这类原型链成员一律返回 undefined,避免从用户导入的文件里被钓出原型污染。

最后是那个"编辑即得原生 JSON"的实时预览面板,它让整个链路的输出始终可见:


十、小结

回头看,这个项目真正花时间的不是界面,而是下面这几件事:

  1. 把"输出什么格式"想清楚------选引擎原生 JSON 而不是自定义中间格式,让后续所有环节都变简单了。
  2. 接受引擎不满足需求,自己写求值器------而不是硬把需求塞进一个不合适的工具里。
  3. 把错误当成一等公民------三态 trace + fail-closed 闸门,避免配置错误变成生产事故。
  4. 让差异可见------操作符注册表作为单一事实源,双引擎并排对比把语义差异提前暴露在编辑期,而不是上线后。

最后一点感触:让业务人员自助维护规则,本质上是在把隐式的领域知识显式化。界面上每一个"为什么没命中"的提示、每一个"被滤除"的标记,都是在替他们回答那些原本要问开发的问题。这部分体验做扎实了,工具才真的有人用。


附:项目代码结构

css 复制代码
src/
├── types/       类型模型(RuleSet / ConditionNode / NativeCondition)
├── utils/       全部纯函数:求值、转换、校验、冲突、历史、路径
│   ├── evaluate.ts    非短路求值器(三态 trace)
│   ├── ruleJson.ts    双向转换 + 结构校验
│   ├── conflict.ts    四种冲突策略筛选
│   ├── operators.ts   操作符注册表(单一事实源)
│   └── tree.ts        结构共享的不可变树操作
├── components/  ConditionGroup 递归组件等 18 个组件
├── stores/      Pinia:规则集状态 / 求值视图 / DoorLS 客户端
├── composables/ 防抖、防抖提交、示例引导
└── mock/        axios-mock-adapter 内置 Mock(normal / diff / compile-error 三场景)

逻辑全部下沉到 utils/ 纯函数,组件保持薄------这也是为什么 utils/** 能做到 100% 行覆盖,而组件测试只覆盖真实交互。

技术栈版本:Vue 3.5 / TypeScript 5.9 / Vite 5.4 / Pinia 2.3 / Element Plus 2.14 / Vitest 2.1,json-rules-engine-upgraded 锁定精确版本 1.0.1。

相关推荐
Daorigin_com1 小时前
道本科技携手DeepSeek:以AI重塑合同全生命周期管理
前端·人工智能·科技·网络安全·数据挖掘·前端框架·传媒
flash俊杰1 小时前
自动更新工程:electron-updater、差分更新、灰度发布与失败回滚
前端
fastjson_1 小时前
帆软看板 - 问题收集
linux·前端·javascript
用户83134859306981 小时前
Vue3 v-bind 使用指南,从基础到高阶
前端·javascript·vue.js
计算机魔术师1 小时前
Anthropic冲刺2万亿IPO:一年亏420亿,还要再砸5180亿算力
前端
GISer_Jing1 小时前
前端Agent架构师指南:从零搭建智能前端Agent
前端·ai·langchain
不停喝水2 小时前
【前端转全栈java速通课】 项目实战④-1 Spring Boot 创建项目-定义接口-请求参数处理-分层规范-依赖注入
java·前端·spring boot
涛涛ing2 小时前
当AI能写原生代码:Shopify用12周把React Native应用迁回了Swift和Kotlin
前端