动态表单真正复杂的部分,通常不是把一个输入框渲染出来,而是让一组表单项在业务变化中保持一致。
例如在项目配置表单中,用户将“交付方式”选择为“外部协作”后,页面可能需要立即发生几项变化:
外部供应商字段变为可见外部供应商字段变为必填内部负责人字段不再参与校验结算方式字段根据交付方式设置一个默认值
这些变化看似属于不同功能:显示隐藏、校验、初始化、数据清理。实际上它们都是同一类问题:一个字段的当前值,改变了另一些字段在当前业务上下文中的运行状态。
如果每类变化分别写在组件事件中,页面最终会充满彼此覆盖的 if 判断。动态表单框架需要把这类关联统一为可计算、可追踪的联动规则。
联动处理的是字段的运行时状态
在配置层中,一个字段拥有稳定定义:
{ name: "vendorId", title: "外部供应商", component: "VendorSelect"}
但在页面运行过程中,它还会根据其他字段产生一组动态属性:
{ visible: true, valid: false, defaultValue: undefined}
因此,可以将动态表单中的内容区分为三部分:
schema:字段本身的定义,以及字段之间的依赖规则values:用户当前输入或已经回填的数据fieldState:根据 schema 与 values 计算出来的字段运行时状态
layout 仍然负责字段在页面上的分组和位置,但它渲染一个字段前,需要读取该字段当前是否可见、是否禁用或是否存在错误。
配置条件需要统一表达操作符
业务中的条件并不只有“等于”。数字、枚举、数组和文本字段都需要一套稳定的操作符模型:
| 操作符 | 使用场景 | 示例 |
|---|---|---|
eq / neq | 枚举、布尔值、单值文本 | 交付方式等于外部协作 |
gte / lte / gt / lt | 数字或日期区间 | 预算金额大于等于 100000 |
contains / notContains | 多选值或文本集合 | 项目标签包含重点项目 |
empty / notEmpty | 是否已经填写 | 审批流程为空时设置默认值 |
一条联动规则可以由“依赖字段、操作符、比较值和目标效果”组成:
{ target: "vendorId", effect: "visible", conditions: [ { field: "deliveryMode", operator: "eq", value: "outsourced" } ]}
同样的条件表达方式可以用于各类动态字段属性:
const rules = [ { target: "vendorId", effect: "visible", conditions: [ { field: "deliveryMode", operator: "eq", value: "outsourced" } ] }, { target: "vendorId", effect: "required", conditions: [ { field: "deliveryMode", operator: "eq", value: "outsourced" } ] }, { target: "endDate", effect: "valid", conditions: [ { field: "endDate", operator: "gte", fieldValue: "startDate" } ], message: "结束日期不能早于开始日期" }, { target: "workflowId", effect: "defaultValue", value: "standard-review", conditions: [ { field: "projectType", operator: "eq", value: "standard" }, { field: "workflowId", operator: "empty" } ] }];
操作符必须由规则引擎集中实现,并针对数值、日期、数组和空值定义比较语义。否则同一个“包含”规则在不同组件里被各自解释,联动结果会难以排查。
字段属性并不是同一种配置
一项字段包含的配置并不完全相同。默认值、必填性、有效性和可见性分别具有自己的默认行为和动态能力:
| 属性 | 配置方式 | 是否可能写入字段值 |
|---|---|---|
| 默认值 | 空默认值、固定默认值、条件默认值 | 是 |
| 必填性 | 非必填、固定必填、条件必填 | 否 |
| 有效性 | 默认有效、条件有效 | 否 |
| 可见性 | 默认可见、条件可见 | 否 |
例如,固定默认值可以为新建表单中的“优先级”提供初始选择;条件默认值可以在项目类型为标准项目时建议某个审批流程;条件必填可以在开启外部协作时要求用户选择供应商。
这几类配置中,条件必填、条件有效和条件可见本质上是在根据 values 推导字段状态。它们只影响校验与展示,不必修改用户数据。条件默认值不同,它会真正写入字段内容,从而可能继续命中其他字段的条件规则。
可见性变化时要决定值是否保留
可见性是最容易实现,也最容易留下数据问题的一种联动。
假设“是否需要外部供应商”被关闭,供应商字段随之隐藏。如果隐藏以后仍把旧的 vendorId 提交到服务端,数据可能与界面表达不一致;但如果每次隐藏都立刻删除值,当用户只是暂时切换选项再切回来时,又会丢掉刚才的输入。
因此,条件可见规则需要同时定义值策略:
{ target: "vendorId", effect: "visible", conditions: [ { field: "deliveryMode", operator: "eq", value: "outsourced" } ], hiddenValuePolicy: "ignoreOnSubmit"}
这里的 hiddenValuePolicy 只是表达思路:字段隐藏后,可以选择提交时忽略、继续保留,或在明确业务要求下清理。如果隐藏动作会立即清理字段值,它就从纯展示规则变成了值写入规则,需要参与循环检测。
有效性需要在依赖变化后重新验证
有效性并不只是判断字段是否已经填写。一个字段即便已有内容,也可能因为它依赖的条件变化而不再合法。
例如项目周期中,条件有效可以直接配置为结束日期必须大于等于开始日期。用户先选择了结束日期,再修改开始日期:
开始日期:2023-10-20结束日期:2023-10-18
结束日期并非为空,但已经无效。类似情况还包括:
- 切换部门以后,之前选择的负责人不再属于可选成员。
- 切换项目类型以后,之前选择的审批流程不再适用。
- 修改预算类型以后,已经填写的金额超出新规则范围。
条件有效如果只标记错误状态而不自动清值,本身不会改变其他规则的输入。联动引擎应在依赖源变化时,重新验证受到影响的字段,而不是等到整个表单最终提交时才发现错误:
const dependencies = { endDate: ["startDate"], ownerId: ["departmentId"], workflowId: ["projectType"]};function onFieldChange(changedName, values) { const affectedFields = findAffectedFields(changedName, dependencies); return validateFields(affectedFields, values);}
及时反馈能够让用户理解是哪一次操作使现有值失效,也避免填完大量内容以后才集中暴露问题。
默认值需要区分来源与生命周期
默认值看起来只是赋值操作,但它与条件必填、条件可见、条件有效最大的不同是会写入 values,从而成为其他条件的新输入。如果处理不当,不仅会覆盖用户已经做出的选择,也可能触发下一轮联动。
首先需要区分三种默认值:
| 默认值类型 | 含义 | 应用时机 |
|---|---|---|
| 空默认值 | 字段开始时没有初始内容 | 不写入任何值 |
| 固定默认值 | 字段初始化时直接使用一项固定内容 | 新建表单初始阶段应用一次 |
| 条件默认值 | 满足配置条件时为字段选择初始内容 | 目标字段仍处于可自动初始化状态时应用 |
例如选择某类项目后,系统可以默认推荐一个审批流程:
{ target: "workflowId", effect: "defaultValue", value: "standard-review", conditions: [ { field: "projectType", operator: "eq", value: "standard" }, { field: "workflowId", operator: "empty" } ]}
关键在于,默认值应被理解为系统帮助初始化字段,而不是持续纠正字段当前值。系统需要为值记录来源:
const fieldMeta = { workflowId: { touched: false, source: "conditionalDefault", appliedRuleId: "standard-workflow-rule" }};
source 至少需要区分 empty、fixedDefault、conditionalDefault、user 和 loaded。其中 user 代表用户已经主动输入,loaded 代表编辑已有数据时从服务端回填的内容。这两类值都不应被条件默认值静默覆盖。
如果业务希望“条件变化时,系统推荐值可以同步更新”,也只能针对仍来源于系统默认、且没有被用户编辑过的字段进行更新。一旦用户手动选择过审批流程,后续条件变化应保留用户决定;如果该选择已不满足条件有效规则,应提示用户处理,而不是偷偷换成另一项默认值。
不是所有互相依赖都会产生循环
仅仅出现 A 依赖 B、B 又依赖 A,并不一定会造成运行时死循环。关键要看规则是否写入值。
例如下面两条规则只计算可见状态:
A 在 B 等于 "show" 时可见B 在 A 等于 "enabled" 时可见
只要隐藏字段不被自动清空,规则引擎读取同一份 values 后输出 fieldState 即可。即使依赖图成环,结果仍然是一次状态计算,不会反复修改数据。
条件有效也类似:
折扣金额在折扣类型不等于 "none" 时有效折扣类型在折扣金额大于 0 时有效
如果无效仅展示提示、不自动清理字段值,那么两条规则同样可以基于当前数据同时计算。
真正需要警惕的是以下三种写值行为:
条件默认值命中后向目标字段写入数据字段隐藏时自动清空当前值字段变为无效时自动清空或重置当前值
它们都会改变后续条件的输入,因此可能把一个普通依赖环变成不断触发的执行环。
条件默认值最容易形成反复写入
如果默认值仅在字段为空且从未被用户修改时写入一次,它通常是可控的。危险情况是将“条件默认值”理解为“只要条件命中,就持续覆盖字段当前值”。
例如两项配置互相决定默认值:
const rules = [ { target: "fieldA", effect: "defaultValue", value: "on", conditions: [{ field: "fieldB", operator: "eq", value: "off" }] }, { target: "fieldA", effect: "defaultValue", value: "off", conditions: [{ field: "fieldB", operator: "eq", value: "on" }] }, { target: "fieldB", effect: "defaultValue", value: "on", conditions: [{ field: "fieldA", operator: "eq", value: "on" }] }, { target: "fieldB", effect: "defaultValue", value: "off", conditions: [{ field: "fieldA", operator: "eq", value: "off" }] }];
如果 fieldB 初始为 off,规则可能按下面的路径反复变化:
fieldB = off -> fieldA 默认成 onfieldA = on -> fieldB 默认成 onfieldB = on -> fieldA 默认成 offfieldA = off -> fieldB 默认成 off回到第一步
这不是字段有双向依赖就必然发生,而是因为默认值规则可以反复覆盖已有值。比较可靠的约束是:
固定默认值只在新建表单初始化时写入一次条件默认值只作用于空值或仍由系统默认产生的值从服务端回填或由用户修改过的值不得被自动覆盖同一轮计算中,一个字段最多接受一次默认值写入
可见性或有效性配合清值也会形成环
条件可见与条件有效原本只影响字段状态,但如果系统约定“不可见就清值”或“无效就清值”,它们也会成为写入规则。
例如:
B 只有在 A 等于 "enable" 时可见,隐藏时自动清空 BA 在 B 为空时条件默认值为 "enable"
如果系统在每次状态推导中都重新应用默认值和清值,配置继续扩展后就容易出现:
B 被隐藏并清空 -> A 的默认条件命中 -> A 改变导致 B 显示另一条条件使 A 失效并清空 -> B 再次隐藏并清空 -> 默认规则再次命中
同样的问题也会出现在条件有效中:一个字段变无效后被清空,清空又使另一个字段命中默认值,后者反过来改变前一个字段是否有效。
所以,显示与校验状态最好保持为纯计算结果。确需清理数据时,应将清理动作定义为明确的业务策略,并与默认值写入一起进入循环检测。
将计算分成初始化与交互两个阶段
要避免默认值带来不可控的反复计算,还需要区分页面刚打开和用户开始操作后的执行策略。
新建表单初始化时,可以按稳定顺序执行:
1. 建立空的 values 与字段元信息2. 为配置了固定默认值的字段写入初始值3. 基于当前 values 计算条件必填、条件可见与条件有效4. 对满足条件、且仍可自动初始化的字段应用条件默认值5. 如果默认值发生了写入,重新计算受影响的属性6. 当本轮没有新值写入时结束初始化
编辑已有表单时,应先载入服务端数据并将其标识为 loaded,而不是重新用默认值覆盖已有业务事实。
用户开始修改字段以后,执行策略需要更保守:
1. 将本次用户输入写入 values,并标识 source = "user"2. 根据变化字段寻找受到影响的规则3. 重新计算条件必填、条件可见与条件有效4. 只对仍为空或仍来源于系统默认的目标字段考虑条件默认值5. 默认值写入发生后,仅继续计算被它影响的规则6. 输出最终 fieldState 并通知 layout 渲染
这样既保留了配置驱动的自动能力,也保证用户和服务端已有值不会在联动过程中被覆盖。
联动规则需要有稳定的执行顺序
当一次修改同时影响多个属性时,执行顺序十分重要。例如用户将交付方式从“内部”改为“外部”:
1. 更新 deliveryMode 的字段值2. 找到依赖 deliveryMode 的字段3. 重新计算这些字段的条件必填、条件可见与条件有效状态4. 按值策略处理被隐藏或变为无效的字段5. 判断默认值是否需要应用6. 对受到影响且当前有值的字段重新校验7. 通知 layout 渲染最新字段状态
这一过程可以抽象为统一的状态推导入口:
function evaluateForm(schema, values, meta, changedField) { const affected = collectDependencies(schema, changedField); let nextValues = { ...values }; let nextState = computeFieldState(schema, nextValues, affected); nextValues = applyValuePolicies(nextValues, nextState, meta); nextValues = applyDefaultsToPristineFields(nextValues, nextState, meta); nextState = validateAffectedFields(schema, nextValues, nextState, affected); return { values: nextValues, fieldState: nextState };}
这样,条件必填、条件可见、条件有效与条件默认值就不会由几套互不知道彼此存在的逻辑分别更新。
配置发布前检查包含写值行为的依赖环
配置驱动的联动能力越强,越需要在发布配置前检查依赖关系。检测时不能只检查“谁读取了谁”,还需要标记“谁可能写入谁”:
只读边:A 的值影响 B 是否必填、可见或有效写值边:A 的值命中规则后,为 B 设置默认值隐式写值边:A 的值导致 B 隐藏或无效,并按策略清空 B
只读边形成环通常可以接受;包含写值边的强连通环应当告警,存在可反复覆盖或自动清值行为时应禁止发布。例如:
A 的条件默认值依赖 B,B 的条件默认值又依赖 AA 的条件默认值依赖 B,A 的值又控制 B 隐藏后清空A 的默认值导致 B 无效清空,B 为空又命中 A 的另一条默认规则
框架还需要对运行时执行提供限制:
- 明确操作符、依赖方向和值策略,形成可检查的依赖图。
- 默认值写入必须幂等:目标值与当前值相同时不生成新的变更事件。
- 记录值来自空值、固定默认值、条件默认值、用户输入还是服务端回填。
- 条件默认值只写入可自动初始化的字段,不覆盖
user或loaded值。 - 条件可见和条件有效默认只输出状态,不自动清值。
- 确需自动清值时,将其标记为写值规则参与环检测。
- 一次联动事务中记录
(field, value, source, ruleId)的写入轨迹,发现重复状态即判定不收敛。 - 对一次计算设置最大轮数,发现状态仍在变化时中止自动写入并上报配置链路。
- 在配置发布前提供规则校验或调试视图,帮助业务排查联动链路。
对于大型动态表单来说,可观察性与渲染能力同样重要。仅能执行规则却无法说明某个字段为什么被隐藏、为什么突然变成必填、为什么获得一个默认值,会让维护成本迅速上升。
运行时需要计算到稳定状态而不是无限触发事件
默认值写入后的重新计算是合理的,因为新的值确实可能改变其他字段属性。问题不在于“重新计算”,而在于计算无法收敛。
因此,联动引擎更适合以一轮事务求稳定状态,而不是每次 setValue 都直接触发一条新的不受控更新链:
function settleForm(schema, initialValues, initialMeta, changedField) { let values = initialValues; let meta = initialMeta; const history = new Set(); for (let round = 0; round < 20; round += 1) { const state = computeDerivedState(schema, values); const result = applyEligibleDefaults(schema, values, meta, state); const signature = createSignature(result.values, result.meta); if (!result.changed) { return { values, meta, state }; } if (history.has(signature)) { throw new Error("条件默认值配置无法收敛"); } history.add(signature); values = result.values; meta = result.meta; } throw new Error("条件联动计算超过最大轮数");}
这里的最大轮数不是用来掩盖错误配置,而是避免错误配置拖垮页面。正常规则应当在很少几轮内稳定下来;无法稳定的配置需要被定位并修复。
双层配置下的职责边界
在 schema 与 layout 分离的动态表单体系中,字段联动更适合按以下方式协作:
| 能力 | 主要归属 |
|---|---|
| 字段名称、类型、默认值类型与必填配置 | schema |
| 操作符、字段依赖及必填/有效/可见状态推导 | schema 的规则层 |
| 分组、分栏与区块展示结构 | layout |
| 按最新可见性渲染或跳过字段 | layout 渲染器 |
| 用户输入值、值来源与是否手动修改过 | 运行时状态 |
如果实际系统将部分显隐逻辑配置在 layout,核心原则仍然不变:联动的计算结果需要统一,渲染与校验不能各自推导一份不一致的状态。
小结
动态表单中的条件联动,本质上是在维护业务条件变化后的字段一致性:
必填性决定当前场景必须补全哪些信息可见性决定当前页面应该让用户看到什么有效性决定已有输入还能不能继续使用默认值决定系统可以在何时帮助用户完成初始化
把字段配置、条件规则与操作符放入统一的依赖和计算流程中,并将状态推导与默认值写入明确区分,表单才能既灵活又可靠。默认值造成重新计算并不可怕,真正需要预防的是覆盖用户意图和无法收敛的写值环。对企业级项目而言,这比简单地通过配置生成输入框更重要,因为真正决定用户能否顺利完成任务的,往往正是这些彼此关联的业务规则。