Skip to content
Go back

动态表单系统如何用 schema 与 layout 组织页面

企业级项目管理工具中存在大量表单:创建项目、配置流程、维护成员信息、设置交付内容,或者录入某一类业务数据。它们看起来都是输入控件的组合,但不同客户、不同流程和不同权限下,需要展示的字段及规则可能完全不同。

如果每新增一种表单都重新开发页面,代码很快会堆积大量相似结构。动态表单系统尝试解决的问题,是将易变化的业务描述从固定页面代码中抽离出来,通过配置驱动渲染、校验和交互联动。

这套业务框架在思路上与配置驱动表单方案相近,但实现中并不是将所有信息都压进一份配置,而是抽象了两层:

schema:描述字段、数据类型、校验和字段间业务规则layout:描述分组、排列、区块结构和页面展示方式

将两者分离以后,同样一组业务字段可以根据场景组织成不同页面,页面布局调整时也不必修改字段本身的定义。

schema 描述字段与业务规则

一份基础 schema 可以包含字段标识、组件类型、展示文案、默认值方式和校验规则:

const schema = {  fields: [    {      name: "projectName",      title: "项目名称",      component: "Input",      required: true    },    {      name: "ownerId",      title: "负责人",      component: "UserSelect",      required: true    },    {      name: "deliveryDate",      title: "计划交付时间",      component: "DatePicker",      defaultValueType: "empty",      requiredType: "optional",      validType: "valid",      visibleType: "visible"    }  ]};

它并不是把 JSX 换成 JSON 就结束了。schema 实际承担了表单数据协议的角色:哪些字段存在、使用哪种输入组件、值如何保存、默认值如何产生、校验如何描述,以及字段之间存在哪些业务依赖。

在当前业务模型中,字段属性可以拆成以下配置方式:

属性配置方式
默认值空默认值、固定默认值、条件默认值
必填性非必填、固定必填、条件必填
有效性默认有效、条件有效
可见性默认可见、条件可见

其中条件必填、条件有效和条件可见计算的是字段状态;条件默认值会写入真实数据,因此需要额外处理值来源、写入时机与循环依赖。

例如,项目名称无论被放在“基础信息”区域还是单独编辑弹窗中,它的数据字段、必填规则和提交字段名都不应随布局变化。这类稳定信息应留在 schema 中。

layout 决定字段如何被组织到页面中

如果所有字段都由 schema.fields.map() 顺序渲染,页面很快就只能表达最简单的一列表单。实际 To B 页面往往还需要分组、分栏、区块标题、折叠区域和不同操作入口。

layout 可以单独描述这些页面结构:

const layout = {  type: "Page",  children: [    {      type: "Section",      title: "基础信息",      columns: 2,      children: [        { type: "Field", name: "projectName" },        { type: "Field", name: "ownerId" }      ]    },    {      type: "Section",      title: "交付安排",      children: [        { type: "Field", name: "deliveryDate" }      ]    }  ]};

layout 中的 Field 只通过名称引用 schema 中已有字段。它不重复声明字段校验或提交规则,只回答这些字段在当前页面中如何排列。

这使页面可以自然支持几类变化:

同一套字段在创建页中完整展示,在快捷编辑中只展示部分字段不同角色使用不同布局,但提交的数据协议保持一致业务需要把字段重新分组或调整双列布局,而不影响数据处理逻辑

渲染器分别解析字段与布局

表单渲染不能依靠大量 if 判断字段类型。更可扩展的方法是维护一份组件注册关系:

const fieldComponents = {  Input,  Select,  DatePicker,  UserSelect,  AttachmentUpload};function FieldRenderer({ field, value, onChange }) {  const Component = fieldComponents[field.component];  if (!Component) {    return <UnsupportedField title={field.title} />;  }  return (    <FormItem title={field.title} required={field.required}>      <Component        value={value}        options={field.options}        onChange={nextValue => onChange(field.name, nextValue)}      />    </FormItem>  );}

业务添加新字段类型时,只需注册一个遵循统一输入输出协议的组件,不需要重新调整整个渲染器流程。

布局渲染器则遍历 layout,遇到字段引用时,从 schema 中取得真正的字段定义:

function LayoutRenderer({ node, fieldMap, values, onChange }) {  if (node.type === "Section") {    return (      <FormSection title={node.title} columns={node.columns}>        {node.children.map(child => (          <LayoutRenderer            key={child.name || child.title}            node={child}            fieldMap={fieldMap}            values={values}            onChange={onChange}          />        ))}      </FormSection>    );  }  if (node.type === "Field") {    const field = fieldMap[node.name];    return (      <FieldRenderer        field={field}        value={values[node.name]}        onChange={onChange}      />    );  }  return null;}

这样渲染过程是两步:layout 提供页面骨架,schema 提供每个字段的业务定义。二者各自变化时,影响范围比较明确。

配置、字段值与页面结构应当分离

schema 描述字段协议,layout 描述页面结构,表单数据描述当前输入。三者不能混成同一份可变对象,否则修改输入值时会污染配置,调整布局时也可能影响数据提交。

function DynamicForm({ schema, layout, initialValues }) {  const [values, setValues] = React.useState(initialValues);  const fieldMap = React.useMemo(    () => Object.fromEntries(schema.fields.map(field => [field.name, field])),    [schema]  );  function setFieldValue(name, value) {    setValues(current => ({      ...current,      [name]: value    }));  }  return (    <LayoutRenderer      node={layout}      fieldMap={fieldMap}      values={values}      onChange={setFieldValue}    />  );}

编辑已有数据时,服务端结果进入 values;字段协议变化时更新 schema;页面展示方式变化时更新 layout。这对长期运行的 To B 业务尤其重要,因为界面演进并不一定意味着后端数据协议同步改变。

真正困难的是字段联动

动态表单一旦落到实际业务中,很快会出现条件变化:

选择项目类型以后显示对应字段选择客户以后重新请求可选成员关闭某项能力以后隐藏并清理相关配置某个日期变化以后限制另一个日期范围不同权限用户看到不同可编辑字段

字段业务联动可以通过 schema 中的条件和操作符描述,例如交付方式等于外部协作时展示供应商字段:

{  target: "externalVendor",  effect: "visible",  conditions: [    {      field: "deliveryMode",      operator: "eq",      value: "outsourced"    }  ]}

条件不仅可以判断相等关系,也可以扩展为大于等于、小于、包含与不包含等操作符,并用于条件必填、条件可见、条件有效和条件默认值。需要注意的是,“一个字段是否展示”虽然会最终影响布局,但判断依据仍属于字段业务规则;layout 负责在字段可见后将它放到正确位置。涉及多个字段互相设置默认值,或者隐藏、无效后自动清值时,还需要防止联动规则产生循环计算。

校验既包含字段规则,也包含业务规则

必填、长度和格式属于单字段校验;项目周期、成员权限和流程条件往往需要跨字段甚至请求服务端检查:

async function validate(values, schema) {  const errors = validateRequiredFields(values, schema);  if (values.startDate && values.endDate &&      values.startDate > values.endDate) {    errors.endDate = "结束日期需要晚于开始日期";  }  if (values.ownerId) {    const canManage = await checkOwnerPermission(values.ownerId);    if (!canManage) {      errors.ownerId = "当前成员不能作为项目负责人";    }  }  return errors;}

渲染器负责统一呈现错误状态,业务服务负责判断特定规则,才能既保证一致体验,又不让底层框架承担所有业务知识。

schema 和 layout 需要分别版本化与降级

schemalayout 都会随业务变化,历史表单数据却需要继续被查看与编辑。因此需要考虑:

动态表单的价值,不只是少写几个页面,而是为大量变化中的业务表单建立一套稳定协议。schema 将字段和数据规则稳定下来,layout 让页面结构能够按场景演进;只有渲染、值管理、联动、校验和版本兼容都能够协作,配置驱动才真正具有长期意义。


Share this post on: