前端知库

动态表单怎样设计?schema 驱动、字段联动和联动校验怎么做?

动态表单用一份 schema 描述有哪些字段、用什么组件、什么时候显示、怎样校验,渲染器按 schema 生成表单。联动写成声明式条件,比如“type 等于 company 时显示”,由引擎根据当前值算出每个字段的状态。联动校验指一个字段的规则依赖另一个字段,比如结束日期不能早于开始日期,要在依赖字段变化时重新校验,隐藏的字段不参与校验和提交。

参考答案

简答

比如一个报销后台,几十种单据,每种的字段都不一样。如果每种单据写一个页面,改一次规则就要发一次版。动态表单的做法是用一份 schema,也就是一段 JSON,描述有哪些字段、每个字段用什么组件、什么时候显示、怎样校验,前端写一个渲染器,按 schema 把表单画出来。

字段联动是说一个字段的值会影响另一个字段,比如类型选了“企业”才显示“企业名称”,选了“个人”就隐藏。我会把联动写成声明式条件,像 { field: 'type', equals: 'company' },而不是一段代码。引擎每次拿当前表单值把所有条件算一遍,得到每个字段是否显示、是否必填。这样配置能存进数据库,也不用执行配置里的字符串。

联动校验是一个字段的校验规则依赖别的字段,比如结束日期不能早于开始日期。所以校验函数要拿到整个表单的值,而且依赖的字段变了,要把依赖它的字段重新校验一遍,否则错误提示会停在旧状态。

要注意两点。第一,隐藏的字段不参与校验,提交时也要把它的值去掉,否则用户看不见的字段会挡住提交,或者把过期的值发给后端。第二,前端校验只是为了体验,后端必须用同一套规则再校验一次。

schema 长什么样

下面这份 schema 描述一个简化的开票表单。看三处:visibleWhen 决定显示,requiredWhen 决定必填,rules 里的 afterField 是依赖另一个字段的校验。

JavaScript
const schema = {
  version: 1,
  fields: [
    { name: 'type', component: 'Select', label: '抬头类型', required: true,
      options: [{ value: 'person', label: '个人' }, { value: 'company', label: '企业' }] },
    { name: 'companyName', component: 'Input', label: '企业名称',
      visibleWhen: { field: 'type', equals: 'company' }, required: true },
    { name: 'taxId', component: 'Input', label: '税号',
      visibleWhen: { field: 'type', equals: 'company' },
      rules: [{ pattern: '^[0-9A-Z]{18}$', message: '税号为 18 位数字或大写字母' }] },
    { name: 'start', component: 'DatePicker', label: '开始日期', required: true },
    { name: 'end', component: 'DatePicker', label: '结束日期', required: true,
      rules: [{ afterField: 'start', message: '结束日期不能早于开始日期' }] },
  ],
};

每个字段只有数据,没有函数。为什么要这样?因为 schema 通常由后台配置、存在数据库里,函数没法存成 JSON。条件写成对象,渲染器还能在不运行的情况下分析出字段之间谁依赖谁,下面的重新校验就要用到这一点。

组件名只能从注册过的组件里选,配置里也不能写可执行代码,这部分安全上的约束见《低代码和 Schema 驱动 UI 怎样保持可维护与安全?》。

联动怎样算出来

联动的核心是一个纯函数:输入 schema 和当前表单值,输出每个字段的状态。下面的 resolveFields 算出每个字段是否显示,validate 只校验显示的字段,collectSubmit 只提交显示的字段。

JavaScript
const schema = {
  fields: [
    { name: 'type', required: true },
    { name: 'companyName', required: true, visibleWhen: { field: 'type', equals: 'company' } },
    { name: 'start', required: true },
    { name: 'end', required: true, rules: [{ afterField: 'start', message: '结束日期不能早于开始日期' }] },
  ],
};

function matches(cond, values) {
  if (!cond) return true;
  return values[cond.field] === cond.equals;
}

function resolveFields(schema, values) {
  return schema.fields.map((f) => ({ ...f, visible: matches(f.visibleWhen, values) }));
}

function validate(schema, values) {
  const errors = {};
  for (const f of resolveFields(schema, values)) {
    if (!f.visible) continue; // 隐藏的字段不校验
    const v = values[f.name];
    if (f.required && (v === undefined || v === '')) {
      errors[f.name] = '必填';
      continue;
    }
    for (const rule of f.rules ?? []) {
      // 联动校验:规则要读另一个字段的值
      if (rule.afterField && values[rule.afterField] && v < values[rule.afterField]) {
        errors[f.name] = rule.message;
      }
    }
  }
  return errors;
}

function collectSubmit(schema, values) {
  const data = {};
  for (const f of resolveFields(schema, values)) {
    if (f.visible && values[f.name] !== undefined) data[f.name] = values[f.name];
  }
  return data;
}

const values = { type: 'company', start: '2026-05-10', end: '2026-05-01' };
console.log(JSON.stringify(validate(schema, values)));
// {"companyName":"必填","end":"结束日期不能早于开始日期"}

values.type = 'person';
values.companyName = '旧的企业名';
values.end = '2026-05-20';
console.log(JSON.stringify(validate(schema, values))); // {}
console.log(JSON.stringify(collectSubmit(schema, values)));
// {"type":"person","start":"2026-05-10","end":"2026-05-20"}

第一次校验,类型是企业,“企业名称”显示了但没填,所以报必填;结束日期比开始日期早,所以报联动校验的错。

切成个人以后,“企业名称”隐藏了。它虽然还留着“旧的企业名”这个值,但校验跳过了它,提交的数据里也没有它。为什么不直接在切换时把值清掉?因为用户可能只是误点,切回企业时还希望看到刚才填的内容。保留值、提交时按可见性过滤,两种需求都能满足。

日期用 YYYY-MM-DD 格式的字符串,按字符串比较大小正好和日期先后一致,所以示例直接用 < 比较。真实项目里日期格式不固定时,应该先转成时间戳再比。

依赖的字段变了,怎样重新校验

上面的 validate 每次都校验整张表单,字段少时没问题。字段多了以后,一般只校验刚改动的字段。可是联动校验会出问题:用户先填了结束日期 5 月 20 日,没有报错;再把开始日期改成 5 月 25 日,如果只校验“开始日期”,结束日期上就不会出现错误提示。

所以要根据 schema 建一张反向依赖表,记下“哪个字段被谁依赖”:

JavaScript
const fields = [
  { name: 'type' },
  { name: 'companyName', visibleWhen: { field: 'type', equals: 'company' } },
  { name: 'start' },
  { name: 'end', rules: [{ afterField: 'start' }] },
];

function buildDependents(fields) {
  const map = {};
  const link = (from, to) => {
    if (!map[from]) map[from] = [];
    if (!map[from].includes(to)) map[from].push(to);
  };
  for (const f of fields) {
    if (f.visibleWhen) link(f.visibleWhen.field, f.name);
    for (const rule of f.rules ?? []) {
      if (rule.afterField) link(rule.afterField, f.name);
    }
  }
  return map;
}

const dependents = buildDependents(fields);
console.log(JSON.stringify(dependents)); // {"type":["companyName"],"start":["end"]}

function fieldsToRevalidate(changed) {
  return [changed, ...(dependents[changed] ?? [])];
}
console.log(fieldsToRevalidate('start').join(',')); // start,end
console.log(fieldsToRevalidate('end').join(',')); // end

改了 start,要重新校验的是 start 和 end;改了 end,只校验它自己。这张表就是从 schema 里的条件对象读出来的,条件写成代码的话就分析不出来。

依赖还可能连成链,比如 A 决定 B 是否显示,B 又被 C 的规则引用。这时要沿着依赖表一直往下找,直到没有新的字段为止;如果配置里出现了 A 依赖 B、B 又依赖 A 的环,渲染器应该在加载 schema 时就报错,而不是运行时无限循环。

和 JSON Schema 怎样配合

上面的 visibleWhen 管的是界面怎么显示,提交的数据长什么样,可以再用 JSON Schema 描述,前后端共用。JSON Schema 有对应的条件关键字:dependentRequired 表示某个属性出现时,另外几个属性也必须出现;if、then、else 在 draft 7 引入,if 里的子 schema 通过时数据要满足 then,不通过时要满足 else。

比如“类型是企业时企业名称必填”,可以写成 if 判断 type 等于 company,then 里要求 companyName 必填。后端用同一份 JSON Schema 校验提交的数据,前端的规则写漏了,后端也能拦住。“结束日期不能早于开始日期”这类比较两个字段值的规则,可以在前后端各注册一个同名的校验函数,schema 里只写函数名。

状态放在哪里

表单值、每个字段的错误、是否被用户碰过(touched)这几样状态,和普通复杂表单是一样的管理方式,见《Vue 复杂表单怎样组织状态、校验和提交?》。动态表单多出来的只是“字段列表不再写死,而是从 schema 算出来”:显示状态、必填状态都是从“schema + 当前值”推导出的,不单独存一份,就不会出现两份状态对不上。

常见追问

异步联动怎么办,比如选了省份再去请求城市列表?

在 schema 里写数据源的名字和依赖字段,比如 options: { source: 'cities', dependsOn: 'province' },由前端注册好的函数去请求。省份变了就重新请求,并且只采用最后一次请求的结果,否则先发的慢请求晚回来会把新列表覆盖掉。原来选的城市不在新列表里时,要把它清空。

字段很多时性能怎么保证?

主要是减少重新计算和重新渲染的范围。用上面的依赖表,只重新计算受影响的字段;每个字段组件只订阅自己的值和状态,别的字段变了它不重新渲染。几百个字段的表单通常还要分组、分步骤,或者对长列表做虚拟滚动。

schema 升级了,旧数据还能打开吗?

schema 要带版本号,渲染器为每次结构变化写一个迁移函数,把旧版本一级一级升到新版本,迁移函数要有测试。已经提交的数据也要记下是按哪个版本的 schema 填的,回显时用对应的版本。