基于KOV与依赖图的联动规则组件设计与实现
做中后台表单和流程配置的同学应该都有过这种经历:业务方跑过来说“这个字段选了 A,下面那几个就不要再填了;选了 B,金额输入框必须出现,而且默认带出上一步的值”。你听完觉得不难,随手写了几个if,结果第二周规则加到二十条,字段开始互相打架,页面一刷新顺序不一样,隐藏的字段还在提交校验里报错。这篇就是我围绕联动规则组件做的一轮 KOV 场景梳理和技术方案沉淀,把散落在各个业务里的联动需求收拢成一套可配置、可校验、可回放的机制。
KOV 是 Key-Operator-Value 的缩写,也就是“哪个字段、做什么判断、和什么值比”这个三元组。它是联动规则的原子单位,把一句话规则拆成机器能算、人能看懂的结构。联动规则组件要干的事情很明确:业务侧用 KOV 把条件拼出来,运行时由组件算出每个字段该显示还是隐藏、禁用还是可编辑、必填还是选填、值该不该被清掉、下拉选项要不要按前置条件过滤。适合谁来读?正在做表单引擎、流程设计器、低代码配置面板的前端同学,以及被“字段联动”这四个字坑过一次、不想被坑第二次的人。下面我按场景梳理、结构设计、核心实现、配置态设计、问题排查的顺序讲,中间会贴结构定义和关键算法,能直接抄的部分我都标出来了。
1. 先把“联动”这件事拆干净:KOV 到底覆盖哪些场景
在做技术方案之前我踩过最大的坑,就是一上来就写代码,结果写到一半发现“联动”这个词下面藏了五六种完全不同的东西,它们的数据结构、求值时机、渲染影响都不一样。所以第一件事是把场景盘清楚,这也决定了后面组件的抽象边界在哪里。
1.1 KOV 三元组的本质:把一句话规则变成可计算的数据
业务说“当订单类型为预付费且金额大于一万时,审批人字段必填”,这句话里其实有两个 KOV:订单类型 eq 预付费、金额 gt 10000,中间用“且”连接,然后接一个动作“审批人 required”。所以一条完整规则由三部分组成:条件树(Condition)、动作列表(Action)、优先级与开关(Meta)。KOV 描述的是条件树里最末端的叶子节点。
为什么一定要拆成三段而不是直接存一个表达式字符串?我做过对比。存表达式字符串的好处是表达力强,坏处是:配置态没法做可视化回显,用户改一个值你得解析字符串再拼回去;校验态没法做字段级定位,报错只能报“表达式非法”;安全上更麻烦,等于把一段可执行代码交给业务配置人员。而 KOV 结构化之后,每个字段都能被单独校验、单独翻译成自然语言、单独做权限控制。代价是表达力受限,需要通过组合(与/或/非、嵌套组)来补。实测下来,九成以上的业务联动用 KOV 加两层嵌套就够用了,剩下那一成我会留一个“高级表达式”后门,但要求走审批。
三元组里最容易出问题的是 Operator 这一端。很多人一开始只做eq和ne,跑到第三个业务就顶不住了。我现在的做法是把操作符做成注册表,按字段类型分组挂载,数字类型挂gt/gte/lt/lte/between,字符串挂contains/startsWith,枚举挂in/notIn,日期挂区间比较,集合类型挂hasAny/hasAll。操作符不是写死的 switch,而是带元信息的一个对象:需要几个值、值从哪个字段类型来、是否支持空值。这样配置面板可以按字段类型自动渲染出正确的操作符下拉,运行时也只需要查表调用。
还有一点容易被忽略:KOV 的 Value 端可能不是一个常量。真实场景里大量存在“A 字段等于 B 字段”这样的比较,或者“金额大于上一步的预算”。所以 Value 端要支持两种形态:字面量(Literal)和引用(Reference)。字面量走类型转换,引用走路径寻址。这个设计在第一天就要留好口子,否则后面改起来会波及整个求值链路。
1.2 六类高频联动场景盘点
我把过去几个项目里出现过的联动需求做了归类,大致落在下面六类里。这张表是我跟产品对齐需求时的底稿,基本上一张表就能说清楚组件的能力边界。
| 场景类别 | 典型描述 | 影响目标 | 求值时机 | 实现复杂度 |
|---|---|---|---|---|
| 显隐联动 | 选了“其他”才出现备注框 | 字段可见性 | 依赖值变更时 | 低 |
| 禁用联动 | 审批通过后所有字段置灰 | 字段可编辑性 | 状态/依赖值变更 | 低 |
| 必填联动 | 金额超阈值时审批人必填 | 校验规则 | 依赖值变更时 | 中 |
| 赋值联动 | 选了城市自动带出区域编码 | 字段值 | 依赖值变更时 | 中 |
| 选项联动 | 二级分类选项按一级分类过滤 | 选项数据源 | 依赖值变更 + 异步 | 高 |
| 结构联动 | 选“多主体”才展示子表单区块 | DOM 结构/子表单实例 | 依赖值变更时 | 高 |
前四类是刚需,几乎每个表单都有;选项联动之所以复杂,是因为它牵扯异步请求和竞态,用户快速切换一级分类时,慢回来的那个请求可能把新选项覆盖掉,这个坑我在两个项目里都踩过,后面会用请求序号来解决;结构联动最麻烦,因为它不只是改属性,还要处理子表单的实例化、销毁和值回收,一旦子表单被隐藏,里面已经填的数据是保留还是清空,必须跟业务明确约定,不能自己拍脑袋。
这里要说一个容易漏掉的边界:联动的触发源不只是用户输入。初始化赋值、接口回填、草稿恢复、跨步骤跳转,这些都会让字段值发生变化,进而触发规则。如果只在onChange里跑规则,那接口回填的数据就不会触发联动,页面一进来就是错的。正确的做法是把“值变更”统一收敛到一个setFieldValue的入口,规则求值挂在这个入口上,不管值从哪来,路径都一样。
1.3 为什么不直接给业务一段 JS 表达式
这个方案我认真评估过,结论是:作为兜底可以,作为主方案不行。理由有三个。
第一是配置态不可逆。业务配了一段form.a === 1 && form.b.length > 2,你想在界面上把它回显成一个可视化的条件树,得写一个解析器;业务想改其中一个值,你得做 AST 变换再序列化回去。这个成本比一开始就结构化高得多。
第二是安全与可控性。让配置人员写任意 JS,等于把整个页面的执行环境交出去,哪怕做了沙箱,new Function这类东西在主流程里出现,安全评审那一关就过不去。就算是内部系统,我也倾向于把口子收窄,只开放白名单函数。
第三是排查成本。线上出问题的时候,结构化规则我可以直接打日志:规则 ID、条件求值结果、动作执行结果,一眼看出是哪条规则没生效。表达式字符串只能打一个“结果 false”,然后人肉去猜。我们后来做的规则追踪面板,就是建立在这个结构化前提上的。
所以我最终的选择是:主链路用结构化 KOV,配一个受限的表达式字段作为逃生舱,表达式只允许引用当前表单的字段路径和一组白名单函数(长度、包含、日期差之类),不开放任意 JS。
2. 数据结构与技术方案选型:把规则设计成可演进的 DSL
结构定义这一步如果偷懒,后面每一次需求变更都会让你改核心代码。我在第二个项目里就吃过这个亏:一开始规则是平铺的数组,{field, op, value, action, target},跑得挺好,直到业务要“满足 A 且(B 或 C)”,平铺结构直接废掉,只能推倒重来。所以这次我一开始就按树形结构设计。
2.1 规则 DSL 的字段设计
条件树用递归结构,节点分两种:逻辑组(and/or/not)和叶子(KOV)。动作列表挂在规则上,一条规则可以带多个动作。下面是我实际用的类型定义,简化了一部分业务字段。
type FieldPath = string; // 形如 "base.amount" 或 "items[0].sku" type OperatorId = | 'eq' | 'ne' | 'in' | 'notIn' | 'gt' | 'gte' | 'lt' | 'lte' | 'between' | 'contains' | 'startsWith' | 'regex' | 'isEmpty' | 'isNotEmpty' | 'changed'; interface KovLeaf { kind: 'leaf'; key: FieldPath; operator: OperatorId; value: LiteralValue | ReferenceValue; // 常量或字段引用 } interface LogicGroup { kind: 'group'; logic: 'and' | 'or' | 'not'; children: ConditionNode[]; } type ConditionNode = KovLeaf | LogicGroup; interface RuleAction { type: 'visible' | 'hidden' | 'disable' | 'enable' | 'required' | 'optional' | 'setValue' | 'clearValue' | 'setOptions' | 'filterOptions'; target: FieldPath; payload?: unknown; // setValue 的值、filterOptions 的过滤策略 } interface KovRule { id: string; name: string; // 给业务看的自然语言名,必填 enabled: boolean; priority: number; // 数值越大越优先,冲突时决定谁赢 when: ConditionNode; then: RuleAction[]; scope?: string; // 作用于哪个区块/子表单 remark?: string; }有几个字段是我特意加的,值得单独说。name强制必填,因为线上排查的时候你只会看到规则 ID,没有可读名字根本不知道这条规则是干嘛的;我们后来强制要求名字里带上业务语义,比如“预付费单必填审批人”,效果立竿见影。priority是为了解决动作冲突,两条规则同时要求某个字段“必填”和“选填”,没有优先级就只能靠数组顺序,而数组顺序在可视化编辑器里是会被拖动的,非常不可靠。scope是为了支持子表单,同名字段在不同子表单实例里路径不同,作用域能把规则限制在某个分支内。
then设计成数组而不是单个动作,也是为了减少规则数量。业务常见的一句话是“选了 A 就把 B 隐藏、把 B 的值清掉、把 C 设为必填”,拆成三条规则会让配置界面上出现大量重复条件,维护起来很痛苦。一条规则带多个动作,既省配置又保证这几个动作是同一轮求值里执行的,不会出现中间态。
2.2 三条技术路线,我为什么最后选了混合方案
前端做联动,常见三条路:事件总线、依赖图、表达式求值。我在不同项目里都用过,各自的脾气差别很大。
| 方案 | 实现方式 | 优点 | 致命问题 | 适用阶段 |
|---|---|---|---|---|
| 事件总线 | 字段变更时 emit,规则订阅 | 上手快,解耦好 | 触发顺序不可控,容易连锁触发循环 | 规则少于 5 条的早期 |
| 依赖图 | 建字段依赖有向图,拓扑序求值 | 顺序确定,可检测环 | 需要维护图,动态规则要重建 | 规则 10 条以上 |
| 表达式求值 | 全量重算所有条件 | 实现简单,无状态 | 性能差,无法定位单条规则 | 小表单、规则极少 |
事件总线的问题我在第一个项目里体验得很彻底:A 变触发 B 赋值,B 赋值触发 C 显隐,C 显隐又触发一条规则去改 A,页面直接卡死,控制台刷屏。你没法从代码上判断谁先谁后,只能靠加setTimeout硬凑顺序,这就是典型的用时间换正确性,最后两边都没保住。
纯表达式求值的思路是每次值变更就把所有规则的条件重算一遍,逻辑上永远正确,因为是无状态全量计算。小表单没问题,字段上百、规则几十条的时候,每次输入都跑一遍全量,输入框会明显掉帧。
我最终采用的是依赖图 + 增量求值的组合:编译期(规则保存时)把规则解析成依赖图,运行时按拓扑序分层求值,值变更时只重算受影响的下游节点。这个方案的正确性来自拓扑序——同一层内的节点互不依赖,可以并行算;层与层之间严格顺序,不会被赋值风暴打乱。性能来自增量——改一个叶子字段,只波及它的下游子图,跟规则总数无关。
2.3 编译期与运行期分离:一次编译,多次执行
这里有个设计决策值得展开。规则的解析不该放在每次渲染时做,因为规则是配置态产物,运行期基本不变。我把它拆成两个阶段:
编译期发生在规则集加载或保存时。做的事包括:语法校验(字段路径是否存在、操作符是否匹配字段类型、值类型是否合法)、生成依赖图(谁依赖谁)、拓扑排序、环检测、把规则翻译成自然语言描述用于回显。编译结果是一份CompiledRuleSet,包含图结构、拓扑层级、以及每条规则的执行函数闭包。
运行期只做两件事:接收值变更事件,按拓扑层级调度执行;把动作结果合并成一份渲染指令表(RenderInstruction),交给渲染层应用。
这个拆法还有一个好处:编译结果可以缓存。同一份规则在列表页和详情页都用,只要规则 ID 和版本号没变,直接用缓存,省掉一次解析。我们线上规则集平均 30 条左右,解析一次大概几毫秒,缓存之后首屏基本感知不到。
3. 核心实现拆解:从字段寻址到渲染落地
方案定了之后,真正花时间的是细节。这一段我把几个关键环节的实现摊开讲,包括路径寻址、依赖图构建、环检测、求值引擎和渲染应用,中间会贴核心算法。
3.1 字段路径寻址与 Schema 注册
表单数据是嵌套的,规则里的key必须是能唯一定位到某个字段的路径。我用的是点号加数组下标的形式:base.amount、items[0].sku、approval.chain[2].approver。解析路径的代码不长,但必须做得足够稳,因为它是所有求值的基础。
function resolvePath(data: any, path: string): unknown { const tokens = path .replace(/\[(\d+)\]/g, '.$1') .split('.') .filter(Boolean); let cur = data; for (const t of tokens) { if (cur == null) return undefined; cur = cur[t]; } return cur; }配套的是 Schema 注册表。每个字段在渲染时把自己注册进去,登记路径、类型、当前值、可用的操作符、变更回调。规则求值时不直接读表单实例,而是读注册表,这样组件和表单库解耦,换成任何表单方案都能接。
这里有个坑要说清楚:数组下标路径是不稳定的。子表单里删掉第一行,原来items[1]的数据会变成items[0],规则如果绑死了下标就会串位。我的处理是子表单内部的规则使用相对路径,作用域限定在实例内,由实例自己去匹配;跨实例的规则只允许引用实例的聚合属性,比如items.length、items.sum(amount)。这个约定写进了配置规范,禁止业务直接写下标。
3.2 依赖图构建与拓扑排序
依赖图的构建逻辑很直接:遍历所有规则,规则when里引用到的每个字段路径,都指向then里每个动作的target。边方向是“被依赖字段 -> 受影响字段”。同一条规则内部的多个 KOV,边都指向所有动作目标。
function buildGraph(rules: KovRule[]) { const nodes = new Set<string>(); const edges = new Map<string, Set<string>>(); // from -> to const addEdge = (from: string, to: string) => { if (from === to) return; // 自依赖,规则内部问题,单独告警 if (!edges.has(from)) edges.set(from, new Set()); edges.get(from)!.add(to); nodes.add(from); nodes.add(to); }; for (const rule of rules) { if (!rule.enabled) continue; const sources = collectLeafKeys(rule.when); const targets = rule.then.map(a => a.target); for (const s of sources) for (const t of targets) addEdge(s, t); } return { nodes, edges }; }拓扑排序我用的是 Kahn 算法,按入度分层出队。分层的好处是:同一层的字段可以合并成一次渲染指令下发,避免中间态闪烁;跨层严格顺序,保证求值结果的确定性。这里必须强调“确定性”——同样的输入必然得到同样的输出,跟规则的存储顺序无关。之前用事件总线的时候,改一下规则的数组顺序页面表现就变了,这种不确定性在做配置化产品时是灾难。
function topoLayers(nodes: Set<string>, edges: Map<string, Set<string>>) { const indeg = new Map<string, number>(); nodes.forEach(n => indeg.set(n, 0)); edges.forEach((tos) => tos.forEach(t => indeg.set(t, (indeg.get(t) ?? 0) + 1))); const layers: string[][] = []; let frontier = [...nodes].filter(n => (indeg.get(n) ?? 0) === 0); const visited = new Set<string>(); while (frontier.length) { layers.push(frontier); const next: string[] = []; for (const n of frontier) { visited.add(n); for (const t of edges.get(n) ?? []) { const d = (indeg.get(t) ?? 0) - 1; indeg.set(t, d); if (d === 0) next.push(t); } } frontier = next; } return { layers, hasCycle: visited.size < nodes.size }; }3.3 环检测与告警:配置态就要拦住,不能等到线上
环是联动系统里最隐蔽的问题。A 的显隐依赖 B,B 的赋值依赖 A,这在单条规则看都没问题,合起来就是死循环。Kahn 算法天然能检测环——如果最后访问过的节点数小于总节点数,剩下的就是环上的节点。
关键在于什么时候检测。我的做法是配置态实时检测:用户在规则编辑器里每加一条规则,就在本地重建一次图跑一遍检测,发现环立刻在编辑器顶部弹提示,指出哪几个字段构成了环,并且禁止保存。上线前的规则集如果是从别处导入的,加载时再跑一次,历史上遗留下来的环也能兜住。
光拦住还不够,还得让业务看得懂。所以提示文案我做了本地化处理:把字段路径翻译成字段标签,输出成“审批状态 -> 审批人 -> 审批状态 之间存在循环依赖,请调整规则顺序或拆分条件”。这句话比“检测到循环引用”有用得多,业务能自己改。
还有一种隐性环拓扑检测抓不到:字段赋值触发了自身所在层的重算。比如 A 变触发 B 赋值,B 又出现在 A 的条件里。这种情况在图上是有边的(B -> A),拓扑排序会把它排到后面一层,不会无限循环,但会导致求值结果依赖执行轮次。我的处理是给赋值动作加“只在新值与原值不同时才真正写入”的判断,值相同直接跳过,绝大多数空转就此消失。
3.4 求值引擎:操作符体系与惰性求值
求值引擎的核心是一个递归函数,处理 LogicGroup 的短路求值和 KovLeaf 的操作符调用。短路这件事在小表单上感知不到,规则一多就是实打实的性能差异——一个and组里第一个条件为假,后面的引用和取值都不该发生。
function evalNode(node: ConditionNode, ctx: EvalContext): boolean { if (node.kind === 'group') { if (node.logic === 'and') { for (const c of node.children) if (!evalNode(c, ctx)) return false; return true; } if (node.logic === 'or') { for (const c of node.children) if (evalNode(c, ctx)) return true; return false; } return !evalNode(node.children[0], ctx); } const left = ctx.getValue(node.key); const right = isReference(node.value) ? ctx.getValue(node.value.ref) : node.value.literal; const op = ctx.operatorRegistry.get(node.operator); if (!op) { ctx.warn(`未注册的操作符: ${node.operator}`); return false; } return op.test(left, right, ctx); }操作符注册表里每一项都是一个带元信息的对象,实现上考虑空值语义。这块特别容易出 bug:金额 gt 10000在金额为空的时候应该返回什么?不同业务的预期不一样。我的约定是:除了isEmpty/isNotEmpty/changed之外,所有操作符遇到左侧为undefined/null/''一律返回false,并且不报错。这样“没填”天然不满足任何比较条件,规则不生效,符合直觉。如果业务需要“没填也算满足”,就显式配一条isEmpty的规则,别让操作符自己去猜。
还有changed这个操作符需要特殊处理。它不比较值的大小,而是判断“本轮是否发生过变更”,需要引擎维护一个脏字段集合。这个操作符的用途是“用户主动改过这个字段才校验”,避免初始化时一堆必填项全红。
3.5 渲染层落地:把规则结果转成指令表
求值引擎输出的是动作列表,渲染层不能直接在渲染过程中改状态,那样会触发 React 的重复渲染警告,也容易产生中间态。我的做法是让引擎先累积成一份指令表,这一轮所有层的求值都跑完之后,一次性下发。
interface RenderInstruction { visible: Map<FieldPath, boolean>; disabled: Map<FieldPath, boolean>; required: Map<FieldPath, boolean>; values: Map<FieldPath, unknown>; // setValue / clearValue 的合并结果 options: Map<FieldPath, OptionFilter>; // 选项过滤策略 }合并规则要说清楚:同一字段多条规则给冲突指令时,按规则priority降序取第一个,优先级相同则按规则 ID 字典序(保证确定性)。可见性和必填是布尔值直接覆盖;赋值是后者覆盖前者,但清空(clearValue)优先级高于赋值,因为隐藏字段的残留值是最常见的脏数据来源。
这里有一条我认为必须固化的原则:字段被隐藏时,它的值不参与提交,但也不立刻清空。原因很简单,用户切到 A 填了一堆,切回 B 又切回来,数据不该丢。真正的清空发生在提交前的裁剪阶段:提交时按指令表把不可见字段剔除,再送后端。但如果业务明确要求“切换就清空”,就在规则里显式加clearValue,语义由配置表达,而不是组件偷偷决定。
选项联动要单独处理异步。我的方案是给每次选项请求带一个自增序号,回来的时候比对当前序号,不匹配就丢弃。这个技巧简单但极其有效,比 AbortController 兼容性好,也不会因为有多个字段共用同一个数据源而互相干扰。
3.6 性能:增量重算与渲染合并
规则少的时候性能不是问题,规则多了之后,真正的开销不在条件计算,而在三处:一是值变更引发的全量重算,二是每个字段的订阅回调各自触发一次重渲染,三是选项联动的重复请求。
第一处的解法是增量。值变更时,从依赖图里取出该字段的所有下游节点,只重算这些节点涉及的规则,而不是全量。实现上用edges做一次 BFS 拿到受影响集合,再与规则索引求交。实测 40 条规则的表单,单次输入的重算量从 40 条降到 3 到 5 条,输入明显跟手了。
第二处的解法是合并。同一轮里的所有指令合并成一次状态更新,React 18 的自动批处理能帮一部分忙,但跨await的更新不会自动合并,所以我在引擎里显式做了批次管理:一轮求值开始时收集,结束时一次性提交。
第三处的解法是缓存加去重。同一个数据源、同一组参数,短时间内的请求直接走缓存;参数变了但序号过期,回包丢弃。我们还加了最小请求间隔,防止用户拖动选择器时疯狂打接口。
4. 配置态设计:让业务自己配,而不是每次都找开发
组件做出来只是第一步,更大的挑战是让业务能自己用。我们第一版把规则配成了 JSON 编辑器,结果业务看一眼就跑了,最后还是开发代劳。后来重做了配置界面,规则配置量才真正转移到业务侧。
4.1 规则编排器的交互结构
现在的结构是“规则列表 + 规则详情”两栏。左边一列是规则的卡片列表,每张卡片显示规则名、条件摘要(自动翻译成中文)、开关和优先级;右边是详情编辑区,条件部分用“条件组”的方式嵌套,一组内可以选择“全部满足”或“任一满足”,组可以继续嵌套。嵌套层级我限制在三层,超过三层人的理解成本就超过收益了,不如拆成多条规则。
条件行的交互是三个下拉加一个输入:选字段、选操作符、填值。操作符下拉按字段类型动态过滤,值输入框按字段类型切换控件——日期字段出日期选择器,枚举字段出下拉,引用模式下切换成“选择字段”而不是“填值”。这个随类型切换的细节看着小,实际体验差别很大,业务不用去记“日期要写成什么格式”。
动作部分做成勾选式:勾了“隐藏”就自动禁用“必填”选项,因为隐藏且必填是自相矛盾的配置。这类互斥校验前置到 UI 上,比保存后再报错友好得多。我们统计过,配置阶段的报错里有六成是这类逻辑矛盾,拦在输入环节能省掉大量沟通。
4.2 冲突检测与可读性回显
冲突检测分三类。第一类是字段级冲突:同一字段被两条优先级相同的规则要求“显示”和“隐藏”。第二类是可达性冲突:某字段被规则永久隐藏,但又被另一条规则设为必填,这条规则永远不会生效。第三类是引用失效:条件里引用的字段被删掉了。
检测逻辑在保存时跑,结果以警告形式列出,不阻断保存但要求确认。为什么不一刀切禁止?因为有些冲突是有意为之的,比如节假日临时加一条规则覆盖默认逻辑,这种靠优先级解决,不算错误。但警告必须给,让人知道自己做了什么。
回显这块我做了一个“规则转自然语言”的函数,把条件树翻译成中文句子,显示在卡片上。比如“当 订单类型 等于 预付费 且 金额 大于 10000 时,将 审批人 设为必填”。这个功能看起来是锦上添花,实际使用中极大降低了维护成本,业务扫一眼列表就知道自己配了什么,不用点进去逐条看。
function describe(node: ConditionNode): string { if (node.kind === 'group') { const joiner = node.logic === 'and' ? ' 且 ' : node.logic === 'or' ? ' 或 ' : ' 非 '; return node.children.map(describe).join(joiner); } const label = getFieldLabel(node.key); const opText = getOperatorLabel(node.operator); const valText = isReference(node.value) ? getFieldLabel(node.value.ref) : formatValue(node.value.literal); return `${label} ${opText} ${valText}`; }4.3 规则版本与灰度
规则是配置,配置就会改错。所以规则集我做了版本号,每次保存生成一个快照,线上运行时按版本号加载。出问题可以回滚到上一个版本,这个能力在真实故障里救过我们一次——某次一条规则把必填项全关了,导致一批单据带着空字段提交,回滚之后五分钟恢复。
灰度这块做得比较简单:规则集带一个生效范围字段,先在某些业务线上启用新版本,观察一段时间没问题再全量。规则命中的日志会上报(规则 ID、命中次数、动作类型),跑一天就能看出哪些规则是死规则、哪些规则命中率高得异常。死规则定期清理,异常规则重点看,这套机制比人工 review 有效得多。
5. 常见问题与排查实录
下面这些问题都是我在实际项目里真遇到过的,整理成速查表方便对照。表后面挑几条展开讲排查过程。
| 现象 | 可能原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 字段该隐藏却一直显示 | 规则未命中/优先级被覆盖 | 开规则追踪,看条件求值结果 | 调优先级或修条件 |
| 页面输入卡顿 | 全量重算或渲染未合并 | 打点统计单次求值耗时 | 改增量,合并指令 |
| 选项错乱 | 异步回包乱序 | 打印请求序号与当前序号 | 加序号比对,丢弃过期回包 |
| 初始化时联动不生效 | 只在 onChange 触发规则 | 检查赋值入口 | 收敛到统一 setValue |
| 隐藏字段仍报错 | 校验未读指令表 | 看校验器是否过滤不可见 | 提交前按指令裁剪 |
| 页面偶发卡死 | 规则成环 | 拓扑排序检测 | 配置态拦截,禁止保存 |
| 子表单串位 | 用了绝对下标路径 | 检查路径是否含[n] | 改相对路径 + 作用域 |
5.1 赋值风暴:一次输入触发几十次更新
这个问题的表现是输入框打字明显卡顿,Performance 面板里看到一秒内几十次 render。根因是赋值联动链路过长:改 A 触发 B,B 变化触发 C,C 又触发 D,每一跳都独立走一次状态更新。修复思路是把整条链压到一轮里跑完。
具体做法是给引擎加一个“事务”概念:进入求值时开启事务,所有setValue不直接写状态,而是写进事务内的待提交队列;一轮拓扑求值全部结束后,统一提交并只触发一次渲染。这个改动之后,五跳的链条从五次 render 降到一次,输入延迟肉眼可见地消失了。要注意的是事务内的取值必须读“待提交值”而不是“已提交值”,否则后面的规则会读到旧值,这是实现时最容易写错的地方。
另外补一个经验:赋值动作一定要做等值判断。setValue写入的值跟当前值相同就直接跳过,不要触发下游。很多空转都是这么来的——规则本意是“跟随”,但每次输入都重新赋一遍相同的值。
5.2 隐藏字段的脏数据:提交时才暴露
这个坑很典型。字段被隐藏了,界面上看不见,用户以为不填也行,结果提交的时候后端报“必填字段为空”,或者更糟,把一个之前填过的旧值悄悄提交上去了。根因是隐藏只改了渲染,数据层没动。
我的处理分两步。第一步,隐藏时不清值,但标记为“非活跃”,规则引擎在后续求值中跳过非活跃字段的条件。第二步,提交前执行裁剪,按当前指令表把所有不可见字段从提交数据里剔除,同时剔除它们的校验结果。这里要注意校验器必须读同一份指令表,不能自己去判断可见性,否则两边口径不一致又会出现“隐藏了但还在校验”的情况。
还有一种边界情况:字段被隐藏,但它的值被另一条规则引用作为条件。这时候值不能剔除,否则条件读不到。所以裁剪只发生在提交数据副本上,不影响运行时状态。
5.3 选项联动的竞态:慢请求覆盖新结果
用户先把一级分类从“电子”切到“服装”,电子类的二级选项请求慢,回来得晚,把服装的选项覆盖了,用户看到的是错的列表,选了一个不存在的组合。这个 bug 在测试环境几乎复现不了,因为网络快,线上才出现。
修法是请求序号。每次发起请求时seq++并记录到局部变量,回包时比对localSeq === currentSeq,不等就丢弃。代码就三行,但能彻底解决。我还加了一个兜底:选项回包后检查当前一级分类是否还是发起请求时的那个,不等也丢弃。双保险,因为有时候序号管理会因为组件重渲染而失效。
顺带说一句缓存。同样的参数短时间内重复请求是浪费,我按“字段路径 + 参数序列化”做 key 缓存,有效期设得比较短(30 秒左右),既能挡住连续切换,又不会让用户在后台改了配置之后长时间看到旧数据。
5.4 规则越配越多:治理比功能更重要
做到后期真正的痛点不是技术,是规则数量失控。一个表单配上四五十条规则,没人敢动,因为不知道删了会影响什么。我们做了两件事:一是规则命中埋点,跑一个月之后统计每条规则的命中次数,零命中的直接归档;二是依赖可视化,选中某个字段能看到它被哪些规则引用、又被哪些动作影响,删除前先看影响面。
还有一条规范我觉得挺值:规则必须写清楚名字和备注,否则保存时提示。听起来像形式主义,实际效果很好。半年后回来看,一条叫“规则 23”的东西没人看得懂,一条叫“预付费单金额超一万需二级审批”的,一眼就明白。
6. 这套方案上线之后我的几点实际体会
整套东西从梳理场景到上线大概花了两个多月,中间返工过一次数据结构。回头看,最值的几个决策都不是技术上多精巧,而是边界划得清。
把 KOV 拆成结构化数据这件事,短期看是给自己找麻烦,要写编译器、要写回显、要写校验,但长期看所有能力都建立在这上面,包括规则追踪、自然语言回显、冲突检测、命中统计,没有一个是靠表达式字符串能低成本做出来的。
拓扑序求值这个决定,当时还有同事建议用setTimeout分层来“模拟”顺序,被我否了。用时间换顺序,在本地跑得通,一到线上机器负载高了就乱,而且乱了之后没有任何手段能定位。确定性这件事,在配置化系统里是不能妥协的。
还有一个我开始没太在意、后来发现很重要的点:规则执行的可观测性。我们后来加了一个调试面板,开发环境默认打开,能看到每条规则的命中情况、条件求值的中间结果、动作执行的顺序。这个面板在排查问题时省的时间,远比开发它花的时间多。如果你的表单规则超过二十条,我建议一开始就把这个口子留好,哪怕先只打日志。
最后提一句扩展方向。这套结构其实不只能用在表单上,流程节点跳转条件、列表页的筛选联动、报表的参数联动,本质都是 KOV 加动作,差异只在动作的执行器上。我们后来把动作执行器做成可插拔的,表单场景注册“显隐/赋值”执行器,流程场景注册“跳转/驳回”执行器,规则编辑器和求值引擎完全复用。这个复用带来的收益,比我预想的要大得多。